diff --git a/server/routes/forge.js b/server/routes/forge.js index a114992c..bf54f806 100644 --- a/server/routes/forge.js +++ b/server/routes/forge.js @@ -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', @@ -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 }); } @@ -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.' }); @@ -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, diff --git a/server/test/forgeClashRoute.test.js b/server/test/forgeClashRoute.test.js index b8f8adde..567d250d 100644 --- a/server/test/forgeClashRoute.test.js +++ b/server/test/forgeClashRoute.test.js @@ -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 }); diff --git a/src/pages/ForgeClash.tsx b/src/pages/ForgeClash.tsx index 8ac41d63..312dfa2f 100644 --- a/src/pages/ForgeClash.tsx +++ b/src/pages/ForgeClash.tsx @@ -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, @@ -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, + ); +} + function buildForgeClashRivalDisplayCard( - rival: ForgeClashMatch["rival"] | null, + rival: ForgeClashRival | null, ): CardPayload | null { if (!rival) return null; const district = rival.district ?? "Batteryville"; @@ -405,7 +417,7 @@ function RivalCard({ rival, telegraph, }: { - rival: ForgeClashMatch["rival"] | null; + rival: ForgeClashRival | null; telegraph: ForgeClashTelegraph | null; }) { const intent = telegraph?.intent ?? "rush"; @@ -456,6 +468,7 @@ export function ForgeClash() { const { user } = useAuth(); const { refreshWallet } = useWallet(); const [loanerCards, setLoanerCards] = useState([]); + const [rivalPreview, setRivalPreview] = useState(null); const [loanersLoading, setLoanersLoading] = useState(true); const [selectedKeys, setSelectedKeys] = useState([]); const [draftTouched, setDraftTouched] = useState(false); @@ -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], @@ -560,8 +591,27 @@ export function ForgeClash() { const guidedOpening = match?.status === "playing" ? getGuidedOpening(match.turn, match.maxHeat) : null; - const rivalForDisplay = useMemo(() => { - if (match?.rival) return match.rival; + const rivalForDisplay = useMemo(() => { + 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 { @@ -569,8 +619,8 @@ export function ForgeClash() { 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 diff --git a/src/services/forge.ts b/src/services/forge.ts index 1e7b85d5..c5828971 100644 --- a/src/services/forge.ts +++ b/src/services/forge.ts @@ -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", @@ -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> & { + board?: { imageUrl?: string }; +}; + export interface ForgeClashMatch { id: string; status: "playing" | "completed"; @@ -137,26 +162,7 @@ export interface ForgeClashMatch { heat: number; cooldowns: Record; result: "win" | "loss" | "draw" | null; - rival: JoustCardSnapshot & { - tagline: string; - signatureTrait: string; - dialogue: { - intro: string; - win: string; - loss: string; - draw: string; - }; - } & Partial> & { - board?: { imageUrl?: string }; - }; + rival: ForgeClashRival; roster: ForgeClashRosterSlot[]; rounds: ForgeClashRound[]; telegraph: ForgeClashTelegraph | null; @@ -211,6 +217,11 @@ export async function fetchForgeComputerRivals(user: User, count = 6): Promise { + const payload = await callForgeApi<{ rival?: ForgeClashRival }>(user, FORGE_CLASH_RIVAL_API_URL); + return payload.rival ?? null; +} + export async function startForgeClash( user: User, {