Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 46 additions & 15 deletions server/routes/forge.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ const FORGE_CLASH_MATCHES_COLLECTION = 'forgeClashMatches';
const PROFILE_COLLECTION = 'userProfiles';
const USERS_COLLECTION = 'users';

/**
* Collections that can hold the forged art for a Forge Clash rival, in
* lookup order. `rivalCards` is the dedicated rival art collection, while
* `adminBossAssets` is the Boss Assets library the Card Forge writes to when
* an admin saves a boss card (see `src/pages/cardForge/useForgeSave.ts`).
*/
const RIVAL_ART_COLLECTIONS = ['rivalCards', 'adminBossAssets'];
const RIVAL_ART_LAYER_KEYS = [
'backgroundImageUrl',
'characterImageUrl',
Expand Down Expand Up @@ -83,23 +90,41 @@ async function queryRivalArtCard(collectionRef, field, rival) {
* Resolves the forged-card art layers for a Forge Clash rival so the clash
* stage can render the rival with its actual skater imagery instead of the
* procedural placeholder. Looks for a forged card named after the rival in
* the dedicated `rivalCards` collection, then falls back to the static rival
* catalogue when no forged art is available.
* every rival art collection, then falls back to the static rival catalogue
* when no forged art is available.
*/
async function loadRivalArtLayers(adminDb, rivalDefinition) {
const rival = rivalDefinition?.signatureCard;
const rivalName = normalizeRivalName(rivalDefinition?.name);
if (rival && rivalName && adminDb) {
const rivalCardsRef = adminDb.collection('rivalCards');
const byIdentity = await queryRivalArtCard(rivalCardsRef, 'identity.name', rival);
const byName = byIdentity ?? await queryRivalArtCard(rivalCardsRef, 'name', rival);
if (byName) {
return pickRivalArtLayers(byName);
for (const collectionName of RIVAL_ART_COLLECTIONS) {
const collectionRef = adminDb.collection(collectionName);
const byIdentity = await queryRivalArtCard(collectionRef, 'identity.name', rival);
const artCard = byIdentity ?? await queryRivalArtCard(collectionRef, 'name', rival);
if (artCard) {
return pickRivalArtLayers(artCard);
}
}
}
return pickRivalArtLayers(rival);
}

/**
* Builds the rival payload the Forge Clash stage renders: the
* server-authoritative stat snapshot plus any forged art layers and the
* rival's flavour copy.
*/
function buildClashRival(rivalDefinition, rivalArtLayers) {
return {
...rivalDefinition.signatureCard,
...rivalArtLayers,
id: rivalDefinition.id,
tagline: rivalDefinition.tagline,
signatureTrait: rivalDefinition.signatureTrait,
dialogue: rivalDefinition.dialogue,
};
}

function badRequest(message) {
return Object.assign(new Error(message), { statusCode: 400 });
}
Expand Down Expand Up @@ -443,6 +468,19 @@ export function registerForgeRoutes(app, {
}
});

app.get('/api/forge/clash/rival', forgeRateLimit, authenticateForgeRequest, async (req, res) => {
try {
const rivalDefinition = getDistrictRival(FORGE_CLASH_RIVAL_ID);
if (!rivalDefinition) {
throw Object.assign(new Error('Jax Voltage is unavailable.'), { statusCode: 503 });
}
const rivalArtLayers = await loadRivalArtLayers(adminDb, rivalDefinition);
res.json({ rival: buildClashRival(rivalDefinition, rivalArtLayers) });
} catch (error) {
res.status(error.statusCode ?? 500).json({ error: error.message ?? 'Failed to load the Forge Clash rival.' });
}
});

app.post('/api/forge/clash/start', forgeRateLimit, authenticateForgeRequest, async (req, res) => {
if (!adminDb || typeof randomUUID !== 'function') {
res.status(503).json({ error: 'Forge Clash is not configured on this server.' });
Expand All @@ -466,14 +504,7 @@ export function registerForgeRoutes(app, {
const rivalArtLayers = await loadRivalArtLayers(adminDb, rivalDefinition);
const match = await adminDb.runTransaction(async (tx) => {
const roster = await loadValidatedClashRoster(tx, adminDb, rosterRefs, caller.uid);
const rival = {
...rivalDefinition.signatureCard,
...rivalArtLayers,
id: rivalDefinition.id,
tagline: rivalDefinition.tagline,
signatureTrait: rivalDefinition.signatureTrait,
dialogue: rivalDefinition.dialogue,
};
const rival = buildClashRival(rivalDefinition, rivalArtLayers);
const createdMatch = createForgeClashMatch({
id: matchId,
uid: caller.uid,
Expand Down
42 changes: 42 additions & 0 deletions server/test/forgeClashRoute.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,48 @@ test('Forge Clash start does not apply art from a rival card whose name does not
assert.equal(rival.joust.lance, 8);
});

test('Forge Clash start falls back to the Boss Assets library for rival art', async () => {
const harness = createHarness();
harness.adminDb.write('adminBossAssets/forged-jax-voltage', {
...buildCard('forged-jax-voltage', { name: 'Jax Voltage' }),
characterImageUrl: 'https://cdn.example.com/boss-jax-character.png',
backgroundImageUrl: 'https://cdn.example.com/boss-jax-background.png',
board: { imageUrl: 'https://cdn.example.com/boss-jax-board.png' },
});
const roster = seedCards(harness.adminDb, 'player-1');

const started = await harness.invoke('POST', '/api/forge/clash/start', { body: { roster } });

assert.equal(started.statusCode, 201);
const { rival } = started.body.match;
assert.equal(rival.characterImageUrl, 'https://cdn.example.com/boss-jax-character.png');
assert.equal(rival.backgroundImageUrl, 'https://cdn.example.com/boss-jax-background.png');
assert.equal(rival.board.imageUrl, 'https://cdn.example.com/boss-jax-board.png');
assert.equal(rival.joust.lance, 8);
});

test('Forge Clash rival endpoint returns the rival with its forged art layers', async () => {
const harness = createHarness();
harness.adminDb.write('adminBossAssets/forged-jax-voltage', {
...buildCard('forged-jax-voltage', { name: 'Jax Voltage' }),
characterImageUrl: 'https://cdn.example.com/boss-jax-character.png',
});

const unauthenticated = await harness.invoke('GET', '/api/forge/clash/rival', { authorization: '' });
assert.equal(unauthenticated.statusCode, 401);

const response = await harness.invoke('GET', '/api/forge/clash/rival');

assert.equal(response.statusCode, 200);
const { rival } = response.body;
assert.equal(rival.id, 'batteryville-jax-voltage');
assert.equal(rival.name, 'Jax Voltage');
assert.equal(rival.signatureTrait, 'Boost Charge');
assert.equal(rival.characterImageUrl, 'https://cdn.example.com/boss-jax-character.png');
assert.equal(rival.joust.lance, 8);
assert.equal(rival.joust.shield, 5);
});

test('official all-loaner Crews can play without claiming a card cosmetic', async () => {
const harness = createHarness();
harness.adminDb.write('userProfiles/admin-crew', { isAdmin: true });
Expand Down
62 changes: 56 additions & 6 deletions src/pages/ForgeClash.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ import type {
JoustTactic,
} from "../lib/types";
import {
fetchForgeClashRival,
fetchForgeComputerRivals,
playForgeClashTurn,
startForgeClash,
type ForgeClashMatch,
type ForgeClashRival,
type ForgeClashRound,
type ForgeClashTelegraph,
type ForgeLoanerCard,
Expand Down Expand Up @@ -277,8 +279,18 @@ function getRoundBreakdown(round: ForgeClashRound): string {
return `${advantage} · Lance ${round.breakdown.attack} vs Shield ${round.breakdown.defense} · Lane roll ${laneRoll}${finisher}${comboBonus} · Strike ${round.effectiveStrike}`;
}

function hasRivalArt(rival: ForgeClashRival | null): boolean {
if (!rival) return false;
return Boolean(
rival.characterImageUrl
|| rival.backgroundImageUrl
|| rival.frameImageUrl
|| rival.board?.imageUrl,
);
}
Comment on lines +282 to +290

function buildForgeClashRivalDisplayCard(
rival: ForgeClashMatch["rival"] | null,
rival: ForgeClashRival | null,
): CardPayload | null {
if (!rival) return null;
const district = rival.district ?? "Batteryville";
Expand Down Expand Up @@ -405,7 +417,7 @@ function RivalCard({
rival,
telegraph,
}: {
rival: ForgeClashMatch["rival"] | null;
rival: ForgeClashRival | null;
telegraph: ForgeClashTelegraph | null;
}) {
const intent = telegraph?.intent ?? "rush";
Expand Down Expand Up @@ -456,6 +468,7 @@ export function ForgeClash() {
const { user } = useAuth();
const { refreshWallet } = useWallet();
const [loanerCards, setLoanerCards] = useState<ForgeLoanerCard[]>([]);
const [rivalPreview, setRivalPreview] = useState<ForgeClashRival | null>(null);
const [loanersLoading, setLoanersLoading] = useState(true);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [draftTouched, setDraftTouched] = useState(false);
Expand Down Expand Up @@ -504,6 +517,24 @@ export function ForgeClash() {
};
}, [user]);

useEffect(() => {
let active = true;
if (!user) {
setRivalPreview(null);
return;
}
fetchForgeClashRival(user)
.then((rival) => {
if (active) setRivalPreview(rival);
})
.catch(() => {
if (active) setRivalPreview(null);
});
return () => {
active = false;
};
}, [user]);

const sortedOwnedCards = useMemo(
() => [...cards].sort((left, right) => computeCardWorth(right) - computeCardWorth(left)),
[cards],
Expand Down Expand Up @@ -560,17 +591,36 @@ export function ForgeClash() {
const guidedOpening = match?.status === "playing"
? getGuidedOpening(match.turn, match.maxHeat)
: null;
const rivalForDisplay = useMemo<ForgeClashMatch["rival"] | null>(() => {
if (match?.rival) return match.rival;
const rivalForDisplay = useMemo<ForgeClashRival | null>(() => {
const liveRival = match?.rival ?? null;
if (liveRival) {
// Matches started before the rival art was forged keep an art-less
// snapshot, so top the live rival up with the latest forged layers.
if (hasRivalArt(liveRival) || !hasRivalArt(rivalPreview) || rivalPreview?.id !== liveRival.id) {
return liveRival;
}
return {
...liveRival,
backgroundImageUrl: rivalPreview.backgroundImageUrl,
characterImageUrl: rivalPreview.characterImageUrl,
frameImageUrl: rivalPreview.frameImageUrl,
weaponImageUrl: rivalPreview.weaponImageUrl,
characterPlacement: rivalPreview.characterPlacement,
weaponPlacement: rivalPreview.weaponPlacement,
activeFrameId: rivalPreview.activeFrameId,
board: rivalPreview.board,
};
}
if (rivalPreview) return rivalPreview;
const rivalDef = getDistrictRival(FORGE_CLASH_RIVAL_ID);
if (!rivalDef) return null;
return {
...rivalDef.signatureCard,
tagline: rivalDef.tagline,
signatureTrait: rivalDef.signatureTrait,
dialogue: rivalDef.dialogue,
} as ForgeClashMatch["rival"];
}, [match?.rival]);
} as ForgeClashRival;
}, [match?.rival, rivalPreview]);
const canStart = Boolean(
user
&& selectedCrew.length === CREW_SIZE
Expand Down
51 changes: 31 additions & 20 deletions src/services/forge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ const FORGE_CLASH_START_API_URL = resolveApiUrl(
import.meta.env.VITE_FORGE_CLASH_START_API_URL as string | undefined,
"/api/forge/clash/start",
);
const FORGE_CLASH_RIVAL_API_URL = resolveApiUrl(
import.meta.env.VITE_FORGE_CLASH_RIVAL_API_URL as string | undefined,
"/api/forge/clash/rival",
);
const FORGE_CLASH_PLAY_API_URL = resolveApiUrl(
import.meta.env.VITE_FORGE_CLASH_PLAY_API_URL as string | undefined,
"/api/forge/clash/play",
Expand Down Expand Up @@ -124,6 +128,27 @@ export interface ForgeClashRewards {
};
}

export type ForgeClashRival = JoustCardSnapshot & {
tagline: string;
signatureTrait: string;
dialogue: {
intro: string;
win: string;
loss: string;
draw: string;
};
} & Partial<Pick<CardPayload,
| "backgroundImageUrl"
| "characterImageUrl"
| "frameImageUrl"
| "weaponImageUrl"
| "characterPlacement"
| "weaponPlacement"
| "activeFrameId"
>> & {
board?: { imageUrl?: string };
};

export interface ForgeClashMatch {
id: string;
status: "playing" | "completed";
Expand All @@ -137,26 +162,7 @@ export interface ForgeClashMatch {
heat: number;
cooldowns: Record<string, number>;
result: "win" | "loss" | "draw" | null;
rival: JoustCardSnapshot & {
tagline: string;
signatureTrait: string;
dialogue: {
intro: string;
win: string;
loss: string;
draw: string;
};
} & Partial<Pick<CardPayload,
| "backgroundImageUrl"
| "characterImageUrl"
| "frameImageUrl"
| "weaponImageUrl"
| "characterPlacement"
| "weaponPlacement"
| "activeFrameId"
>> & {
board?: { imageUrl?: string };
};
rival: ForgeClashRival;
roster: ForgeClashRosterSlot[];
rounds: ForgeClashRound[];
telegraph: ForgeClashTelegraph | null;
Expand Down Expand Up @@ -211,6 +217,11 @@ export async function fetchForgeComputerRivals(user: User, count = 6): Promise<F
return Array.isArray(payload.cards) ? payload.cards : [];
}

export async function fetchForgeClashRival(user: User): Promise<ForgeClashRival | null> {
const payload = await callForgeApi<{ rival?: ForgeClashRival }>(user, FORGE_CLASH_RIVAL_API_URL);
return payload.rival ?? null;
}

export async function startForgeClash(
user: User,
{
Expand Down
Loading