diff --git a/package.json b/package.json index f8d883d..7322791 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "verify:sortie-recommendations": "node scripts/verify-sortie-recommendations.mjs", "verify:sortie-roster-history": "node scripts/verify-sortie-roster-history.mjs", "verify:camp-rewards": "node scripts/verify-camp-reward-data.mjs", + "verify:city-stays": "node scripts/verify-city-stay-data.mjs", + "verify:city-stays:browser": "node scripts/verify-city-stay-browser.mjs", "verify:camp-skins": "node scripts/verify-camp-skin-data.mjs", "verify:camp-soundscapes": "node scripts/verify-camp-soundscape-data.mjs", "verify:sound-director": "node scripts/verify-sound-director.mjs", @@ -59,7 +61,7 @@ "verify:interaction-ux": "node scripts/verify-interaction-ux.mjs", "verify:save-flow": "node scripts/verify-save-retry-flow.mjs", "verify:release": "node scripts/verify-release-candidate.mjs", - "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", + "verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:city-stays:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance", "verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl", "verify:public-deploy": "node scripts/verify-public-deploy.mjs", "qa:representative": "node scripts/qa-representative-battles.mjs", diff --git a/scripts/verify-camp-reward-data.mjs b/scripts/verify-camp-reward-data.mjs index db893e3..eed011b 100644 --- a/scripts/verify-camp-reward-data.mjs +++ b/scripts/verify-camp-reward-data.mjs @@ -98,6 +98,8 @@ function validateCampInteractionUxGuards() { const steppedNavigationMethod = extractPrivateMethod(source, 'startCampNavigationWithCampaignStep'); const startStoryMethod = extractPrivateMethod(source, 'startVictoryStory'); const saveMethod = extractPrivateMethod(source, 'saveCampToSlot'); + const wolongLeadMethod = extractPrivateMethod(source, 'hasWolongAudienceLead'); + const renderBondListMethod = extractPrivateMethod(source, 'renderBondList'); let checkedGuardCount = 0; expectCampUx( @@ -192,6 +194,21 @@ function validateCampInteractionUxGuards() { ); checkedGuardCount += 1; + expectCampUx( + startStoryMethod.includes("flow.nextBattleId === campBattleIds.seventeenth && !this.hasWolongAudienceLead()") && + wolongLeadMethod.includes("completedVisits.includes('jingzhou-scholar-rumors')") && + wolongLeadMethod.includes("completedVisits.includes('refugee-village-patrol')"), + 'the Wolong sortie gate must accept a completed information visit regardless of which visit reward choice was taken' + ); + checkedGuardCount += 1; + + expectCampUx( + renderBondListMethod.includes('dialogue.id === this.selectedDialogueId') && + renderBondListMethod.includes('[selectedBond, ...relatedBonds.filter'), + 'the resonance list must keep the currently selected dialogue bond visible when more than four bonds are available' + ); + checkedGuardCount += 1; + return checkedGuardCount; } diff --git a/scripts/verify-city-stay-browser.mjs b/scripts/verify-city-stay-browser.mjs new file mode 100644 index 0000000..64ceefd --- /dev/null +++ b/scripts/verify-city-stay-browser.mjs @@ -0,0 +1,849 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { chromium } from 'playwright'; +import { + desktopBrowserContextOptions, + desktopBrowserDeviceScaleFactor, + desktopBrowserViewport +} from './desktop-browser-viewport.mjs'; + +const xuzhouBattleId = 'seventh-battle-xuzhou-rescue'; +const xiaopeiBattleId = 'eighth-battle-xiaopei-supply-road'; +const xuzhouCityStayId = 'xuzhou'; +const seedGold = 5000; +const additionalCityVisualCases = [ + { + cityStayId: 'xinye', + cityName: '신야', + afterBattleId: 'seventeenth-battle-wolong-visit-road', + nextBattleId: 'eighteenth-battle-bowang-ambush', + battleTitle: '와룡 방문로', + campStep: 'seventeenth-camp', + partnerId: 'zhuge-liang', + partnerName: '제갈량', + bondId: 'liu-bei__zhuge-liang', + bondTitle: '삼고초려', + screenshotPath: 'dist/verification-city-xinye-information.png' + }, + { + cityStayId: 'chengdu', + cityName: '성도', + afterBattleId: 'thirty-third-battle-chengdu-surrender', + nextBattleId: 'thirty-fourth-battle-jiameng-pass', + battleTitle: '성도 항복', + campStep: 'thirty-third-camp', + partnerId: 'huang-quan', + partnerName: '황권', + bondId: 'liu-bei__huang-quan', + bondTitle: '익주의 신뢰', + screenshotPath: 'dist/verification-city-chengdu-information.png' + } +]; +const targetUrl = withDebugOptions( + process.env.VERIFY_CITY_STAY_URL ?? 'http://127.0.0.1:41795/' +); + +let serverProcess; +let browser; + +try { + serverProcess = await ensureLocalServer(targetUrl); + browser = await chromium.launch({ + headless: process.env.VERIFY_CITY_STAY_HEADLESS !== '0' + }); + const context = await browser.newContext(desktopBrowserContextOptions); + const page = await context.newPage(); + page.setDefaultTimeout(30000); + + const pageErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message)); + + await openCleanCamp(page); + await assertDesktopViewport(page); + await seedXuzhouCityStay(page); + await restartCamp(page); + + const initialState = await waitForXuzhouCityStay(page); + verifyCityStayLayout(initialState); + await captureStableScreenshot(page, 'dist/verification-city-xuzhou-information.png'); + assert.equal( + initialState.victoryRewardAcknowledgement.visible, + false, + 'The seeded victory-reward notice must not obstruct the city-stay flow.' + ); + + const informationResult = await verifyInformationReward(page, initialState); + const marketResult = await verifyMarketPurchase(page); + const dialogueResult = await verifyCompanionDialogue(page); + + await restartCamp(page); + const restoredState = await waitForXuzhouCityStay(page); + verifyRestoredOutcomes(restoredState, informationResult, marketResult, dialogueResult); + + await verifyNextBattleBriefing(page, restoredState.cityStay.information.briefingLines); + await verifyAdditionalCityLayouts(page); + + assert.deepEqual( + pageErrors, + [], + `Expected zero browser page errors, received: ${JSON.stringify(pageErrors.slice(-5))}` + ); + + console.log( + `Verified the Xuzhou, Xinye, and Chengdu city stays at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` + + `DPR ${desktopBrowserDeviceScaleFactor}: seven non-overlapping tabs and an in-viewport city panel; ` + + 'idempotent information rewards; exact market gold and inventory changes; saved companion choice; ' + + 'all outcomes surviving a full browser reload; completed city information appearing in each next battle briefing; ' + + 'and information, market, and resonance layouts remaining readable at the desktop baseline.' + ); +} finally { + await browser?.close(); + if (serverProcess && !serverProcess.killed) { + serverProcess.kill(); + } +} + +function withDebugOptions(url) { + const parsed = new URL(url); + parsed.searchParams.set('debug', '1'); + parsed.searchParams.set('renderer', 'canvas'); + return parsed.toString(); +} + +async function openCleanCamp(page) { + await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 }); + await page.evaluate(() => window.localStorage.clear()); + await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); + await waitForDebugApi(page); + await page.evaluate(() => window.__HEROS_DEBUG__?.goToCamp()); + await page.waitForFunction(() => { + const camp = window.__HEROS_DEBUG__?.camp(); + return ( + window.__HEROS_DEBUG__?.activeScenes().includes('CampScene') && + camp?.scene === 'CampScene' && + camp?.report?.battleId + ); + }, undefined, { timeout: 90000 }); +} + +async function seedXuzhouCityStay(page) { + const result = await page.evaluate(({ battleId, battleTitle, gold }) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + if (!scene?.campaign || !scene.report || typeof scene.persistSortieSelection !== 'function') { + return { saved: false, reason: 'CampScene campaign/report/persistSortieSelection is unavailable.' }; + } + + const now = new Date().toISOString(); + const templateUnit = scene.campaign.roster[0] ?? scene.report.units[0]; + const liuBei = templateUnit + ? { + ...structuredClone(templateUnit), + id: 'liu-bei', + name: '유비' + } + : null; + const xuzhouBond = { + id: 'liu-bei__mi-zhu', + unitIds: ['liu-bei', 'mi-zhu'], + title: '서주 후원', + level: 42, + exp: 0, + battleExp: 0, + description: '미축은 서주의 민심과 군량을 묶어 유비군이 오래 버틸 수 있게 돕는다.' + }; + const report = { + ...scene.report, + battleId, + battleTitle, + outcome: 'victory', + turnNumber: Math.max(1, Number(scene.report.turnNumber) || 1), + rewardGold: 0, + itemRewards: [], + objectives: [], + units: liuBei ? [liuBei] : [], + bonds: [xuzhouBond], + completedCampDialogues: [], + completedCampVisits: [], + createdAt: now + }; + delete report.campaignRewards; + + scene.campaign.step = 'seventh-camp'; + scene.campaign.gold = gold; + scene.campaign.latestBattleId = battleId; + scene.campaign.roster = liuBei ? [liuBei] : []; + scene.campaign.bonds = [xuzhouBond]; + scene.campaign.firstBattleReport = report; + scene.campaign.battleHistory = { + [battleId]: { + battleId, + battleTitle, + outcome: 'victory', + turnNumber: report.turnNumber, + rewardGold: 0, + itemRewards: [], + objectives: [], + units: [], + bonds: [], + completedAt: now + } + }; + scene.campaign.completedCampDialogues = []; + scene.campaign.completedCampVisits = []; + scene.campaign.campDialogueChoiceIds = {}; + scene.campaign.campVisitChoiceIds = {}; + scene.campaign.acknowledgedVictoryRewardBattleIds = [battleId]; + scene.campaign.acknowledgedVictoryRewardCategories = {}; + scene.campaign.dismissedVictoryRewardNoticeBattleIds = [battleId]; + delete scene.campaign.pendingAftermathBattleId; + scene.report = report; + + scene.persistSortieSelection(); + return { + saved: true, + latestBattleId: scene.campaign.latestBattleId, + reportBattleId: scene.campaign.firstBattleReport?.battleId, + gold: scene.campaign.gold + }; + }, { + battleId: xuzhouBattleId, + battleTitle: '서주 구원전', + gold: seedGold + }); + + assert.deepEqual( + result, + { + saved: true, + latestBattleId: xuzhouBattleId, + reportBattleId: xuzhouBattleId, + gold: seedGold + }, + `Failed to persist the Xuzhou seed through CampScene.persistSortieSelection(): ${JSON.stringify(result)}` + ); +} + +async function restartCamp(page, expectedCityStayId = xuzhouCityStayId) { + await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); + await waitForDebugApi(page); + await page.evaluate(() => window.__HEROS_DEBUG__?.goToCamp()); + await page.waitForFunction((expectedId) => { + const camp = window.__HEROS_DEBUG__?.camp(); + return ( + window.__HEROS_DEBUG__?.activeScenes().includes('CampScene') && + camp?.scene === 'CampScene' && + camp?.cityStay?.id === expectedId && + camp?.cityStay?.active === true + ); + }, expectedCityStayId, { timeout: 90000 }); +} + +async function verifyAdditionalCityLayouts(page) { + for (const visualCase of additionalCityVisualCases) { + await openCampAboveBattle(page); + + const seedResult = await seedCityStayPreview(page, { ...visualCase, gold: seedGold }); + assert.deepEqual( + seedResult, + { + saved: true, + latestBattleId: visualCase.afterBattleId, + reportBattleId: visualCase.afterBattleId, + cityStayId: visualCase.cityStayId + }, + `Failed to persist the ${visualCase.cityName} city-stay preview: ${JSON.stringify(seedResult)}` + ); + + await restartCamp(page, visualCase.cityStayId); + const state = await waitForCityStay(page, visualCase.cityStayId); + verifyCityStayLayout(state); + assert.equal(state.cityStay.name, visualCase.cityName); + assert.equal(state.cityStay.information.completed, false); + assert.equal(state.cityStay.dialogue.completed, false); + await captureStableScreenshot(page, visualCase.screenshotPath); + + await selectCityLocation(page, 'market'); + const marketState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + assert.equal(marketState.cityStay.market.offers.length, 3); + marketState.cityStay.market.offers.forEach((offer) => { + assert(offer.buyBounds && offer.interactive && offer.canBuy); + assertBoundsInside( + offer.buyBounds, + marketState.cityStay.panelBounds, + `${visualCase.cityName} market offer "${offer.id}" must remain inside the city panel.` + ); + }); + assertNoOverlappingBounds( + marketState.cityStay.market.offers.map((offer) => ({ id: offer.id, bounds: offer.buyBounds })), + `${visualCase.cityName} market offer buttons` + ); + await captureStableScreenshot( + page, + visualCase.screenshotPath.replace('-information.png', '-market.png') + ); + + await selectCityLocation(page, 'dialogue'); + const dialogueState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + assert.equal(dialogueState.cityStay.dialogue.choices.length, 2); + dialogueState.cityStay.dialogue.choices.forEach((choice) => { + assert(choice.bounds && choice.interactive); + assertBoundsInside( + choice.bounds, + dialogueState.cityStay.panelBounds, + `${visualCase.cityName} resonance choice "${choice.id}" must remain inside the city panel.` + ); + }); + assertNoOverlappingBounds( + dialogueState.cityStay.dialogue.choices.map((choice) => ({ id: choice.id, bounds: choice.bounds })), + `${visualCase.cityName} resonance choice buttons` + ); + await captureStableScreenshot( + page, + visualCase.screenshotPath.replace('-information.png', '-dialogue.png') + ); + + await selectCityLocation(page, 'information'); + const functionalInitialState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const informationResult = await verifyInformationReward(page, functionalInitialState); + const marketResult = await verifyMarketPurchase(page, null); + const dialogueResult = await verifyCompanionDialogue(page, null); + + await restartCamp(page, visualCase.cityStayId); + const restoredState = await waitForCityStay(page, visualCase.cityStayId); + verifyRestoredOutcomes(restoredState, informationResult, marketResult, dialogueResult); + await verifyNextBattleBriefing( + page, + restoredState.cityStay.information.briefingLines, + visualCase.nextBattleId, + visualCase.cityName + ); + } +} + +async function openCampAboveBattle(page) { + await page.evaluate(async () => { + const game = window.__HEROS_GAME__; + if (!game || !window.__HEROS_DEBUG__) { + throw new Error('The game debug API is unavailable while opening CampScene.'); + } + if (game.scene.isActive('BattleScene')) { + game.scene.stop('BattleScene'); + } + await window.__HEROS_DEBUG__.goToCamp(); + game.scene.bringToTop('CampScene'); + }); + await page.waitForFunction(() => ( + !window.__HEROS_GAME__?.scene.isActive('BattleScene') && + window.__HEROS_DEBUG__?.activeScenes().includes('CampScene') && + window.__HEROS_DEBUG__?.camp()?.scene === 'CampScene' + ), undefined, { timeout: 90000 }); +} + +async function seedCityStayPreview(page, visualCase) { + return page.evaluate((config) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + if (!scene?.campaign || !scene.report || typeof scene.persistSortieSelection !== 'function') { + return { saved: false, reason: 'CampScene campaign/report/persistSortieSelection is unavailable.' }; + } + + const now = new Date().toISOString(); + const templateUnit = scene.campaign.roster[0] ?? scene.report.units[0]; + if (!templateUnit) { + return { saved: false, reason: 'No unit template is available for the city preview.' }; + } + + const liuBei = { + ...structuredClone(templateUnit), + id: 'liu-bei', + name: '유비' + }; + const partner = { + ...structuredClone(templateUnit), + id: config.partnerId, + name: config.partnerName + }; + const bond = { + id: config.bondId, + unitIds: ['liu-bei', config.partnerId], + title: config.bondTitle, + level: 42, + exp: 0, + battleExp: 0, + description: `${config.cityName} 체류 중 함께 다음 전투를 준비하며 쌓은 공명입니다.` + }; + const report = { + ...scene.report, + battleId: config.afterBattleId, + battleTitle: config.battleTitle, + outcome: 'victory', + turnNumber: Math.max(1, Number(scene.report.turnNumber) || 1), + rewardGold: 0, + itemRewards: [], + objectives: [], + units: [liuBei, partner], + bonds: [bond], + completedCampDialogues: [], + completedCampVisits: [], + createdAt: now + }; + delete report.campaignRewards; + + scene.campaign.step = config.campStep; + scene.campaign.gold = config.gold; + scene.campaign.latestBattleId = config.afterBattleId; + scene.campaign.roster = [liuBei, partner]; + scene.campaign.bonds = [bond]; + scene.campaign.firstBattleReport = report; + scene.campaign.battleHistory = { + [config.afterBattleId]: { + battleId: config.afterBattleId, + battleTitle: config.battleTitle, + outcome: 'victory', + turnNumber: report.turnNumber, + rewardGold: 0, + itemRewards: [], + objectives: [], + units: [], + bonds: [], + completedAt: now + } + }; + scene.campaign.completedCampDialogues = []; + scene.campaign.completedCampVisits = []; + scene.campaign.campDialogueChoiceIds = {}; + scene.campaign.campVisitChoiceIds = {}; + scene.campaign.acknowledgedVictoryRewardBattleIds = [config.afterBattleId]; + scene.campaign.acknowledgedVictoryRewardCategories = {}; + scene.campaign.dismissedVictoryRewardNoticeBattleIds = [config.afterBattleId]; + delete scene.campaign.pendingAftermathBattleId; + scene.report = report; + scene.persistSortieSelection(); + + const cityStayId = scene.currentCityStay?.()?.id ?? null; + return { + saved: true, + latestBattleId: scene.campaign.latestBattleId, + reportBattleId: scene.campaign.firstBattleReport?.battleId, + cityStayId + }; + }, visualCase); +} + +async function waitForCityStay(page, expectedId) { + await page.waitForFunction((cityStayId) => { + const camp = window.__HEROS_DEBUG__?.camp(); + return ( + camp?.scene === 'CampScene' && + camp?.cityStay?.id === cityStayId && + camp?.cityStay?.active === true && + camp?.cityStay?.panelBounds && + camp?.campTabs?.length === 7 + ); + }, expectedId, { timeout: 90000 }); + return page.evaluate(() => window.__HEROS_DEBUG__?.camp()); +} + +async function waitForXuzhouCityStay(page) { + return waitForCityStay(page, xuzhouCityStayId); +} + +function verifyCityStayLayout(state) { + assert.equal(state.activeTab, 'city', 'The city tab should be active when the Xuzhou stay opens.'); + assert.equal(state.campTabs.length, 7, 'A city stay must expose exactly seven CampScene tabs.'); + assert( + state.campTabs.every((tab) => tab.bounds && tab.interactive), + `Every city-stay tab must be visible and interactive: ${JSON.stringify(state.campTabs)}` + ); + assertNoOverlappingBounds( + state.campTabs.map((tab) => ({ id: tab.id, bounds: tab.bounds })), + 'CampScene tabs' + ); + + const sceneBounds = state.campSkin?.sceneBounds ?? { + x: 0, + y: 0, + width: desktopBrowserViewport.width, + height: desktopBrowserViewport.height + }; + assertBoundsInside( + state.cityStay.panelBounds, + sceneBounds, + `The Xuzhou city panel must remain inside the ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} scene.` + ); + state.campTabs.forEach((tab) => { + assertBoundsInside(tab.bounds, sceneBounds, `CampScene tab "${tab.id}" must remain inside the scene.`); + }); +} + +async function verifyInformationReward(page, initialState) { + const information = initialState.cityStay.information; + assert.equal(information.completed, false, 'Xuzhou information should initially be unclaimed.'); + assert(information.actionInteractive && information.actionBounds, 'The information action must be clickable.'); + + const reward = parseInventoryReward(information.itemReward); + const beforeCount = initialState.campaign.inventory[reward.label] ?? 0; + await clickSceneBounds(page, 'CampScene', information.actionBounds); + await page.waitForFunction(({ visitId, label, expectedCount }) => { + const camp = window.__HEROS_DEBUG__?.camp(); + return ( + camp?.cityStay?.information?.id === visitId && + camp.cityStay.information.completed === true && + (camp.campaign?.inventory?.[label] ?? 0) === expectedCount + ); + }, { + visitId: information.id, + label: reward.label, + expectedCount: beforeCount + reward.amount + }); + + const afterFirstClaim = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const cityStay = scene?.currentCityStay?.(); + if (!scene || !cityStay) { + throw new Error('Xuzhou city stay disappeared before the idempotency probe.'); + } + scene.gatherCityInformation(cityStay); + }); + await page.waitForTimeout(100); + const afterRepeatedClaim = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + + assert.equal( + afterRepeatedClaim.campaign.inventory[reward.label] ?? 0, + afterFirstClaim.campaign.inventory[reward.label] ?? 0, + 'Calling the completed information action again must not duplicate its inventory reward.' + ); + assert.equal( + afterRepeatedClaim.campaign.completedCampVisits.filter((id) => id === information.id).length, + 1, + 'The information completion record must remain unique.' + ); + + return { + id: information.id, + inventoryLabel: reward.label, + inventoryCount: afterRepeatedClaim.campaign.inventory[reward.label] ?? 0 + }; +} + +async function verifyMarketPurchase(page, screenshotPath = 'dist/verification-city-xuzhou-market.png') { + await selectCityLocation(page, 'market'); + const before = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (screenshotPath) { + await captureStableScreenshot(page, screenshotPath); + } + const offer = before.cityStay.market.offers[0]; + assert(offer?.buyBounds && offer.interactive && offer.canBuy, 'The first Xuzhou market offer must be affordable and clickable.'); + + const goldBefore = before.cityStay.market.gold; + const ownedBefore = offer.owned; + await clickSceneBounds(page, 'CampScene', offer.buyBounds); + await page.waitForFunction(({ offerId, expectedGold, expectedOwned }) => { + const market = window.__HEROS_DEBUG__?.camp()?.cityStay?.market; + const currentOffer = market?.offers?.find((candidate) => candidate.id === offerId); + return market?.gold === expectedGold && currentOffer?.owned === expectedOwned; + }, { + offerId: offer.id, + expectedGold: goldBefore - offer.price, + expectedOwned: ownedBefore + 1 + }); + + const after = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const purchasedOffer = after.cityStay.market.offers.find((candidate) => candidate.id === offer.id); + assert.equal(after.cityStay.market.gold, goldBefore - offer.price, 'The market must deduct the exact listed price.'); + assert.equal(purchasedOffer.owned, ownedBefore + 1, 'The purchased equipment inventory must increase by exactly one.'); + + return { + offerId: offer.id, + itemId: offer.itemId, + itemName: offer.itemName, + gold: after.cityStay.market.gold, + owned: purchasedOffer.owned + }; +} + +async function verifyCompanionDialogue(page, screenshotPath = 'dist/verification-city-xuzhou-dialogue.png') { + await selectCityLocation(page, 'dialogue'); + const before = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (screenshotPath) { + await captureStableScreenshot(page, screenshotPath); + } + const choice = before.cityStay.dialogue.choices[0]; + assert(choice?.bounds && choice.interactive, 'The first Xuzhou companion choice must be clickable.'); + + await clickSceneBounds(page, 'CampScene', choice.bounds); + await page.waitForTimeout(150); + const after = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + assert( + after?.cityStay?.dialogue?.id === before.cityStay.dialogue.id && + after.cityStay.dialogue.completed === true && + after.cityStay.dialogue.choiceId === choice.id, + `The companion choice did not complete synchronously: ${JSON.stringify({ + before: before.cityStay.dialogue, + after: after?.cityStay?.dialogue, + campaignBonds: after?.report?.bonds + })}` + ); + assert.equal( + after.campaign.completedCampDialogues.filter((id) => id === before.cityStay.dialogue.id).length, + 1, + 'The companion dialogue completion record must be unique.' + ); + assert.equal( + after.campaign.campDialogueChoiceIds[before.cityStay.dialogue.id], + choice.id, + 'The selected companion response must be saved by choice ID.' + ); + + return { + id: before.cityStay.dialogue.id, + choiceId: choice.id + }; +} + +async function selectCityLocation(page, locationId) { + const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const location = state.cityStay.locations.find((candidate) => candidate.id === locationId); + assert(location?.bounds && location.interactive, `City location "${locationId}" must be clickable.`); + await clickSceneBounds(page, 'CampScene', location.bounds); + await page.waitForFunction((expectedId) => ( + window.__HEROS_DEBUG__?.camp()?.cityStay?.selectedLocation === expectedId + ), locationId); +} + +function verifyRestoredOutcomes(state, information, market, dialogue) { + assert.equal(state.cityStay.information.completed, true, 'Information completion must survive a CampScene restart.'); + assert.equal( + state.campaign.inventory[information.inventoryLabel] ?? 0, + information.inventoryCount, + 'The one-time information reward count must survive a CampScene restart.' + ); + + const restoredOffer = state.cityStay.market.offers.find((offer) => offer.id === market.offerId); + assert.equal(state.cityStay.market.gold, market.gold, 'The exact post-purchase gold balance must survive a CampScene restart.'); + assert.equal(restoredOffer?.owned, market.owned, 'The purchased equipment count must survive a CampScene restart.'); + + assert.equal(state.cityStay.dialogue.completed, true, 'Companion dialogue completion must survive a CampScene restart.'); + assert.equal(state.cityStay.dialogue.choiceId, dialogue.choiceId, 'The companion choice must survive a CampScene restart.'); + assert.equal( + state.campaign.campDialogueChoiceIds[dialogue.id], + dialogue.choiceId, + 'The persisted companion choice history must match the selected response.' + ); +} + +async function verifyNextBattleBriefing( + page, + expectedBriefingLines, + nextBattleId = xiaopeiBattleId, + cityName = '서주' +) { + await page.evaluate((battleId) => window.__HEROS_DEBUG__?.goToBattle(battleId), nextBattleId); + await page.waitForFunction((battleId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return ( + window.__HEROS_DEBUG__?.activeScenes().includes('BattleScene') && + battle?.scene === 'BattleScene' && + battle?.battleId === battleId && + battle?.cityInformation + ); + }, nextBattleId, { timeout: 90000 }); + + let battle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + assert.equal(battle.cityInformation.available, true, `${nextBattleId} must expose its preceding city information.`); + assert.equal(battle.cityInformation.completed, true, `${nextBattleId} must see the saved ${cityName} information completion.`); + assert.deepEqual( + battle.cityInformation.briefingLines, + expectedBriefingLines, + `${nextBattleId} must expose the exact authored ${cityName} briefing lines.` + ); + await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + scene?.showOpeningBattleEvent?.(); + }); + await page.waitForFunction(() => { + const battleState = window.__HEROS_DEBUG__?.battle(); + return battleState?.activeBattleEvent?.key === 'opening' || battleState?.battleLog?.length > 0; + }); + battle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const openingEvent = battle.activeBattleEvent?.key === 'opening' + ? battle.activeBattleEvent + : battle.pendingBattleEvents?.find((event) => event.key === 'opening'); + const briefingRecordedInLog = expectedBriefingLines.every((line) => ( + battle.battleLog?.some((entry) => entry.includes(line)) + )); + assert( + (openingEvent && expectedBriefingLines.every((line) => openingEvent.lines.includes(line))) || briefingRecordedInLog, + `The opening battle briefing must record every completed ${cityName} information line: ${JSON.stringify({ + openingEvent, + battleLog: battle.battleLog?.slice(-4) + })}` + ); +} + +function assertNoOverlappingBounds(entries, label) { + for (let leftIndex = 0; leftIndex < entries.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < entries.length; rightIndex += 1) { + const left = entries[leftIndex]; + const right = entries[rightIndex]; + const overlapWidth = Math.min(left.bounds.x + left.bounds.width, right.bounds.x + right.bounds.width) - + Math.max(left.bounds.x, right.bounds.x); + const overlapHeight = Math.min(left.bounds.y + left.bounds.height, right.bounds.y + right.bounds.height) - + Math.max(left.bounds.y, right.bounds.y); + assert( + overlapWidth <= 0 || overlapHeight <= 0, + `${label} "${left.id}" and "${right.id}" overlap: ${JSON.stringify({ left, right })}` + ); + } + } +} + +function assertBoundsInside(bounds, container, message) { + const epsilon = 0.01; + assert( + bounds.x >= container.x - epsilon && + bounds.y >= container.y - epsilon && + bounds.x + bounds.width <= container.x + container.width + epsilon && + bounds.y + bounds.height <= container.y + container.height + epsilon, + `${message} ${JSON.stringify({ bounds, container })}` + ); +} + +async function waitForDebugApi(page) { + await page.waitForFunction(() => ( + document.querySelector('canvas') !== null && + window.__HEROS_GAME__ !== undefined && + window.__HEROS_DEBUG__ !== undefined + ), undefined, { timeout: 90000 }); +} + +async function assertDesktopViewport(page) { + const viewport = await page.evaluate(() => { + const canvas = document.querySelector('canvas'); + return { + width: window.innerWidth, + height: window.innerHeight, + dpr: window.devicePixelRatio, + visualScale: window.visualViewport?.scale ?? 1, + canvas: canvas ? { width: canvas.width, height: canvas.height } : null + }; + }); + assert.deepEqual( + viewport, + { + width: desktopBrowserViewport.width, + height: desktopBrowserViewport.height, + dpr: desktopBrowserDeviceScaleFactor, + visualScale: 1, + canvas: { + width: desktopBrowserViewport.width, + height: desktopBrowserViewport.height + } + }, + 'The browser QA baseline must be an explicit 1920x1080 CSS viewport at DPR 1 and 100% zoom.' + ); +} + +async function clickSceneBounds(page, sceneKey, bounds) { + const point = await page.evaluate(({ requestedSceneKey, requestedBounds }) => { + const scene = window.__HEROS_GAME__?.scene.getScene(requestedSceneKey); + const canvas = document.querySelector('canvas'); + const canvasBounds = canvas?.getBoundingClientRect(); + if (!scene || !canvasBounds || !requestedBounds) { + return null; + } + return { + x: canvasBounds.left + + (requestedBounds.x + requestedBounds.width / 2) * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + + (requestedBounds.y + requestedBounds.height / 2) * canvasBounds.height / scene.scale.height + }; + }, { + requestedSceneKey: sceneKey, + requestedBounds: bounds + }); + assert(point && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a valid ${sceneKey} pointer coordinate.`); + await page.mouse.click(point.x, point.y); +} + +async function captureStableScreenshot(page, path) { + await page.waitForTimeout(300); + const loopSlept = await page.evaluate(() => { + const loop = window.__HEROS_GAME__?.loop; + if (!loop || typeof loop.sleep !== 'function') { + return false; + } + loop.sleep(); + return true; + }); + await page.waitForTimeout(50); + try { + await page.screenshot({ path, fullPage: true }); + } finally { + if (loopSlept) { + await page.evaluate(() => window.__HEROS_GAME__?.loop.wake()); + await page.waitForTimeout(50); + } + } +} + +async function ensureLocalServer(url) { + if (await canReach(url)) { + return undefined; + } + + const parsed = new URL(url); + if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname)) { + throw new Error(`No server responded at ${url}`); + } + + const stderr = []; + const child = spawn( + process.execPath, + [ + 'node_modules/vite/bin/vite.js', + '--host', + '127.0.0.1', + '--port', + parsed.port || '41795', + '--strictPort' + ], + { + cwd: process.cwd(), + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + } + ); + child.stderr.on('data', (chunk) => stderr.push(chunk.toString())); + child.stdout.on('data', () => {}); + + for (let attempt = 0; attempt < 120; attempt += 1) { + if (await canReach(url)) { + return child; + } + await delay(250); + } + + child.kill(); + throw new Error(`Vite server did not start at ${url}\n${stderr.join('')}`); +} + +async function canReach(url) { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(1000) }); + return response.ok; + } catch { + return false; + } +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function parseInventoryReward(reward) { + const match = /^(.*?)\s+([1-9]\d*)$/.exec(reward); + assert(match, `Expected an inventory reward ending in a positive amount: ${reward}`); + return { label: match[1], amount: Number(match[2]) }; +} diff --git a/scripts/verify-city-stay-data.mjs b/scripts/verify-city-stay-data.mjs new file mode 100644 index 0000000..d57b5ae --- /dev/null +++ b/scripts/verify-city-stay-data.mjs @@ -0,0 +1,252 @@ +import { createServer } from 'vite'; + +const expectedCityStayIds = ['xuzhou', 'xinye', 'chengdu']; +const errors = []; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true }, + appType: 'custom' +}); + +try { + const { + cityStayDefinitions, + findCityStayAfterBattle, + findCityStayBeforeBattle, + getCityStayDefinition + } = await server.ssrLoadModule('/src/game/data/cityStays.ts'); + const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); + const { itemCatalog } = await server.ssrLoadModule('/src/game/data/battleItems.ts'); + + validateCityStayCollection(cityStayDefinitions, battleScenarios, itemCatalog); + validateLookupHelpers( + cityStayDefinitions, + getCityStayDefinition, + findCityStayAfterBattle, + findCityStayBeforeBattle + ); + + if (errors.length > 0) { + throw new Error(`City stay data verification failed:\n${errors.map((error) => `- ${error}`).join('\n')}`); + } + + console.log( + `Verified ${cityStayDefinitions.length} city stays, ${cityStayDefinitions.length * 3} common equipment offers, battle links, information visits, resonance dialogues, and typed lookup helpers.` + ); +} finally { + await server.close(); +} + +function validateCityStayCollection(definitions, battleScenarios, itemCatalog) { + if (!Array.isArray(definitions) || definitions.length !== 3) { + errors.push(`cityStayDefinitions must contain exactly 3 entries (found ${definitions?.length ?? 'invalid'})`); + return; + } + + const actualIds = definitions.map((definition) => definition.id); + if (JSON.stringify(actualIds) !== JSON.stringify(expectedCityStayIds)) { + errors.push(`city stay ids/order must be ${expectedCityStayIds.join(', ')} (found ${actualIds.join(', ')})`); + } + + const battleOrder = Object.keys(battleScenarios); + const seenCityIds = new Set(); + const seenVisitIds = new Set(); + const seenDialogueIds = new Set(); + const seenChoiceIds = new Set(); + const seenOfferIds = new Set(); + + definitions.forEach((definition, index) => { + const context = `cityStayDefinitions[${index}]/${definition?.id ?? 'unknown'}`; + assertUniqueNonEmptyString(definition?.id, seenCityIds, `${context}: id`); + validateCityMeta(definition?.city, context); + validateBattleLink(definition, context, battleScenarios, battleOrder); + validateInformationVisit(definition?.information, context, seenVisitIds); + validateDialogue(definition?.dialogue, context, definition, battleScenarios, seenDialogueIds, seenChoiceIds); + validateEquipmentOffers(definition?.equipmentOffers, context, itemCatalog, seenOfferIds); + }); +} + +function validateCityMeta(city, context) { + if (!city || typeof city !== 'object') { + errors.push(`${context}: city metadata is missing`); + return; + } + ['name', 'region', 'title', 'description'].forEach((field) => { + assertNonEmptyString(city[field], `${context}: city.${field}`); + }); +} + +function validateBattleLink(definition, context, battleScenarios, battleOrder) { + const afterScenario = battleScenarios[definition.afterBattleId]; + const nextScenario = battleScenarios[definition.nextBattleId]; + if (!afterScenario) { + errors.push(`${context}: unknown afterBattleId "${definition.afterBattleId}"`); + } + if (!nextScenario) { + errors.push(`${context}: unknown nextBattleId "${definition.nextBattleId}"`); + } + if (!afterScenario || !nextScenario) { + return; + } + + const afterIndex = battleOrder.indexOf(definition.afterBattleId); + const nextIndex = battleOrder.indexOf(definition.nextBattleId); + if (afterIndex < 0 || nextIndex !== afterIndex + 1) { + errors.push( + `${context}: next battle must immediately follow after battle (${definition.afterBattleId} -> ${definition.nextBattleId})` + ); + } + if (afterScenario.campaignReward?.unlockBattleId !== definition.nextBattleId) { + errors.push( + `${context}: after battle unlock "${afterScenario.campaignReward?.unlockBattleId ?? 'none'}" does not match nextBattleId` + ); + } +} + +function validateInformationVisit(information, context, seenVisitIds) { + if (!information || typeof information !== 'object') { + errors.push(`${context}: information visit is missing`); + return; + } + + assertUniqueNonEmptyString(information.id, seenVisitIds, `${context}: information.id`); + ['title', 'location', 'description', 'itemReward'].forEach((field) => { + assertNonEmptyString(information[field], `${context}: information.${field}`); + }); + if (!Array.isArray(information.briefingLines) || information.briefingLines.length < 1 || information.briefingLines.length > 2) { + errors.push(`${context}: information.briefingLines must contain 1 or 2 lines`); + } else { + information.briefingLines.forEach((line, lineIndex) => { + assertNonEmptyString(line, `${context}: information.briefingLines[${lineIndex}]`); + }); + } + if (!/^(.*?)\s+([1-9]\d*)$/.test(information.itemReward ?? '')) { + errors.push(`${context}: information.itemReward must end with a positive amount`); + } +} + +function validateDialogue(dialogue, context, definition, battleScenarios, seenDialogueIds, seenChoiceIds) { + if (!dialogue || typeof dialogue !== 'object') { + errors.push(`${context}: resonance dialogue is missing`); + return; + } + + assertUniqueNonEmptyString(dialogue.id, seenDialogueIds, `${context}: dialogue.id`); + assertNonEmptyString(dialogue.title, `${context}: dialogue.title`); + assertNonEmptyString(dialogue.bondId, `${context}: dialogue.bondId`); + assertPositiveInteger(dialogue.rewardExp, `${context}: dialogue.rewardExp`); + if ( + !Array.isArray(dialogue.unitIds) || + dialogue.unitIds.length !== 2 || + !dialogue.unitIds.every((unitId) => typeof unitId === 'string' && unitId.trim().length > 0) || + dialogue.unitIds[0] === dialogue.unitIds[1] + ) { + errors.push(`${context}: dialogue.unitIds must contain two distinct unit ids`); + } + if (!Array.isArray(dialogue.lines) || dialogue.lines.length === 0) { + errors.push(`${context}: dialogue.lines must not be empty`); + } else { + dialogue.lines.forEach((line, lineIndex) => { + assertNonEmptyString(line, `${context}: dialogue.lines[${lineIndex}]`); + }); + } + if (!Array.isArray(dialogue.choices) || dialogue.choices.length !== 2) { + errors.push(`${context}: dialogue.choices must contain exactly 2 entries`); + } else { + dialogue.choices.forEach((choice, choiceIndex) => { + const choiceContext = `${context}: dialogue.choices[${choiceIndex}]`; + assertUniqueNonEmptyString(choice.id, seenChoiceIds, `${choiceContext}.id`); + assertNonEmptyString(choice.label, `${choiceContext}.label`); + assertNonEmptyString(choice.response, `${choiceContext}.response`); + assertPositiveInteger(choice.rewardExp, `${choiceContext}.rewardExp`); + }); + } + + const nextScenario = battleScenarios[definition.nextBattleId]; + const bond = nextScenario?.bonds?.find((candidate) => candidate.id === dialogue.bondId); + if (!bond) { + errors.push(`${context}: dialogue bond "${dialogue.bondId}" is not available in the next battle`); + } else if ( + !Array.isArray(dialogue.unitIds) || + dialogue.unitIds.length !== 2 || + !dialogue.unitIds.every((unitId) => bond.unitIds.includes(unitId)) + ) { + errors.push( + `${context}: dialogue unit ids ${JSON.stringify(dialogue.unitIds)} do not match bond ${JSON.stringify(bond.unitIds)}` + ); + } +} + +function validateEquipmentOffers(offers, context, itemCatalog, seenOfferIds) { + if (!Array.isArray(offers) || offers.length !== 3) { + errors.push(`${context}: equipmentOffers must contain exactly 3 entries`); + return; + } + + const cityItemIds = new Set(); + offers.forEach((offer, offerIndex) => { + const offerContext = `${context}: equipmentOffers[${offerIndex}]`; + assertUniqueNonEmptyString(offer?.id, seenOfferIds, `${offerContext}.id`); + assertNonEmptyString(offer?.itemId, `${offerContext}.itemId`); + assertPositiveInteger(offer?.price, `${offerContext}.price`); + if (cityItemIds.has(offer?.itemId)) { + errors.push(`${offerContext}: duplicate city item "${offer?.itemId}"`); + } + cityItemIds.add(offer?.itemId); + + const item = itemCatalog[offer?.itemId]; + if (!item) { + errors.push(`${offerContext}: unknown equipment item "${offer?.itemId}"`); + return; + } + if (item.rank !== 'common') { + errors.push(`${offerContext}: item "${offer.itemId}" must have common rank (found ${item.rank})`); + } + if (offer.slot !== item.slot) { + errors.push(`${offerContext}: slot "${offer.slot}" does not match catalog slot "${item.slot}"`); + } + }); +} + +function validateLookupHelpers( + definitions, + getCityStayDefinition, + findCityStayAfterBattle, + findCityStayBeforeBattle +) { + definitions.forEach((definition) => { + if (getCityStayDefinition(definition.id) !== definition) { + errors.push(`getCityStayDefinition("${definition.id}") did not return the canonical definition`); + } + if (findCityStayAfterBattle(definition.afterBattleId) !== definition) { + errors.push(`findCityStayAfterBattle("${definition.afterBattleId}") did not return ${definition.id}`); + } + if (findCityStayBeforeBattle(definition.nextBattleId) !== definition) { + errors.push(`findCityStayBeforeBattle("${definition.nextBattleId}") did not return ${definition.id}`); + } + }); +} + +function assertUniqueNonEmptyString(value, seen, context) { + assertNonEmptyString(value, context); + if (typeof value !== 'string' || value.trim().length === 0) { + return; + } + if (seen.has(value)) { + errors.push(`${context}: duplicate value "${value}"`); + } + seen.add(value); +} + +function assertNonEmptyString(value, context) { + if (typeof value !== 'string' || value.trim().length === 0) { + errors.push(`${context} must be a non-empty string`); + } +} + +function assertPositiveInteger(value, context) { + if (!Number.isInteger(value) || value <= 0) { + errors.push(`${context} must be a positive integer (found ${value})`); + } +} diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index e0a37e8..3157c59 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -235,7 +235,11 @@ try { await clickBattleDeploymentStart(page); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); - return state?.battleId === 'first-battle-zhuo-commandery' && state?.phase === 'idle' && state?.triggeredBattleEvents?.includes('opening'); + return ( + state?.battleId === 'first-battle-zhuo-commandery' && + state?.phase === 'idle' && + state?.activeBattleEvent?.key === 'opening' + ); }, undefined, { timeout: 30000 }); await page.screenshot({ path: `${screenshotDir}/rc-first-battle.png`, fullPage: true }); await assertCanvasPainted(page, 'first battle'); @@ -3829,7 +3833,16 @@ try { ); await advanceSortiePrepStep(page, 'loadout'); await clickLegacyUi(page, 1116, 656); - await advanceUntilBattle(page, 'second-battle-yellow-turban-pursuit'); + await advanceUntilBattle(page, 'second-battle-yellow-turban-pursuit', { startDeployment: false }); + await clickBattleDeploymentStart(page); + await page.waitForFunction(() => { + const state = window.__HEROS_DEBUG__?.battle(); + return ( + state?.battleId === 'second-battle-yellow-turban-pursuit' && + state?.phase === 'idle' && + state?.activeBattleEvent?.key === 'opening' + ); + }, undefined, { timeout: 30000 }); await page.screenshot({ path: `${screenshotDir}/rc-second-battle-from-first-camp.png`, fullPage: true }); await assertCanvasPainted(page, 'second battle from first camp'); diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 16f1f51..c33fcd8 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -21,6 +21,7 @@ const checks = [ 'scripts/verify-battle-scenario-data.mjs', 'scripts/verify-narrative-continuity.mjs', 'scripts/verify-camp-reward-data.mjs', + 'scripts/verify-city-stay-data.mjs', 'scripts/verify-victory-reward-acknowledgements.mjs', 'scripts/verify-camp-skin-data.mjs', 'scripts/verify-camp-soundscape-data.mjs', diff --git a/src/game/data/cityStays.ts b/src/game/data/cityStays.ts new file mode 100644 index 0000000..030b9f7 --- /dev/null +++ b/src/game/data/cityStays.ts @@ -0,0 +1,233 @@ +import type { EquipmentSlot } from './battleItems'; +import type { BattleScenarioId } from './battles'; + +export type CityStayId = 'xuzhou' | 'xinye' | 'chengdu'; + +export type CityStayMeta = { + name: string; + region: string; + title: string; + description: string; +}; + +export type CityInformationVisit = { + id: string; + title: string; + location: string; + description: string; + briefingLines: readonly [string] | readonly [string, string]; + itemReward: string; +}; + +export type CityResonanceDialogueChoice = { + id: string; + label: string; + response: string; + rewardExp: number; +}; + +export type CityResonanceDialogue = { + id: string; + title: string; + unitIds: readonly [string, string]; + bondId: string; + rewardExp: number; + lines: readonly string[]; + choices: readonly [CityResonanceDialogueChoice, CityResonanceDialogueChoice]; +}; + +export type CityEquipmentOffer = { + id: string; + itemId: string; + slot: EquipmentSlot; + price: number; +}; + +export type CityStayDefinition = { + id: CityStayId; + city: CityStayMeta; + afterBattleId: BattleScenarioId; + nextBattleId: BattleScenarioId; + information: CityInformationVisit; + dialogue: CityResonanceDialogue; + equipmentOffers: readonly [CityEquipmentOffer, CityEquipmentOffer, CityEquipmentOffer]; +}; + +export const cityStayDefinitions: readonly CityStayDefinition[] = [ + { + id: 'xuzhou', + city: { + name: '서주', + region: '서주성', + title: '서주의 첫 성내 체류', + description: '구원전 직후 성내의 소문과 보급 사정을 살피고, 새로 합류한 미축과 다음 방위선을 준비합니다.' + }, + afterBattleId: 'seventh-battle-xuzhou-rescue', + nextBattleId: 'eighth-battle-xiaopei-supply-road', + information: { + id: 'city-xuzhou-xiaopei-ledger', + title: '소패 보급로 장부 확인', + location: '서주 관청', + description: '관청의 창고 장부와 상인들의 증언을 맞추어 소패로 향하는 보급로의 약한 지점을 찾습니다.', + briefingLines: [ + '소패의 창고는 서쪽 길목과 마을 어귀를 통해 보급을 받습니다.', + '습격대보다 먼저 마을과 창고 주변을 안정시키면 고순의 압박을 견디기 쉬워집니다.' + ], + itemReward: '소패 보급로 장부 1' + }, + dialogue: { + id: 'city-xuzhou-liu-mi-supply-trust', + title: '서주의 창고를 맡기다', + unitIds: ['liu-bei', 'mi-zhu'], + bondId: 'liu-bei__mi-zhu', + rewardExp: 12, + lines: [ + '미축: 성을 구한 기세만으로는 서주를 지킬 수 없습니다. 상인과 창고지기의 신뢰를 함께 묶어야 합니다.', + '유비: 백성의 곡식을 군의 공으로 돌리지 않겠소. 자중의 장부를 믿고 다음 길을 맡기겠소.' + ], + choices: [ + { + id: 'city-xuzhou-share-storehouse-duty', + label: '창고와 백성의 몫을 함께 지킨다', + response: '미축은 군량과 구휼 장부를 나누어 적고, 어느 쪽도 희생시키지 않겠다고 답했습니다.', + rewardExp: 10 + }, + { + id: 'city-xuzhou-trust-mi-zhu-ledger', + label: '보급 판단을 미축에게 맡긴다', + response: '미축은 유비의 신뢰에 고개를 숙이고 소패까지 이어지는 상단과 창고를 직접 정리했습니다.', + rewardExp: 12 + } + ] + }, + equipmentOffers: [ + { id: 'city-xuzhou-iron-spear', itemId: 'iron-spear', slot: 'weapon', price: 620 }, + { id: 'city-xuzhou-lamellar-armor', itemId: 'lamellar-armor', slot: 'armor', price: 680 }, + { id: 'city-xuzhou-grain-pouch', itemId: 'grain-pouch', slot: 'accessory', price: 360 } + ] + }, + { + id: 'xinye', + city: { + name: '신야', + region: '형주 북부', + title: '신야의 출진 준비', + description: '융중 방문을 마친 뒤 신야의 객사와 장터를 돌며 박망파의 지형을 확인하고 제갈량의 첫 지휘를 준비합니다.' + }, + afterBattleId: 'seventeenth-battle-wolong-visit-road', + nextBattleId: 'eighteenth-battle-bowang-ambush', + information: { + id: 'city-xinye-bowang-scout-map', + title: '박망파 숲길 정찰', + location: '신야 객사', + description: '나무꾼과 피난민이 기억하는 숲길을 정찰표와 맞추어 화공 매복에 쓸 수 있는 좁은 길을 찾습니다.', + briefingLines: [ + '박망파 중앙 숲길은 적을 깊이 끌어들인 뒤 화공으로 퇴로를 끊기 좋은 지형입니다.', + '서쪽 진영에서 성급히 밀지 말고 조운의 기동과 관우·장비의 길목 차단을 맞추어야 합니다.' + ], + itemReward: '박망파 매복 정찰도 1' + }, + dialogue: { + id: 'city-xinye-liu-zhuge-first-command', + title: '와룡의 첫 군령', + unitIds: ['liu-bei', 'zhuge-liang'], + bondId: 'liu-bei__zhuge-liang', + rewardExp: 14, + lines: [ + '제갈량: 하후돈의 기세를 정면에서 꺾기보다, 그 기세가 스스로 숲 안에 갇히게 해야 합니다.', + '유비: 군사께 첫 지휘를 맡기겠소. 장수들이 계책의 뜻까지 이해하도록 내가 곁에서 힘을 보태겠소.' + ], + choices: [ + { + id: 'city-xinye-explain-strategy-to-officers', + label: '장수들에게 계책의 뜻을 설명한다', + response: '제갈량은 유비가 장수들의 신뢰를 먼저 묶어 주자, 매복 순서와 퇴로까지 차분히 풀어 설명했습니다.', + rewardExp: 12 + }, + { + id: 'city-xinye-entrust-first-command', + label: '첫 군령을 온전히 맡긴다', + response: '제갈량은 유비의 결단을 받아 들고 박망파의 바람과 불길을 한 장의 작전도로 정리했습니다.', + rewardExp: 14 + } + ] + }, + equipmentOffers: [ + { id: 'city-xinye-short-bow', itemId: 'short-bow', slot: 'weapon', price: 840 }, + { id: 'city-xinye-lamellar-armor', itemId: 'lamellar-armor', slot: 'armor', price: 960 }, + { id: 'city-xinye-grain-pouch', itemId: 'grain-pouch', slot: 'accessory', price: 520 } + ] + }, + { + id: 'chengdu', + city: { + name: '성도', + region: '익주', + title: '성도 수습과 북문 준비', + description: '항복 직후 성도의 관청과 시장을 안정시키고 황권에게 가맹관과 한중 방면의 사정을 듣습니다.' + }, + afterBattleId: 'thirty-third-battle-chengdu-surrender', + nextBattleId: 'thirty-fourth-battle-jiameng-pass', + information: { + id: 'city-chengdu-jiameng-route-ledger', + title: '가맹관 산길 장부 대조', + location: '성도 임시 관청', + description: '황권이 보관한 북문 장부와 상단의 이동 기록을 대조해 가맹관으로 향하는 산길과 보급 거점을 확인합니다.', + briefingLines: [ + '가맹관은 좁은 산길과 보급로가 맞물려 서량 기병의 첫 돌격을 흘려낼 자리가 제한됩니다.', + '황권을 지키며 척후선과 보급로를 안정시키면 마초에게 유비군의 군율을 보여 줄 수 있습니다.' + ], + itemReward: '가맹관 산길 장부 1' + }, + dialogue: { + id: 'city-chengdu-liu-huang-quan-order', + title: '항복 뒤의 첫 약속', + unitIds: ['liu-bei', 'huang-quan'], + bondId: 'liu-bei__huang-quan', + rewardExp: 16, + lines: [ + '황권: 성문은 열렸지만 익주의 마음까지 열린 것은 아닙니다. 북문으로 군을 움직일수록 성도의 질서를 먼저 보여야 합니다.', + '유비: 항복한 이를 의심으로 묶지 않겠소. 공형의 판단으로 성도와 가맹관 사이의 길을 함께 세워 주시오.' + ], + choices: [ + { + id: 'city-chengdu-preserve-local-order', + label: '익주의 기존 질서를 존중한다', + response: '황권은 성도 관리와 창고지기를 함부로 바꾸지 않겠다는 약속을 듣고 북문 장부를 내놓았습니다.', + rewardExp: 12 + }, + { + id: 'city-chengdu-appoint-huang-quan-guide', + label: '황권에게 북문 준비를 맡긴다', + response: '황권은 유비의 신뢰를 받아 가맹관의 길과 서량 기병의 움직임을 직접 정리하겠다고 답했습니다.', + rewardExp: 14 + } + ] + }, + equipmentOffers: [ + { id: 'city-chengdu-western-spear', itemId: 'western-cavalry-spear', slot: 'weapon', price: 1880 }, + { id: 'city-chengdu-lamellar-armor', itemId: 'lamellar-armor', slot: 'armor', price: 1640 }, + { id: 'city-chengdu-grain-pouch', itemId: 'grain-pouch', slot: 'accessory', price: 920 } + ] + } +]; + +const cityStayDefinitionById = new Map(cityStayDefinitions.map((definition) => [definition.id, definition])); +const cityStayDefinitionByAfterBattleId = new Map( + cityStayDefinitions.map((definition) => [definition.afterBattleId, definition]) +); +const cityStayDefinitionByNextBattleId = new Map( + cityStayDefinitions.map((definition) => [definition.nextBattleId, definition]) +); + +export function getCityStayDefinition(id: CityStayId): CityStayDefinition { + return cityStayDefinitionById.get(id)!; +} + +export function findCityStayAfterBattle(battleId: BattleScenarioId): CityStayDefinition | undefined { + return cityStayDefinitionByAfterBattleId.get(battleId); +} + +export function findCityStayBeforeBattle(battleId: BattleScenarioId): CityStayDefinition | undefined { + return cityStayDefinitionByNextBattleId.get(battleId); +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index c0fff2f..e0d68a1 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -29,6 +29,7 @@ import { resolveCampaignPresentationSoundscape } from '../data/campaignPresentationProfiles'; import { getSortieFlow } from '../data/campaignFlow'; +import { findCityStayBeforeBattle } from '../data/cityStays'; import { enemyIntentOpeningGuide, isEnemyIntentCounterplayAvailable, @@ -17555,9 +17556,39 @@ export class BattleScene extends Phaser.Scene { return false; } + private cityInformationDebugState(campaign: CampaignState = getCampaignState()) { + const cityStay = findCityStayBeforeBattle(battleScenario.id); + const completed = Boolean( + cityStay && + campaign.completedCampVisits.includes(cityStay.information.id) + ); + + return { + available: Boolean(cityStay), + completed, + cityId: cityStay?.id ?? null, + name: cityStay?.city.name ?? null, + visitId: cityStay?.information.id ?? null, + briefingLines: cityStay ? [...cityStay.information.briefingLines] : [] + }; + } + + private completedCityInformationBriefingLines(campaign: CampaignState = getCampaignState()) { + const cityInformation = this.cityInformationDebugState(campaign); + if (!cityInformation.completed || !cityInformation.name) { + return []; + } + + return [ + `도시 정보 · ${cityInformation.name}`, + ...cityInformation.briefingLines + ]; + } + private showOpeningBattleEvent() { const guide = battleScenario.tacticalGuide; const objectiveLines = guide ? [guide.summary, `진군: ${guide.route}`, guide.focus] : battleScenario.openingObjectiveLines; + const cityInformationLines = this.completedCityInformationBriefingLines(); if (battleScenario.sortie) { const roleLines = this.sortieOpeningLines(); const title = this.firstPursuitTrinityConfigured() ? '출진 군세 · 도원 삼재진' : '출진 군세'; @@ -17566,6 +17597,7 @@ export class BattleScene extends Phaser.Scene { ...(this.launchSortieRecommendation ? [`선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`] : []), + ...cityInformationLines, enemyIntentOpeningGuide(this.isFirstPursuitRoleEffectBattle() ? tacticalInitiativeThreshold : undefined), ...(battleScenario.id === 'first-battle-zhuo-commandery' ? ['첫 행동 · 아군 선택 → 파란 이동 칸 → 명령 공격 → 붉은 적 선택'] @@ -17574,7 +17606,10 @@ export class BattleScene extends Phaser.Scene { ], { priority: 'critical' }); return; } - this.triggerBattleEvent('opening', '작전 목표', objectiveLines, { priority: 'critical' }); + this.triggerBattleEvent('opening', '작전 목표', [ + ...objectiveLines, + ...cityInformationLines + ], { priority: 'critical' }); } private triggerTacticalEvent(key: BattleTacticalGuideEventKey, fallbackTitle: string, fallbackLines: string[]) { @@ -17724,7 +17759,11 @@ export class BattleScene extends Phaser.Scene { } const maxBodyLines = 3; - const bodyLines = lines.slice(0, maxBodyLines).map((line) => this.truncateBattleLogText(line, 42)); + const cityInformationIndex = lines.findIndex((line) => line.startsWith('도시 정보 ·')); + const bannerLines = cityInformationIndex >= maxBodyLines + ? [lines[0], ...lines.slice(cityInformationIndex, cityInformationIndex + 2)] + : lines; + const bodyLines = bannerLines.slice(0, maxBodyLines).map((line) => this.truncateBattleLogText(line, 42)); const width = 540; const height = Phaser.Math.Clamp(80 + bodyLines.length * 22, 108, maxBodyLines > 2 ? 214 : 126); const left = this.layout.gridX + Math.max(16, (this.layout.gridWidth - width) / 2); @@ -17871,7 +17910,9 @@ export class BattleScene extends Phaser.Scene { private showSortieDoctrineBanner(event: BattleSavePendingEvent) { const { title, lines } = event; + const completedCityInformationLines = new Set(this.completedCityInformationBriefingLines()); const doctrineLines = lines.filter((line) => ( + completedCityInformationLines.has(line) || line.startsWith('도원 공명') || line.startsWith('공명 ') || line.startsWith('적 의도') || @@ -28677,6 +28718,7 @@ export class BattleScene extends Phaser.Scene { battleId: battleScenario.id, battleTitle: battleScenario.title, environment: this.battleEnvironmentDebugState(), + cityInformation: this.cityInformationDebugState(campaign), victoryConditionLabel: battleScenario.victoryConditionLabel, defeatConditionLabel: battleScenario.defeatConditionLabel, selectedSortieUnitIds: effectiveSortieUnitIds, @@ -29051,7 +29093,7 @@ export class BattleScene extends Phaser.Scene { active: Boolean(battleScenario.sortie), battleId: battleScenario.id, coveredRoleCount: groups.filter((group) => group.units.length > 0).length, - openingBannerShown: this.triggeredBattleEvents.has('opening'), + openingBannerShown: this.activeBattleEvent?.key === 'opening' || this.triggeredBattleEvents.has('opening'), roles: groups.map(({ role, definition, units }) => ({ role, label: definition.label, @@ -29115,7 +29157,10 @@ export class BattleScene extends Phaser.Scene { fullCoverage: this.firstPursuitTrinityConfigured(), trinityActive: this.firstPursuitTrinityActive(), trinitySupportRange: this.firstPursuitTrinityActive() ? 2 : 1, - openingBannerShown: this.isFirstPursuitRoleEffectBattle() && this.triggeredBattleEvents.has('opening'), + openingBannerShown: this.isFirstPursuitRoleEffectBattle() && ( + this.activeBattleEvent?.key === 'opening' || + this.triggeredBattleEvents.has('opening') + ), roles: this.firstPursuitRoleAssignments().map(({ role, unit, definition }) => ({ unitId: unit.id, name: unit.name, diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 4ec234c..d78ecf4 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -10,6 +10,7 @@ import { equipmentSlots, findItemByName, getItem, + itemInventoryLabel, type EquipmentSlot, type ItemDefinition } from '../data/battleItems'; @@ -23,6 +24,12 @@ import { getUnitClass, terrainRules, type TerrainType, type UnitClassKey } from import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles'; import { getSortieFlow, type SortieFlow } from '../data/campaignFlow'; import { getCampSoundscape, type CampSoundscape } from '../data/campSoundscapes'; +import { + findCityStayAfterBattle, + type CityEquipmentOffer, + type CityResonanceDialogueChoice, + type CityStayDefinition +} from '../data/cityStays'; import { campaignPortraitKeysByUnitId, portraitAssetEntriesForKey, @@ -217,7 +224,20 @@ const campNoticeBaseHoldMs = 1800; const campNoticeCharacterHoldMs = 55; const campNoticeParagraphPauseMs = 500; -type CampTab = 'status' | 'dialogue' | 'visit' | 'supplies' | 'equipment' | 'progress'; +type CampTab = 'status' | 'city' | 'dialogue' | 'visit' | 'supplies' | 'equipment' | 'progress'; + +type CityStayLocationId = 'information' | 'market' | 'dialogue'; + +type CityStayLocationView = { + id: CityStayLocationId; + background: Phaser.GameObjects.Rectangle; +}; + +type CityEquipmentOfferView = { + offerId: string; + itemId: string; + buyButton: Phaser.GameObjects.Rectangle; +}; type CampDialogue = { id: string; @@ -11319,6 +11339,12 @@ export class CampScene extends Phaser.Scene { private campRosterPage = 0; private campRosterLayout?: CampRosterLayout; private activeTab: CampTab = 'status'; + private selectedCityLocation: CityStayLocationId = 'information'; + private cityPanelBackground?: Phaser.GameObjects.Rectangle; + private cityLocationViews: CityStayLocationView[] = []; + private cityInformationActionButton?: Phaser.GameObjects.Rectangle; + private cityDialogueChoiceButtons: Phaser.GameObjects.Rectangle[] = []; + private cityEquipmentOfferViews: CityEquipmentOfferView[] = []; private selectedDialogueId = campDialogues[0].id; private selectedVisitId = campVisits[0].id; private equipmentInventoryPage = 0; @@ -11432,6 +11458,11 @@ export class CampScene extends Phaser.Scene { this.contentObjects = []; this.campRosterPage = 0; this.campRosterLayout = undefined; + this.cityPanelBackground = undefined; + this.cityLocationViews = []; + this.cityInformationActionButton = undefined; + this.cityDialogueChoiceButtons = []; + this.cityEquipmentOfferViews = []; this.dialogueObjects = []; this.sortieObjects = []; this.sortieComparisonPanelView = undefined; @@ -11527,6 +11558,19 @@ export class CampScene extends Phaser.Scene { this.sortieItemAssignments = this.normalizedSortieItemAssignments(this.campaign.sortieItemAssignments); this.selectedDialogueId = this.availableCampDialogues()[0]?.id ?? campDialogues[0].id; this.selectedVisitId = this.availableCampVisits()[0]?.id ?? campVisits[0].id; + const cityStay = this.currentCityStay(); + if (cityStay) { + const informationComplete = this.completedCampVisits().includes(cityStay.information.id); + const dialogueComplete = this.completedCampDialogues().includes(cityStay.dialogue.id); + this.activeTab = 'city'; + this.selectedCityLocation = !informationComplete + ? 'information' + : !dialogueComplete + ? 'dialogue' + : 'market'; + } else if (this.activeTab === 'city') { + this.activeTab = 'status'; + } soundDirector.playSoundscape({ musicKey: this.campSoundscape.music.trackKey, ambienceKey: this.campSoundscape.ambience.trackKey, @@ -12495,6 +12539,10 @@ export class CampScene extends Phaser.Scene { private currentCampTitle() { const battleId = this.currentCampBattleId(); + const cityStay = this.currentCityStay(); + if (cityStay) { + return `${cityStay.city.name} 성내 체류`; + } if (this.campaign?.step === 'shu-han-foundation-camp') { return '촉한 건국 후 군영'; } @@ -12709,6 +12757,24 @@ export class CampScene extends Phaser.Scene { return this.campaign?.latestBattleId ?? this.report?.battleId ?? defaultBattleScenario.id; } + private currentCityStay() { + if (this.retrySortieBattleId) { + return undefined; + } + return findCityStayAfterBattle(this.currentCampBattleId() as BattleScenarioId); + } + + private hasPendingCityStayActions() { + const cityStay = this.currentCityStay(); + return Boolean( + cityStay && + ( + !this.completedCampVisits().includes(cityStay.information.id) || + !this.completedCampDialogues().includes(cityStay.dialogue.id) + ) + ); + } + private availableCampDialogues() { const battleId = this.currentCampBattleId(); const battleDialogues = campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(battleId)); @@ -12741,12 +12807,22 @@ export class CampScene extends Phaser.Scene { } private renderStaticButtons() { - this.addTabButton('장수', 'status', 576, 38); - this.addTabButton('대화', 'dialogue', 650, 38); - this.addTabButton('방문', 'visit', 724, 38); - this.addTabButton('보급', 'supplies', 798, 38); - this.addTabButton('장비', 'equipment', 872, 38); - this.addTabButton('연표', 'progress', 946, 38); + if (this.currentCityStay()) { + this.addTabButton('장수', 'status', 566, 38, 60, 14); + this.addTabButton('성내', 'city', 630, 38, 60, 14); + this.addTabButton('대화', 'dialogue', 694, 38, 60, 14); + this.addTabButton('방문', 'visit', 758, 38, 60, 14); + this.addTabButton('보급', 'supplies', 822, 38, 60, 14); + this.addTabButton('장비', 'equipment', 886, 38, 60, 14); + this.addTabButton('연표', 'progress', 950, 38, 60, 14); + } else { + this.addTabButton('장수', 'status', 576, 38); + this.addTabButton('대화', 'dialogue', 650, 38); + this.addTabButton('방문', 'visit', 724, 38); + this.addTabButton('보급', 'supplies', 798, 38); + this.addTabButton('장비', 'equipment', 872, 38); + this.addTabButton('연표', 'progress', 946, 38); + } this.addCommandButton('저장', 1030, 38, 76, () => { soundDirector.playSelect(); this.showCampSaveSlotPanel(); @@ -13638,8 +13714,8 @@ export class CampScene extends Phaser.Scene { this.startVictoryStory(); } - private addTabButton(label: string, tab: CampTab, x: number, y: number) { - const bg = this.scaleLegacyCampUi(this.add.rectangle(x, y, 76, 34, 0x182431, 0.94)); + private addTabButton(label: string, tab: CampTab, x: number, y: number, width = 76, fontSize = 16) { + const bg = this.scaleLegacyCampUi(this.add.rectangle(x, y, width, 34, 0x182431, 0.94)); bg.setStrokeStyle(1, palette.blue, 0.62); bg.setInteractive({ useHandCursor: true }); @@ -13654,14 +13730,15 @@ export class CampScene extends Phaser.Scene { const text = this.scaleLegacyCampUi(this.add.text(x, y, label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: '16px', + fontSize: `${fontSize}px`, color: '#f2e3bf', fontStyle: '700' })); text.setOrigin(0.5); text.setInteractive({ useHandCursor: true }); - const newBadge = tab === 'supplies' || tab === 'equipment' - ? this.scaleLegacyCampUi(this.add.text(x + 29, y - 17, 'NEW', { + const badgeLabel = tab === 'city' ? '새' : width < 70 ? 'N' : 'NEW'; + const newBadge = tab === 'city' || tab === 'supplies' || tab === 'equipment' + ? this.scaleLegacyCampUi(this.add.text(x + width / 2 - 10, y - 17, badgeLabel, { ...this.textStyle(9, '#fff2b8', true), backgroundColor: '#8a3f25', padding: { x: 4, y: 2 } @@ -13693,6 +13770,7 @@ export class CampScene extends Phaser.Scene { indicator.setVisible(active || hovered); indicator.setAlpha(active ? 1 : 0.66); newBadge?.setVisible(Boolean( + (tab === 'city' && this.hasPendingCityStayActions()) || (tab === 'supplies' && pendingCategories.has('supplies')) || (tab === 'equipment' && pendingCategories.has('equipment')) )); @@ -13741,6 +13819,11 @@ export class CampScene extends Phaser.Scene { this.renderUnitColumn(); this.renderReportPanel(); + if (this.activeTab === 'city') { + this.renderCityStayPanel(); + return; + } + if (this.activeTab === 'dialogue') { this.renderDialoguePanel(); return; @@ -19899,7 +19982,7 @@ export class CampScene extends Phaser.Scene { return; } - if (flow.nextBattleId === campBattleIds.seventeenth && this.wolongClueCount() <= 0) { + if (flow.nextBattleId === campBattleIds.seventeenth && !this.hasWolongAudienceLead()) { this.hideSortiePrep(); this.activeTab = 'visit'; this.render(); @@ -22318,8 +22401,8 @@ export class CampScene extends Phaser.Scene { const dialogues = this.availableCampDialogues(); const compactDialogueList = dialogues.length > 3; - const dialogueRowGap = compactDialogueList ? 44 : 64; - const dialogueRowHeight = compactDialogueList ? 36 : 48; + const dialogueRowGap = compactDialogueList ? 35 : 64; + const dialogueRowHeight = compactDialogueList ? 30 : 48; if (!dialogues.some((dialogue) => dialogue.id === this.selectedDialogueId)) { this.selectedDialogueId = dialogues[0]?.id ?? campDialogues[0].id; } @@ -22337,9 +22420,9 @@ export class CampScene extends Phaser.Scene { this.selectedDialogueId = dialogue.id; this.render(); }); - this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 5 : 8), dialogue.title, this.textStyle(compactDialogueList ? 13 : 15, completed ? '#a8ffd0' : '#f2e3bf', true))); + this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 3 : 8), dialogue.title, this.textStyle(compactDialogueList ? 11 : 15, completed ? '#a8ffd0' : '#f2e3bf', true))); const maxReward = dialogue.rewardExp + Math.max(...dialogue.choices.map((choice) => choice.rewardExp)); - this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 22 : 29), `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}~${maxReward}`, this.textStyle(compactDialogueList ? 10 : 12, '#9fb0bf'))); + this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 17 : 29), `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}~${maxReward}`, this.textStyle(compactDialogueList ? 9 : 12, '#9fb0bf'))); }); this.renderSelectedDialogue(x + 372, y + 96, 416, 300); @@ -22409,10 +22492,20 @@ export class CampScene extends Phaser.Scene { } private renderBondList(x: number, y: number, width: number) { - const bonds = this.currentBonds(); + const dialogues = this.availableCampDialogues(); + const relatedBondIds = new Set(dialogues.map((dialogue) => dialogue.bondId)); + const relatedBonds = this.currentBonds().filter((bond) => relatedBondIds.has(bond.id)); + const selectedBondId = dialogues.find((dialogue) => dialogue.id === this.selectedDialogueId)?.bondId; + const selectedBond = relatedBonds.find((bond) => bond.id === selectedBondId); + const orderedBonds = selectedBond + ? [selectedBond, ...relatedBonds.filter((bond) => bond.id !== selectedBond.id)] + : relatedBonds.length > 0 + ? relatedBonds + : this.currentBonds(); + const bonds = orderedBonds.slice(0, 4); const compact = bonds.length > 3; const rowGap = compact ? 25 : 36; - this.track(this.add.text(x, y, '공명', this.textStyle(18, '#f2e3bf', true))); + this.track(this.add.text(x, y, relatedBonds.length > 0 ? '공명 · 현재 대화' : '공명', this.textStyle(18, '#f2e3bf', true))); bonds.forEach((bond, index) => { const rowY = y + (compact ? 28 : 32) + index * rowGap; const first = this.unitName(bond.unitIds[0]); @@ -22422,13 +22515,409 @@ export class CampScene extends Phaser.Scene { }); } + private renderCityStayPanel() { + const cityStay = this.currentCityStay(); + if (!cityStay) { + this.activeTab = 'status'; + this.render(); + return; + } + + const x = 394; + const y = 120; + const width = 828; + const height = 444; + const informationComplete = this.completedCampVisits().includes(cityStay.information.id); + const dialogueComplete = this.completedCampDialogues().includes(cityStay.dialogue.id); + const bg = this.track(this.add.rectangle(x, y, width, height, 0x101820, 0.92)); + this.cityPanelBackground = bg; + bg.setOrigin(0); + bg.setStrokeStyle(1, this.campSkinSelection?.skin.accentColor ?? palette.gold, 0.68); + + this.track(this.add.text(x + 24, y + 18, cityStay.city.title, this.textStyle(24, '#f2e3bf', true))); + this.track(this.add.text(x + 24, y + 51, cityStay.city.description, { + ...this.textStyle(13, '#d4dce6'), + wordWrap: { width: 600, useAdvancedWrap: true } + })); + const progress = this.track(this.add.text( + x + width - 24, + y + 23, + `체류 기록 ${Number(informationComplete) + Number(dialogueComplete)}/2`, + this.textStyle(13, informationComplete && dialogueComplete ? '#a8ffd0' : '#d8b15f', true) + )); + progress.setOrigin(1, 0); + + const locations: Array<{ + id: CityStayLocationId; + mark: string; + title: string; + subtitle: string; + status: string; + complete: boolean; + }> = [ + { + id: 'information', + mark: '첩', + title: '정보 수집', + subtitle: cityStay.information.location, + status: informationComplete ? '다음 전투 정보 확보' : '새 정보 확인 가능', + complete: informationComplete + }, + { + id: 'market', + mark: '시', + title: '시장과 대장간', + subtitle: `${cityStay.equipmentOffers.length}종 장비`, + status: `군자금 ${this.campaign?.gold ?? 0}`, + complete: false + }, + { + id: 'dialogue', + mark: '공', + title: '동료와 대화', + subtitle: this.cityDialogueUnitNames(cityStay), + status: dialogueComplete ? '공명 대화 완료' : '선택에 따라 공명 상승', + complete: dialogueComplete + } + ]; + + this.cityLocationViews = locations.map((location, index) => { + const cardX = x + 24 + index * 266; + const cardY = y + 86; + const selected = this.selectedCityLocation === location.id; + const card = this.track(this.add.rectangle( + cardX, + cardY, + 248, + 70, + selected ? 0x283d50 : 0x151f2a, + selected ? 0.98 : 0.9 + )); + card.setOrigin(0); + card.setStrokeStyle( + selected ? 2 : 1, + location.complete ? palette.green : selected ? palette.gold : palette.blue, + selected ? 0.88 : 0.5 + ); + card.setInteractive({ useHandCursor: true }); + card.on('pointerover', () => { + if (this.selectedCityLocation !== location.id) { + card.setFillStyle(0x213343, 0.96); + } + }); + card.on('pointerout', () => { + if (this.selectedCityLocation !== location.id) { + card.setFillStyle(0x151f2a, 0.9); + } + }); + card.on('pointerdown', () => { + soundDirector.playSelect(); + this.selectedCityLocation = location.id; + this.render(); + }); + + const mark = this.track(this.add.circle(cardX + 29, cardY + 35, 18, 0x0d141c, 0.96)); + mark.setStrokeStyle(1, location.complete ? palette.green : palette.gold, 0.72); + const markText = this.track(this.add.text(cardX + 29, cardY + 35, location.mark, this.textStyle(15, '#f2e3bf', true))); + markText.setOrigin(0.5); + this.track(this.add.text(cardX + 56, cardY + 10, location.title, this.textStyle(15, '#f2e3bf', true))); + this.track(this.add.text(cardX + 56, cardY + 31, this.compactText(location.subtitle, 20), this.textStyle(11, '#9fb0bf'))); + this.track(this.add.text( + cardX + 56, + cardY + 49, + location.status, + this.textStyle(10, location.complete ? '#a8ffd0' : selected ? '#e8c978' : '#d4dce6', true) + )); + return { id: location.id, background: card }; + }); + + const detailX = x + 24; + const detailY = y + 172; + const detailWidth = width - 48; + const detailHeight = 248; + if (this.selectedCityLocation === 'market') { + this.renderCityMarket(cityStay, detailX, detailY, detailWidth, detailHeight); + return; + } + if (this.selectedCityLocation === 'dialogue') { + this.renderCityDialogue(cityStay, detailX, detailY, detailWidth, detailHeight); + return; + } + this.renderCityInformation(cityStay, detailX, detailY, detailWidth, detailHeight); + } + + private renderCityInformation( + cityStay: CityStayDefinition, + x: number, + y: number, + width: number, + height: number + ) { + const information = cityStay.information; + const completed = this.completedCampVisits().includes(information.id); + const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.94)); + bg.setOrigin(0); + bg.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.54); + this.track(this.add.text(x + 20, y + 14, information.title, this.textStyle(19, '#f2e3bf', true))); + this.track(this.add.text(x + 20, y + 43, information.description, { + ...this.textStyle(13, '#d4dce6'), + wordWrap: { width: width - 40, useAdvancedWrap: true }, + lineSpacing: 2 + })); + + if (completed) { + const reveal = this.track(this.add.rectangle(x + 20, y + 91, width - 40, 102, 0x17231d, 0.96)); + reveal.setOrigin(0); + reveal.setStrokeStyle(1, palette.green, 0.68); + this.track(this.add.text(x + 34, y + 103, `확보한 정보 · ${cityStay.city.name}`, this.textStyle(13, '#a8ffd0', true))); + this.track(this.add.text(x + 34, y + 128, information.briefingLines.join('\n'), { + ...this.textStyle(12, '#d4dce6'), + wordWrap: { width: width - 68, useAdvancedWrap: true }, + lineSpacing: 4 + })); + this.track(this.add.text( + x + 20, + y + height - 29, + `다음 전투 브리핑에 반영 · 기록 ${information.itemReward}`, + this.textStyle(12, '#d8b15f', true) + )); + return; + } + + this.track(this.add.text( + x + 20, + y + 103, + '관청 문서와 현지 증언을 대조하면 다음 전투의 지형과 적 움직임이 브리핑에 추가됩니다.', + { + ...this.textStyle(13, '#9fb0bf'), + wordWrap: { width: width - 40, useAdvancedWrap: true } + } + )); + const action = this.track(this.add.rectangle(x + width / 2, y + height - 36, 260, 38, 0x4a371d, 0.98)); + this.cityInformationActionButton = action; + action.setStrokeStyle(2, palette.gold, 0.92); + action.setInteractive({ useHandCursor: true }); + action.on('pointerover', () => action.setFillStyle(0x654c25, 0.99)); + action.on('pointerout', () => action.setFillStyle(0x4a371d, 0.98)); + action.on('pointerdown', () => this.gatherCityInformation(cityStay)); + const label = this.track(this.add.text( + x + width / 2, + y + height - 36, + '성내 정보 수집', + this.textStyle(14, '#fff2b8', true) + )); + label.setOrigin(0.5); + } + + private renderCityMarket( + cityStay: CityStayDefinition, + x: number, + y: number, + width: number, + height: number + ) { + const campaign = this.campaign ?? getCampaignState(); + const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.94)); + bg.setOrigin(0); + bg.setStrokeStyle(1, palette.gold, 0.52); + this.track(this.add.text(x + 20, y + 14, `${cityStay.city.name} 시장 · 군자금 ${campaign.gold}`, this.textStyle(19, '#f2e3bf', true))); + this.track(this.add.text(x + width - 20, y + 18, '일반 장비 · 구매 즉시 장비 탭에서 사용', this.textStyle(11, '#9fb0bf', true))).setOrigin(1, 0); + + this.cityEquipmentOfferViews = []; + cityStay.equipmentOffers.forEach((offer, index) => { + const item = getItem(offer.itemId); + const rowY = y + 50 + index * 60; + const canBuy = campaign.gold >= offer.price; + const owned = campaign.inventory[itemInventoryLabel(item.id)] ?? 0; + const row = this.track(this.add.rectangle(x + 20, rowY, width - 40, 48, canBuy ? 0x151f2a : 0x111820, canBuy ? 0.94 : 0.76)); + row.setOrigin(0); + row.setStrokeStyle(1, canBuy ? palette.blue : 0x53606c, canBuy ? 0.52 : 0.3); + + const iconKey = `item-${item.id}-micro`; + if (this.textures.exists(iconKey)) { + const iconFrame = this.track(this.add.rectangle(x + 42, rowY + 24, 34, 34, 0x0a1017, 0.94)); + iconFrame.setStrokeStyle(1, palette.gold, 0.54); + const icon = this.track(this.add.image(x + 42, rowY + 24, iconKey)); + icon.setDisplaySize(this.campUiLength(25), this.campUiLength(25)); + } + this.track(this.add.text(x + 68, rowY + 7, `${item.name} · ${equipmentSlotLabels[item.slot]}`, this.textStyle(14, canBuy ? '#f2e3bf' : '#7f8994', true))); + this.track(this.add.text( + x + 68, + rowY + 27, + `${this.itemBonusText(item)} · 보유 ${owned}`, + this.textStyle(11, canBuy ? '#9fb0bf' : '#68737d') + )); + this.track(this.add.text(x + width - 156, rowY + 16, `${offer.price}금`, this.textStyle(13, canBuy ? '#d8b15f' : '#7f8994', true))); + + const button = this.track(this.add.rectangle(x + width - 66, rowY + 24, 72, 30, canBuy ? 0x1a2630 : 0x121922, canBuy ? 0.98 : 0.72)); + button.setStrokeStyle(1, canBuy ? palette.gold : 0x53606c, canBuy ? 0.78 : 0.36); + const buttonText = this.track(this.add.text( + x + width - 66, + rowY + 24, + canBuy ? '구매' : '부족', + this.textStyle(12, canBuy ? '#fff2b8' : '#7f8994', true) + )); + buttonText.setOrigin(0.5); + if (canBuy) { + button.setInteractive({ useHandCursor: true }); + button.on('pointerover', () => button.setFillStyle(0x283947, 0.99)); + button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.98)); + button.on('pointerdown', () => this.buyCityEquipment(offer)); + } + this.cityEquipmentOfferViews.push({ offerId: offer.id, itemId: offer.itemId, buyButton: button }); + }); + } + + private renderCityDialogue( + cityStay: CityStayDefinition, + x: number, + y: number, + width: number, + height: number + ) { + const dialogue = cityStay.dialogue; + const completed = this.completedCampDialogues().includes(dialogue.id); + const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.94)); + bg.setOrigin(0); + bg.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.54); + this.track(this.add.text(x + 20, y + 14, dialogue.title, this.textStyle(19, '#f2e3bf', true))); + const people = this.track(this.add.text(x + width - 20, y + 18, this.cityDialogueUnitNames(cityStay), this.textStyle(12, '#d8b15f', true))); + people.setOrigin(1, 0); + this.track(this.add.text(x + 20, y + 48, dialogue.lines.join('\n'), { + ...this.textStyle(12, '#d4dce6'), + wordWrap: { width: width - 40, useAdvancedWrap: true }, + lineSpacing: 4 + })); + + if (completed) { + const choiceId = this.campaign?.campDialogueChoiceIds[dialogue.id]; + const choice = dialogue.choices.find((candidate) => candidate.id === choiceId); + const memory = this.track(this.add.rectangle(x + 20, y + height - 80, width - 40, 60, 0x17231d, 0.96)); + memory.setOrigin(0); + memory.setStrokeStyle(1, palette.green, 0.7); + this.track(this.add.text( + x + 34, + y + height - 69, + choice ? `나의 결정 · ${choice.label}` : '공명 대화 완료', + this.textStyle(12, '#a8ffd0', true) + )); + this.track(this.add.text( + x + 34, + y + height - 45, + choice ? this.compactText(choice.response, 88) : '이 선택은 저장되어 다시 보상을 받을 수 없습니다.', + { + ...this.textStyle(11, '#d4dce6'), + wordWrap: { width: width - 68, useAdvancedWrap: true } + } + )); + return; + } + + this.cityDialogueChoiceButtons = dialogue.choices.map((choice, index) => { + const buttonWidth = (width - 54) / 2; + const buttonX = x + 20 + buttonWidth / 2 + index * (buttonWidth + 14); + const buttonY = y + height - 37; + const button = this.track(this.add.rectangle(buttonX, buttonY, buttonWidth, 42, 0x1a2630, 0.98)); + button.setStrokeStyle(1, palette.gold, 0.78); + button.setInteractive({ useHandCursor: true }); + button.on('pointerover', () => button.setFillStyle(0x283947, 0.99)); + button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.98)); + button.on('pointerdown', () => this.completeCityDialogue(cityStay, choice)); + const label = this.track(this.add.text( + buttonX, + buttonY, + `${choice.label} · 공명 +${dialogue.rewardExp + choice.rewardExp}`, + this.textStyle(12, '#fff2b8', true) + )); + label.setOrigin(0.5); + return button; + }); + } + + private cityDialogueUnitNames(cityStay: CityStayDefinition) { + const names = cityStay.dialogue.unitIds.map((unitId) => ( + this.currentUnits().find((unit) => unit.id === unitId)?.name ?? unitId + )); + return names.join(' · '); + } + + private gatherCityInformation(cityStay: CityStayDefinition) { + if (this.completedCampVisits().includes(cityStay.information.id)) { + this.showCampNotice('이미 확보한 정보입니다.'); + return; + } + const updated = applyCampVisitReward( + cityStay.information.id, + { itemRewards: [cityStay.information.itemReward] }, + 'city-information-gathered' + ); + if (!updated) { + this.showCampNotice('정보를 장부에 기록하지 못했습니다. 전투 결과 저장 상태를 확인해 주세요.'); + return; + } + this.campaign = updated; + this.report = updated.firstBattleReport ?? this.report; + soundDirector.playEffect('exp-gain', { volume: 0.24, stopAfterMs: 700 }); + this.showCampNotice( + `정보 확보 · ${cityStay.information.title}\n다음 전투 브리핑에 ${cityStay.city.name} 현지 정보가 추가됩니다.` + ); + this.render(); + } + + private completeCityDialogue(cityStay: CityStayDefinition, choice: CityResonanceDialogueChoice) { + const dialogue = cityStay.dialogue; + if (this.completedCampDialogues().includes(dialogue.id)) { + this.showCampNotice('이미 완료된 공명 대화입니다.'); + return; + } + const rewardExp = dialogue.rewardExp + choice.rewardExp; + const updated = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp, choice.id); + if (!updated) { + this.showCampNotice('두 동료의 공명 관계를 찾지 못했습니다. 합류 상태를 확인해 주세요.'); + return; + } + this.report = updated; + this.campaign = getCampaignState(); + soundDirector.playEffect('exp-gain', { volume: 0.28, stopAfterMs: 700 }); + this.showCampNotice(`공명 +${rewardExp} · ${choice.label}\n${choice.response}`); + this.render(); + } + + private buyCityEquipment(offer: CityEquipmentOffer) { + const cityStay = this.currentCityStay(); + if (!cityStay || !cityStay.equipmentOffers.some((candidate) => candidate.id === offer.id)) { + this.showCampNotice('현재 시장에서는 살 수 없는 장비입니다.'); + return; + } + const item = getItem(offer.itemId); + const campaign = this.campaign ?? getCampaignState(); + if (item.rank !== 'common') { + this.showCampNotice('보물 장비는 일반 시장에서 거래할 수 없습니다.'); + return; + } + if (campaign.gold < offer.price) { + this.showCampNotice('군자금이 부족합니다.'); + return; + } + + campaign.gold -= offer.price; + const label = itemInventoryLabel(item.id); + campaign.inventory[label] = (campaign.inventory[label] ?? 0) + 1; + this.campaign = saveCampaignState(campaign); + this.report = this.campaign.firstBattleReport ?? this.report; + soundDirector.playSelect(); + this.showCampNotice(`${cityStay.city.name} 시장 · ${item.name} 구입 · 군자금 -${offer.price}`); + this.render(); + } + private renderVisitPanel() { const x = 394; const y = 120; const bg = this.track(this.add.rectangle(x, y, 828, 444, 0x101820, 0.9)); bg.setOrigin(0); bg.setStrokeStyle(1, palette.gold, 0.58); - this.track(this.add.text(x + 24, y + 22, '형주 방문', this.textStyle(24, '#f2e3bf', true))); + const locationLabel = this.currentCityStay()?.city.name ?? this.campSkinSelection?.skin.locationLabel ?? '현지'; + this.track(this.add.text(x + 24, y + 22, `${locationLabel} 방문`, this.textStyle(24, '#f2e3bf', true))); this.track(this.add.text(x + 24, y + 56, '군영 밖 사람들을 만나 정보, 보급, 공명 보상을 얻습니다. 각 방문은 한 번만 완료할 수 있습니다.', this.textStyle(14, '#d4dce6'))); const visits = this.availableCampVisits(); @@ -22445,11 +22934,14 @@ export class CampScene extends Phaser.Scene { return; } + const compactVisitList = visits.length > 3; + const visitRowGap = compactVisitList ? 54 : 66; + const visitRowHeight = compactVisitList ? 42 : 50; visits.forEach((visit, index) => { const completed = this.completedCampVisits().includes(visit.id); - const rowY = y + 96 + index * 66; + const rowY = y + 96 + index * visitRowGap; const selected = this.selectedVisitId === visit.id; - const row = this.track(this.add.rectangle(x + 24, rowY, 320, 50, selected ? 0x25384a : 0x151f2a, selected ? 0.96 : 0.86)); + const row = this.track(this.add.rectangle(x + 24, rowY, 320, visitRowHeight, selected ? 0x25384a : 0x151f2a, selected ? 0.96 : 0.86)); row.setOrigin(0); row.setStrokeStyle(1, completed ? palette.green : selected ? palette.gold : palette.blue, selected ? 0.72 : 0.46); row.setInteractive({ useHandCursor: true }); @@ -22458,8 +22950,8 @@ export class CampScene extends Phaser.Scene { this.selectedVisitId = visit.id; this.render(); }); - this.track(this.add.text(x + 38, rowY + 8, visit.title, this.textStyle(15, completed ? '#a8ffd0' : '#f2e3bf', true))); - this.track(this.add.text(x + 38, rowY + 29, visit.location, this.textStyle(12, '#9fb0bf'))); + this.track(this.add.text(x + 38, rowY + (compactVisitList ? 5 : 8), visit.title, this.textStyle(compactVisitList ? 13 : 15, completed ? '#a8ffd0' : '#f2e3bf', true))); + this.track(this.add.text(x + 38, rowY + (compactVisitList ? 23 : 29), visit.location, this.textStyle(compactVisitList ? 10 : 12, '#9fb0bf'))); }); this.renderSelectedVisit(x + 372, y + 96, 416, 300); @@ -22537,8 +23029,11 @@ export class CampScene extends Phaser.Scene { bg.setStrokeStyle(1, palette.blue, 0.42); this.track(this.add.text(x + 14, y + 12, '현지 준비', this.textStyle(17, '#f2e3bf', true))); this.track(this.add.text(x + 14, y + 40, `방문 ${completed.length}/${visits.length} 완료`, this.textStyle(13, completed.length >= visits.length && visits.length > 0 ? '#a8ffd0' : '#d4dce6', true))); - const insightCount = this.wolongClueCount(); - this.track(this.add.text(x + 14, y + 66, `제갈량 단서 ${insightCount} · 군자금 ${this.campaign?.gold ?? 0}`, this.textStyle(13, '#9fb0bf', true))); + const isWolongSearch = this.currentSortieFlow().nextBattleId === campBattleIds.seventeenth; + const informationLabel = isWolongSearch + ? `와룡 단서 ${this.hasWolongAudienceLead() ? 1 : 0}/1` + : `현지 기록 ${completed.length}`; + this.track(this.add.text(x + 14, y + 66, `${informationLabel} · 군자금 ${this.campaign?.gold ?? 0}`, this.textStyle(13, '#9fb0bf', true))); } private renderEquipmentPanel() { @@ -23931,6 +24426,15 @@ export class CampScene extends Phaser.Scene { return this.inventoryAmount('와룡 소문') + this.inventoryAmount('민심 기록'); } + private hasWolongAudienceLead() { + const completedVisits = this.completedCampVisits(); + return ( + this.wolongClueCount() > 0 || + completedVisits.includes('jingzhou-scholar-rumors') || + completedVisits.includes('refugee-village-patrol') + ); + } + private drawBar(x: number, y: number, width: number, height: number, ratio: number, color: number) { const track = this.track(this.add.rectangle(x, y, width, height, 0x070b10, 0.86)); track.setOrigin(0); @@ -24025,6 +24529,11 @@ export class CampScene extends Phaser.Scene { this.contentObjects.forEach((object) => object.destroy()); this.contentObjects = []; this.campRosterLayout = undefined; + this.cityPanelBackground = undefined; + this.cityLocationViews = []; + this.cityInformationActionButton = undefined; + this.cityDialogueChoiceButtons = []; + this.cityEquipmentOfferViews = []; this.equipmentPanelBackground = undefined; this.equipmentInventoryPreviousButton = undefined; this.equipmentInventoryNextButton = undefined; @@ -24164,6 +24673,13 @@ export class CampScene extends Phaser.Scene { const equipmentSwapCandidateItem = this.pendingEquipmentSwap ? getItem(this.pendingEquipmentSwap.candidateItemId) : undefined; + const cityStay = this.currentCityStay(); + const cityInformationComplete = cityStay + ? this.completedCampVisits().includes(cityStay.information.id) + : false; + const cityDialogueComplete = cityStay + ? this.completedCampDialogues().includes(cityStay.dialogue.id) + : false; return { scene: this.scene.key, victoryRewardAcknowledgement: { @@ -24220,6 +24736,76 @@ export class CampScene extends Phaser.Scene { strokeColor: button.bg.strokeColor, lineWidth: button.bg.lineWidth })), + cityStay: cityStay + ? { + available: true, + id: cityStay.id, + name: cityStay.city.name, + region: cityStay.city.region, + afterBattleId: cityStay.afterBattleId, + nextBattleId: cityStay.nextBattleId, + active: this.activeTab === 'city', + selectedLocation: this.selectedCityLocation, + panelBounds: this.sortieObjectBoundsDebug(this.cityPanelBackground), + pendingActions: Number(!cityInformationComplete) + Number(!cityDialogueComplete), + locations: this.cityLocationViews.map((view) => ({ + id: view.id, + selected: this.selectedCityLocation === view.id, + bounds: this.sortieObjectBoundsDebug(view.background), + interactive: Boolean(view.background.input?.enabled) + })), + information: { + id: cityStay.information.id, + completed: cityInformationComplete, + itemReward: cityStay.information.itemReward, + briefingLines: [...cityStay.information.briefingLines], + actionBounds: this.sortieObjectBoundsDebug(this.cityInformationActionButton), + actionInteractive: Boolean(this.cityInformationActionButton?.input?.enabled) + }, + dialogue: { + id: cityStay.dialogue.id, + bondId: cityStay.dialogue.bondId, + completed: cityDialogueComplete, + choiceId: this.campaign?.campDialogueChoiceIds[cityStay.dialogue.id] ?? null, + choices: cityStay.dialogue.choices.map((choice, index) => ({ + id: choice.id, + label: choice.label, + rewardExp: cityStay.dialogue.rewardExp + choice.rewardExp, + bounds: this.sortieObjectBoundsDebug(this.cityDialogueChoiceButtons[index]), + interactive: Boolean(this.cityDialogueChoiceButtons[index]?.input?.enabled) + })) + }, + market: { + gold: this.campaign?.gold ?? 0, + offers: cityStay.equipmentOffers.map((offer) => { + const item = getItem(offer.itemId); + const view = this.cityEquipmentOfferViews.find((candidate) => candidate.offerId === offer.id); + return { + id: offer.id, + itemId: offer.itemId, + itemName: item.name, + price: offer.price, + owned: this.campaign?.inventory[itemInventoryLabel(item.id)] ?? 0, + canBuy: (this.campaign?.gold ?? 0) >= offer.price, + buyBounds: this.sortieObjectBoundsDebug(view?.buyButton), + interactive: Boolean(view?.buyButton.input?.enabled) + }; + }) + } + } + : { + available: false, + id: null, + name: null, + active: false, + selectedLocation: null, + panelBounds: null, + pendingActions: 0, + locations: [], + information: null, + dialogue: null, + market: null + }, campRoster: this.campRosterLayout ? { ...this.campRosterLayout, @@ -25115,7 +25701,9 @@ export class CampScene extends Phaser.Scene { }, {} as UnitData['equipment']) })), completedCampDialogues: this.campaign.completedCampDialogues, - completedCampVisits: this.campaign.completedCampVisits + completedCampVisits: this.campaign.completedCampVisits, + campDialogueChoiceIds: this.campaign.campDialogueChoiceIds, + campVisitChoiceIds: this.campaign.campVisitChoiceIds } : null, report: this.report