diff --git a/docs/game-design-improvement-goal.md b/docs/game-design-improvement-goal.md index 1298da2..30fdbb6 100644 --- a/docs/game-design-improvement-goal.md +++ b/docs/game-design-improvement-goal.md @@ -172,9 +172,24 @@ RPG로 다듬는다. - 낮 강변 환경음, 지면별 발소리, 목표 완료의 시각·음향 신호를 함께 사용해 소리를 듣지 못해도 진행 상태를 이해할 수 있게 한다. -다음 초반 묶음은 세 번째 군영부터 반복되는 방문·대화·상점 흐름을 감사해 -정보 수집, 장비 구매, 동료 대화 가운데 무엇을 먼저 택했는지가 출전 -브리핑과 첫 턴의 전술 선택에 짧고 명확하게 되돌아오게 한다. +### 완료한 8차 묶음: 세 번째 승리의 결과별 우선 귀환 대화 + +- 세 번째 전투에서 실제로 강가 요새를 확보했는지, 30턴 안에 승리했는지, + 몇 턴이 걸렸는지를 승리 기록에서 함께 읽는다. +- 요새를 놓쳤다면 유비·관우의 방어선 회고, 요새는 지켰지만 늦었다면 + 유비·장비의 추격 속도 회고, 둘 다 달성했다면 관우·장비의 본영 진입 + 대화를 `새 이야기 · 우선`으로 한 곳만 제시한다. +- 전투 결과와 반대되는 칭찬을 하지 않으며, 오래되거나 손상되어 목표와 + 턴을 신뢰할 수 없는 저장은 기존 정적 대사를 그대로 사용한다. +- 출진 준비의 `공명 대화` 권장은 우선 귀환 대화 한 건만 완료하면 충족된다. + 나머지 두 대화와 공명 보상은 사라지지 않고 자유 선택으로 남는다. +- 우선 대사를 완료하면 대화 탭과 카드의 새 이야기 표식이 사라지고, + 다시 군영에 들어와도 완료 상태를 유지한다. +- 네 가지 목표 조합과 Canvas·WebGL의 1920x1080 진행을 자동 검증한다. + +다음 초반 묶음은 세 번째 군영에서 정보 수집, 장비 정비, 동료 대화 가운데 +이번 출진의 준비 우선순위 하나를 직접 정하고, 그 선택이 네 번째 전투 +브리핑과 첫 3턴 안에 짧고 명확하게 되돌아오게 한다. ## 장기 개선 순서 diff --git a/package.json b/package.json index fd09312..94d4c15 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ "verify:first-pursuit-camp:browser": "node scripts/verify-first-pursuit-camp-exploration-browser.mjs", "verify:second-battle-relief": "node scripts/verify-second-battle-relief-exploration.mjs", "verify:second-battle-relief:browser": "node scripts/verify-second-battle-relief-exploration-browser.mjs", + "verify:third-battle-return": "node scripts/verify-third-battle-return-dialogue.mjs", + "verify:third-battle-return:browser": "node scripts/verify-third-battle-priority-return-browser.mjs", "verify:prologue-exploration-assets": "node scripts/verify-prologue-exploration-asset-data.mjs", "verify:exploration-backgrounds": "node scripts/verify-prologue-exploration-asset-data.mjs", "verify:exploration-characters": "node scripts/verify-exploration-character-asset-data.mjs", @@ -73,7 +75,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:city-stays:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && 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:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return: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-static-data.mjs b/scripts/verify-static-data.mjs index 1bbcc60..58ea3b7 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -27,6 +27,7 @@ const checks = [ 'scripts/verify-first-battle-camaraderie-memory.mjs', 'scripts/verify-first-pursuit-scout-memory.mjs', 'scripts/verify-second-battle-relief-exploration.mjs', + 'scripts/verify-third-battle-return-dialogue.mjs', 'scripts/verify-prologue-exploration-asset-data.mjs', 'scripts/verify-exploration-character-asset-data.mjs', 'scripts/verify-prologue-dialogue-portrait-data.mjs', diff --git a/scripts/verify-third-battle-priority-return-browser.mjs b/scripts/verify-third-battle-priority-return-browser.mjs new file mode 100644 index 0000000..898bfb0 --- /dev/null +++ b/scripts/verify-third-battle-priority-return-browser.mjs @@ -0,0 +1,1111 @@ +import assert from 'node:assert/strict'; +import { mkdirSync } from 'node:fs'; +import { chromium } from 'playwright'; +import { createServer } from 'vite'; +import { + desktopBrowserContextOptions, + desktopBrowserDeviceScaleFactor, + desktopBrowserViewport +} from './desktop-browser-viewport.mjs'; + +const sourceBattleId = 'third-battle-guangzong-road'; +const priorityBadgePattern = /우선/; +const resonanceChecklistLabel = '공명 대화'; + +const fixtures = [ + { + id: 'canvas-fort-missed-quick-missed', + renderer: 'canvas', + expectedRendererType: 1, + fortAchieved: false, + quickAchieved: false, + sourceTurn: 34, + expectedVariant: 'fort-recovery', + expectedTargetDialogueId: 'liu-guan-after-guangzong-road' + }, + { + id: 'canvas-fort-missed-quick-achieved', + renderer: 'canvas', + expectedRendererType: 1, + fortAchieved: false, + quickAchieved: true, + sourceTurn: 24, + expectedVariant: 'fort-recovery', + expectedTargetDialogueId: 'liu-guan-after-guangzong-road' + }, + { + id: 'canvas-fort-achieved-quick-missed', + renderer: 'canvas', + expectedRendererType: 1, + fortAchieved: true, + quickAchieved: false, + sourceTurn: 34, + expectedVariant: 'tempo-recovery', + expectedTargetDialogueId: 'liu-zhang-after-guangzong-road' + }, + { + id: 'canvas-fort-achieved-quick-achieved', + renderer: 'canvas', + expectedRendererType: 1, + fortAchieved: true, + quickAchieved: true, + sourceTurn: 24, + expectedVariant: 'decisive-advance', + expectedTargetDialogueId: 'guan-zhang-after-guangzong-road' + }, + { + id: 'webgl-fort-achieved-quick-achieved', + renderer: 'webgl', + expectedRendererType: 2, + fortAchieved: true, + quickAchieved: true, + sourceTurn: 24, + expectedVariant: 'decisive-advance', + expectedTargetDialogueId: 'guan-zhang-after-guangzong-road' + } +]; + +const requestedRenderer = + cliOption('renderer') ?? + process.env.VERIFY_THIRD_BATTLE_PRIORITY_RETURN_RENDERER ?? + 'both'; +assert( + ['canvas', 'webgl', 'both'].includes(requestedRenderer), + `Unknown renderer "${requestedRenderer}". Use canvas, webgl, or both.` +); +const selectedFixtures = fixtures.filter( + ({ renderer }) => + requestedRenderer === 'both' || renderer === requestedRenderer +); + +const server = await createServer({ + logLevel: 'error', + server: { host: '127.0.0.1', port: 0, hmr: false }, + appType: 'spa' +}); +let browser; + +try { + mkdirSync('dist', { recursive: true }); + await server.listen(); + const address = server.httpServer?.address(); + assert( + address && typeof address !== 'string', + 'Expected a local Vite verification server.' + ); + const baseUrl = `http://127.0.0.1:${address.port}/heros_web/`; + browser = await chromium.launch({ + headless: + process.env.VERIFY_THIRD_BATTLE_PRIORITY_RETURN_HEADLESS !== + '0' + }); + + for (const fixture of selectedFixtures) { + await verifyFixture(browser, baseUrl, fixture); + } + + console.log( + `Third-battle priority-return browser verification passed for ${selectedFixtures + .map(({ id }) => id) + .join(', ')} at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} CSS pixels, DPR ${desktopBrowserDeviceScaleFactor}.` + ); +} finally { + await browser?.close(); + await server.close(); +} + +async function verifyFixture(browser, baseUrl, fixture) { + const context = await browser.newContext(desktopBrowserContextOptions); + const page = await context.newPage(); + page.setDefaultTimeout(30000); + const pageErrors = []; + const consoleErrors = []; + page.on('pageerror', (error) => + pageErrors.push(error.stack ?? error.message) + ); + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + + try { + const url = new URL(baseUrl); + url.searchParams.set('debug', '1'); + url.searchParams.set('renderer', fixture.renderer); + await page.goto(url.toString(), { + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await assertFhdViewport(page, fixture); + + const seeded = await seedThirdVictoryCamp(page, fixture); + assert.deepEqual( + seeded, + { + step: 'third-camp', + latestBattleId: sourceBattleId, + reportBattleId: sourceBattleId, + settlementOutcome: 'victory', + sourceTurn: fixture.sourceTurn, + fortAchieved: fixture.fortAchieved, + quickAchieved: fixture.quickAchieved, + dialogueCompleted: false, + dialogueChoiceId: null + }, + `${fixture.id}: unexpected seeded campaign state.` + ); + + await page.evaluate(async () => { + await window.__HEROS_DEBUG__.goToCamp(); + }); + let camp = await waitForPriorityReturn( + page, + fixture, + false + ); + assert.equal(camp.campaign.step, 'third-camp'); + assert.equal(camp.campBattleId, sourceBattleId); + assertPriorityReturn(camp.thirdBattlePriorityReturn, fixture, { + completed: false, + requireRowBounds: false + }); + + const dialogueTab = camp.campTabs.find( + ({ id }) => id === 'dialogue' + ); + assert(dialogueTab, `${fixture.id}: dialogue tab is missing.`); + assert.equal(dialogueTab.interactive, true); + assert.equal( + dialogueTab.newBadgeVisible, + true, + `${fixture.id}: the dialogue tab must advertise the pending priority return.` + ); + assert.match( + dialogueTab.newBadgeText ?? '', + priorityBadgePattern, + `${fixture.id}: the dialogue tab badge must identify the priority return.` + ); + assertBoundsInsideViewport( + dialogueTab.bounds, + `${fixture.id} dialogue tab` + ); + assert.equal( + resonanceChecklist(camp).complete, + false, + `${fixture.id}: resonance dialogue must remain incomplete before the priority return.` + ); + await capture( + page, + `dist/verification-third-battle-priority-return-${fixture.id}-tab-badge.png` + ); + + await clickSceneBounds(page, 'CampScene', dialogueTab.bounds); + await page.waitForFunction( + () => + window.__HEROS_DEBUG__?.camp?.()?.activeTab === + 'dialogue' + ); + camp = await waitForPriorityReturn(page, fixture, false, true); + const priorityReturn = camp.thirdBattlePriorityReturn; + assertPriorityReturn(priorityReturn, fixture, { + completed: false, + requireRowBounds: true + }); + assertBoundsInsideViewport( + priorityReturn.rowBounds, + `${fixture.id} priority dialogue row` + ); + + let badgeProbe = await readPriorityRowBadge( + page, + priorityReturn.rowBounds + ); + assert.equal( + badgeProbe.markers.length, + 1, + `${fixture.id}: expected exactly one visible priority marker in the target row: ${JSON.stringify(badgeProbe)}` + ); + assert.match( + badgeProbe.markers[0].text, + priorityBadgePattern + ); + + const rowControl = await readDialogueControlPoint( + page, + 'row', + fixture.expectedTargetDialogueId + ); + await page.mouse.click(rowControl.x, rowControl.y); + await page.waitForFunction( + (dialogueId) => + window.__HEROS_DEBUG__?.camp?.()?.selectedDialogue + ?.id === dialogueId, + fixture.expectedTargetDialogueId + ); + + camp = await readCamp(page); + const selectedDialogue = camp.selectedDialogue; + assert.equal( + selectedDialogue.id, + fixture.expectedTargetDialogueId + ); + assert.equal( + selectedDialogue.title, + priorityReturn.dialogueTitle + ); + assert.deepEqual( + selectedDialogue.lines, + priorityReturn.dialogueLines + ); + assert.equal(selectedDialogue.completed, false); + assert( + selectedDialogue.choices.length > 0, + `${fixture.id}: the priority dialogue must expose at least one completion choice.` + ); + selectedDialogue.choices.forEach((choice) => { + assertBoundsInsideViewport( + choice.bounds, + `${fixture.id} choice ${choice.id}` + ); + }); + assertNoOverlappingBounds( + selectedDialogue.choices.map(({ id, bounds }) => ({ + id, + bounds + })), + `${fixture.id} dialogue choices` + ); + assertVariantNarrative(priorityReturn, fixture); + await capture( + page, + `dist/verification-third-battle-priority-return-${fixture.id}-pending-dialogue.png` + ); + + const selectedChoice = selectedDialogue.choices[0]; + const choiceControl = await readDialogueControlPoint( + page, + 'choice', + fixture.expectedTargetDialogueId, + selectedChoice.id + ); + await page.mouse.click(choiceControl.x, choiceControl.y); + await page.waitForFunction( + ({ dialogueId, choiceId }) => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + return ( + camp?.thirdBattlePriorityReturn?.completed === true && + camp.thirdBattlePriorityReturn.pending === false && + camp?.selectedDialogue?.id === dialogueId && + camp.selectedDialogue.completed === true && + camp?.campaign?.campDialogueChoiceIds?.[dialogueId] === + choiceId + ); + }, + { + dialogueId: fixture.expectedTargetDialogueId, + choiceId: selectedChoice.id + } + ); + + camp = await readCamp(page); + assertPriorityReturn(camp.thirdBattlePriorityReturn, fixture, { + completed: true, + requireRowBounds: true + }); + assert.equal( + camp.selectedDialogue.id, + fixture.expectedTargetDialogueId + ); + assert.equal(camp.selectedDialogue.completed, true); + assert.equal( + camp.campaign.completedCampDialogues.filter( + (id) => id === fixture.expectedTargetDialogueId + ).length, + 1, + `${fixture.id}: priority dialogue completion must be recorded exactly once.` + ); + assert.equal( + camp.campaign.campDialogueChoiceIds[ + fixture.expectedTargetDialogueId + ], + selectedChoice.id + ); + assert.equal( + camp.campTabs.find(({ id }) => id === 'dialogue') + ?.newBadgeVisible, + false, + `${fixture.id}: the dialogue tab priority badge must clear after completion.` + ); + assert.equal( + resonanceChecklist(camp).complete, + true, + `${fixture.id}: completing the one priority return must satisfy the resonance checklist.` + ); + badgeProbe = await readPriorityRowBadge( + page, + camp.thirdBattlePriorityReturn.rowBounds + ); + assert.equal( + badgeProbe.markers.length, + 0, + `${fixture.id}: the row priority badge must disappear after completion: ${JSON.stringify(badgeProbe)}` + ); + assertPersistedDialogue( + await readCampaignSave(page), + fixture.expectedTargetDialogueId, + selectedChoice.id, + `${fixture.id} completed` + ); + await capture( + page, + `dist/verification-third-battle-priority-return-${fixture.id}-completed.png` + ); + + await page.reload({ + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + await assertFhdViewport(page, fixture); + await page.evaluate(async () => { + await window.__HEROS_DEBUG__.goToCamp(); + }); + camp = await waitForPriorityReturn(page, fixture, true); + assertPriorityReturn(camp.thirdBattlePriorityReturn, fixture, { + completed: true, + requireRowBounds: false + }); + assert.equal( + camp.campTabs.find(({ id }) => id === 'dialogue') + ?.newBadgeVisible, + false, + `${fixture.id}: the dialogue tab badge must remain cleared after Camp re-entry.` + ); + assert.equal( + resonanceChecklist(camp).complete, + true, + `${fixture.id}: the resonance checklist must remain complete after Camp re-entry.` + ); + assertPersistedDialogue( + await readCampaignSave(page), + fixture.expectedTargetDialogueId, + selectedChoice.id, + `${fixture.id} re-entered` + ); + + const reenteredDialogueTab = camp.campTabs.find( + ({ id }) => id === 'dialogue' + ); + await clickSceneBounds( + page, + 'CampScene', + reenteredDialogueTab.bounds + ); + camp = await waitForPriorityReturn(page, fixture, true, true); + const reenteredRow = await readDialogueControlPoint( + page, + 'row', + fixture.expectedTargetDialogueId + ); + await page.mouse.click(reenteredRow.x, reenteredRow.y); + await page.waitForFunction( + (dialogueId) => { + const selected = + window.__HEROS_DEBUG__?.camp?.()?.selectedDialogue; + return ( + selected?.id === dialogueId && + selected.completed === true + ); + }, + fixture.expectedTargetDialogueId + ); + camp = await readCamp(page); + assert.equal(camp.selectedDialogue.completed, true); + badgeProbe = await readPriorityRowBadge( + page, + camp.thirdBattlePriorityReturn.rowBounds + ); + assert.equal( + badgeProbe.markers.length, + 0, + `${fixture.id}: the priority marker must stay absent after Camp re-entry.` + ); + await capture( + page, + `dist/verification-third-battle-priority-return-${fixture.id}-reentered.png` + ); + + assert.deepEqual( + pageErrors, + [], + `${fixture.id}: expected no browser page errors: ${JSON.stringify(pageErrors)}` + ); + assert.deepEqual( + consoleErrors.filter( + (message) => + !message.includes( + 'The AudioContext was not allowed to start' + ) + ), + [], + `${fixture.id}: expected no browser console errors: ${JSON.stringify(consoleErrors)}` + ); + } finally { + await context.close(); + } +} + +async function seedThirdVictoryCamp(page, fixture) { + return page.evaluate( + async ({ + battleId, + expectedDialogueId, + fortAchieved, + quickAchieved, + sourceTurn + }) => { + const campaignModule = await import( + '/heros_web/src/game/state/campaignState.ts' + ); + const { battleScenarios } = await import( + '/heros_web/src/game/data/battles.ts' + ); + const scenario = battleScenarios[battleId]; + if (!scenario) { + throw new Error(`Missing battle scenario ${battleId}.`); + } + + const objectives = scenario.objectives.map( + (objective) => { + const achieved = + objective.id === 'fort' + ? fortAchieved + : objective.id === 'quick' + ? quickAchieved + : true; + return { + id: objective.id, + label: objective.label, + achieved, + status: achieved ? 'done' : 'failed', + detail: achieved ? '달성' : '미달성', + category: + objective.id === 'fort' || + objective.id === 'quick' + ? 'bonus' + : 'required', + rewardGold: objective.rewardGold + }; + } + ); + + campaignModule.resetCampaignState(); + campaignModule.setFirstBattleReport({ + battleId: scenario.id, + battleTitle: scenario.title, + outcome: 'victory', + turnNumber: sourceTurn, + rewardGold: scenario.baseVictoryGold, + defeatedEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + totalEnemies: scenario.units.filter( + (unit) => unit.faction === 'enemy' + ).length, + objectives, + units: scenario.units, + bonds: scenario.bonds.map((bond) => ({ + ...bond, + battleExp: 0 + })), + itemRewards: [], + completedCampDialogues: [], + completedCampVisits: [], + createdAt: '2026-07-27T00:00:00.000Z' + }); + let campaign = campaignModule.getCampaignState(); + campaign.dismissedVictoryRewardNoticeBattleIds = [ + ...new Set([ + ...campaign.dismissedVictoryRewardNoticeBattleIds, + battleId + ]) + ]; + campaignModule.setCampaignState(campaign); + campaignModule.completeCampaignAftermath(scenario.id); + + campaign = campaignModule.getCampaignState(); + const settlement = + campaign.battleHistory[battleId]; + return { + step: campaign.step, + latestBattleId: campaign.latestBattleId ?? null, + reportBattleId: + campaign.firstBattleReport?.battleId ?? null, + settlementOutcome: settlement?.outcome ?? null, + sourceTurn: settlement?.turnNumber ?? null, + fortAchieved: + settlement?.objectives.find( + ({ id }) => id === 'fort' + )?.achieved ?? null, + quickAchieved: + settlement?.objectives.find( + ({ id }) => id === 'quick' + )?.achieved ?? null, + dialogueCompleted: + campaign.completedCampDialogues.includes( + expectedDialogueId + ), + dialogueChoiceId: + campaign.campDialogueChoiceIds[ + expectedDialogueId + ] ?? null + }; + }, + { + battleId: sourceBattleId, + expectedDialogueId: fixture.expectedTargetDialogueId, + fortAchieved: fixture.fortAchieved, + quickAchieved: fixture.quickAchieved, + sourceTurn: fixture.sourceTurn + } + ); +} + +async function waitForDebugApi(page) { + await page.waitForFunction( + () => + document.querySelector('canvas') !== null && + window.__HEROS_GAME__ !== undefined && + window.__HEROS_DEBUG__ !== undefined, + undefined, + { timeout: 90000 } + ); +} + +async function waitForPriorityReturn( + page, + fixture, + completed, + requireRowBounds = false +) { + try { + await page.waitForFunction( + ({ + battleId, + expectedVariant, + expectedTargetDialogueId, + fortAchieved, + quickAchieved, + sourceTurn, + completed, + requireRowBounds + }) => { + const debug = window.__HEROS_DEBUG__; + const camp = debug?.camp?.(); + const priority = camp?.thirdBattlePriorityReturn; + return ( + debug?.activeScenes?.().includes('CampScene') && + camp?.scene === 'CampScene' && + camp?.campaign?.step === 'third-camp' && + camp?.campBattleId === battleId && + camp?.campTabs?.some(({ id }) => id === 'dialogue') && + priority?.available === true && + priority.sourceBattleId === battleId && + priority.sourceTurn === sourceTurn && + priority.fortAchieved === fortAchieved && + priority.quickAchieved === quickAchieved && + priority.variant === expectedVariant && + priority.targetDialogueId === expectedTargetDialogueId && + priority.completed === completed && + priority.pending === !completed && + (!requireRowBounds || Boolean(priority.rowBounds)) + ); + }, + { + battleId: sourceBattleId, + expectedVariant: fixture.expectedVariant, + expectedTargetDialogueId: + fixture.expectedTargetDialogueId, + fortAchieved: fixture.fortAchieved, + quickAchieved: fixture.quickAchieved, + sourceTurn: fixture.sourceTurn, + completed, + requireRowBounds + }, + { timeout: 90000 } + ); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + activeScenes: + window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + camp: window.__HEROS_DEBUG__?.camp?.() ?? null + })); + throw new Error( + `${fixture.id}: CampScene did not expose the expected thirdBattlePriorityReturn contract: ${JSON.stringify(diagnostic)}`, + { cause: error } + ); + } + return readCamp(page); +} + +function assertPriorityReturn( + priority, + fixture, + { completed, requireRowBounds } +) { + assert( + priority, + `${fixture.id}: thirdBattlePriorityReturn is missing.` + ); + assert.equal(priority.available, true); + assert.equal(priority.sourceBattleId, sourceBattleId); + assert.equal(priority.sourceTurn, fixture.sourceTurn); + assert.equal( + priority.fortAchieved, + fixture.fortAchieved + ); + assert.equal( + priority.quickAchieved, + fixture.quickAchieved + ); + assert.equal(priority.variant, fixture.expectedVariant); + assert.equal( + priority.targetDialogueId, + fixture.expectedTargetDialogueId + ); + assert.equal(typeof priority.dialogueTitle, 'string'); + assert(priority.dialogueTitle.length > 0); + assert.equal(priority.dialogueLines.length, 3); + assert( + priority.dialogueLines.every( + (line) => typeof line === 'string' && line.length > 0 + ) + ); + assert.equal(priority.completed, completed); + assert.equal(priority.pending, !completed); + if (requireRowBounds) { + assertFiniteBounds( + priority.rowBounds, + `${fixture.id} priority row` + ); + } +} + +function assertVariantNarrative(priority, fixture) { + const text = [ + priority.dialogueTitle, + ...priority.dialogueLines + ].join('\n'); + if (!fixture.fortAchieved) { + assert.match( + text, + /요새/, + `${fixture.id}: fort-recovery dialogue must discuss the fort.` + ); + assert.match( + text, + /확보하지 못|비워 둔|남겼/, + `${fixture.id}: fort-recovery dialogue must not praise a missed fort objective.` + ); + return; + } + if (!fixture.quickAchieved) { + assert.match( + text, + /늦|때를 놓쳤|제한 초과/, + `${fixture.id}: tempo-recovery dialogue must acknowledge the missed quick objective.` + ); + return; + } + assert.match( + text, + /확보|길이 열렸/, + `${fixture.id}: decisive-advance dialogue must acknowledge the achieved fort objective.` + ); + assert.match( + text, + new RegExp(`${fixture.sourceTurn}턴`), + `${fixture.id}: decisive-advance dialogue must retain the source turn.` + ); +} + +function resonanceChecklist(camp) { + const item = camp?.sortieChecklist?.find( + ({ label }) => label === resonanceChecklistLabel + ); + assert( + item, + `CampScene must expose a "${resonanceChecklistLabel}" checklist item: ${JSON.stringify(camp?.sortieChecklist)}` + ); + return item; +} + +async function readPriorityRowBadge(page, rowBounds) { + return page.evaluate((requestedRowBounds) => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CampScene'); + const objectBounds = (object) => { + const bounds = object?.getBounds?.(); + return bounds + ? { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + } + : null; + }; + const intersects = (left, right) => + Boolean( + left && + right && + left.x < right.x + right.width && + left.x + left.width > right.x && + left.y < right.y + right.height && + left.y + left.height > right.y + ); + const markers = (scene?.contentObjects ?? []) + .filter( + (object) => + object?.type === 'Text' && + object.active && + object.visible && + typeof object.text === 'string' && + object.text.includes('우선') + ) + .map((object) => ({ + text: object.text, + bounds: objectBounds(object) + })) + .filter(({ bounds }) => + intersects(bounds, requestedRowBounds) + ); + return { rowBounds: requestedRowBounds, markers }; + }, rowBounds); +} + +async function readDialogueControlPoint( + page, + control, + dialogueId, + choiceId +) { + const probe = await page.evaluate( + ({ control, dialogueId, choiceId }) => { + const scene = + window.__HEROS_GAME__?.scene.getScene('CampScene'); + const state = window.__HEROS_DEBUG__?.camp?.(); + const canvas = document.querySelector('canvas'); + const priority = state?.thirdBattlePriorityReturn; + const dialogue = + state?.selectedDialogue?.id === dialogueId + ? state.selectedDialogue + : null; + const choice = dialogue?.choices?.find( + ({ id }) => id === choiceId + ); + const bounds = + control === 'row' + ? priority?.targetDialogueId === dialogueId + ? priority.rowBounds + : null + : choice?.bounds; + const interactiveObject = + control === 'row' + ? scene?.campDialogueRowButtons?.[dialogueId] + : scene?.campDialogueChoiceButtons?.[ + `${dialogueId}:${choiceId}` + ]; + if ( + !scene || + !canvas || + !bounds || + !interactiveObject?.input?.enabled + ) { + return { + ready: false, + control, + dialogueId, + choiceId: choiceId ?? null, + bounds: bounds ?? null, + priority: priority ?? null, + selectedDialogue: state?.selectedDialogue ?? null + }; + } + const canvasBounds = canvas.getBoundingClientRect(); + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + ready: true, + control, + dialogueId, + choiceId: choiceId ?? null, + bounds, + x: + canvasBounds.left + + (centerX * canvasBounds.width) / + scene.scale.width, + y: + canvasBounds.top + + (centerY * canvasBounds.height) / + scene.scale.height + }; + }, + { control, dialogueId, choiceId } + ); + assert( + probe.ready === true && + Number.isFinite(probe.x) && + Number.isFinite(probe.y), + `Expected an interactive ${control} for ${dialogueId}: ${JSON.stringify(probe)}` + ); + assertBoundsInsideViewport( + probe.bounds, + `${dialogueId} ${control}` + ); + return probe; +} + +async function clickSceneBounds(page, sceneKey, bounds) { + assertFiniteBounds(bounds, `${sceneKey} click target`); + const point = await page.evaluate( + ({ sceneKey, bounds }) => { + const scene = + window.__HEROS_GAME__?.scene.getScene(sceneKey); + const canvas = document.querySelector('canvas'); + const canvasBounds = canvas?.getBoundingClientRect(); + if (!scene || !canvasBounds) { + return null; + } + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + x: + canvasBounds.left + + (centerX * canvasBounds.width) / + scene.scale.width, + y: + canvasBounds.top + + (centerY * canvasBounds.height) / + scene.scale.height + }; + }, + { sceneKey, bounds } + ); + assert( + point && + Number.isFinite(point.x) && + Number.isFinite(point.y), + `Unable to map ${sceneKey} bounds to a browser point: ${JSON.stringify(bounds)}` + ); + await page.mouse.click(point.x, point.y); +} + +async function readCamp(page) { + return page.evaluate(() => + window.__HEROS_DEBUG__?.camp?.() + ); +} + +async function readCampaignSave(page) { + return page.evaluate(() => { + const raw = window.localStorage.getItem( + 'heros-web:campaign-state' + ); + return raw ? JSON.parse(raw) : null; + }); +} + +function assertPersistedDialogue( + save, + targetDialogueId, + choiceId, + label +) { + assert(save, `${label}: current campaign save is missing.`); + assert.equal(save.step, 'third-camp'); + assert.equal(save.latestBattleId, sourceBattleId); + assert.equal( + save.completedCampDialogues.filter( + (id) => id === targetDialogueId + ).length, + 1, + `${label}: the priority dialogue completion must persist exactly once.` + ); + assert.equal( + save.firstBattleReport?.completedCampDialogues?.filter( + (id) => id === targetDialogueId + ).length, + 1, + `${label}: the report completion must persist exactly once.` + ); + assert.equal( + save.campDialogueChoiceIds[targetDialogueId], + choiceId, + `${label}: the selected priority response must persist.` + ); +} + +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 assertBoundsInsideViewport(bounds, label) { + assertFiniteBounds(bounds, label); + const epsilon = 0.01; + assert( + bounds.x >= -epsilon && + bounds.y >= -epsilon && + bounds.x + bounds.width <= + desktopBrowserViewport.width + epsilon && + bounds.y + bounds.height <= + desktopBrowserViewport.height + epsilon, + `${label}: expected bounds inside the 1920x1080 viewport, received ${JSON.stringify(bounds)}` + ); +} + +function assertFiniteBounds(bounds, label) { + assert(bounds, `${label}: bounds are required.`); + assert( + ['x', 'y', 'width', 'height'].every((key) => + Number.isFinite(bounds[key]) + ) && + bounds.width > 0 && + bounds.height > 0, + `${label}: expected positive finite bounds, received ${JSON.stringify(bounds)}` + ); +} + +async function assertFhdViewport(page, fixture) { + const viewport = await page.evaluate(() => { + const canvas = document.querySelector('canvas'); + const bounds = canvas?.getBoundingClientRect(); + return { + width: window.innerWidth, + height: window.innerHeight, + dpr: window.devicePixelRatio, + visualScale: window.visualViewport?.scale ?? 1, + rendererType: + window.__HEROS_GAME__?.renderer?.type ?? null, + canvas: canvas + ? { + width: canvas.width, + height: canvas.height, + clientWidth: canvas.clientWidth, + clientHeight: canvas.clientHeight, + bounds: bounds + ? { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + } + : null + } + : null + }; + }); + assert.equal(viewport.width, desktopBrowserViewport.width); + assert.equal( + viewport.height, + desktopBrowserViewport.height + ); + assert.equal( + viewport.dpr, + desktopBrowserDeviceScaleFactor + ); + assert.equal(viewport.visualScale, 1); + assert.equal( + viewport.rendererType, + fixture.expectedRendererType, + `${fixture.id}: Phaser did not use the requested renderer.` + ); + assert.equal( + viewport.canvas?.width, + desktopBrowserViewport.width + ); + assert.equal( + viewport.canvas?.height, + desktopBrowserViewport.height + ); + assert.equal( + viewport.canvas?.clientWidth, + desktopBrowserViewport.width + ); + assert.equal( + viewport.canvas?.clientHeight, + desktopBrowserViewport.height + ); + assert.equal( + viewport.canvas?.bounds?.width, + desktopBrowserViewport.width + ); + assert.equal( + viewport.canvas?.bounds?.height, + desktopBrowserViewport.height + ); +} + +async function capture(page, path) { + await page.waitForTimeout(250); + 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(40); + try { + await page.screenshot({ path, fullPage: true }); + } finally { + if (loopSlept) { + await page.evaluate(() => + window.__HEROS_GAME__?.loop.wake() + ); + await page.waitForTimeout(40); + } + } +} + +function cliOption(name) { + const equalsPrefix = `--${name}=`; + const equalsOption = process.argv.find((argument) => + argument.startsWith(equalsPrefix) + ); + if (equalsOption) { + return equalsOption.slice(equalsPrefix.length); + } + const index = process.argv.indexOf(`--${name}`); + return index >= 0 ? process.argv[index + 1] : undefined; +} diff --git a/scripts/verify-third-battle-return-dialogue.mjs b/scripts/verify-third-battle-return-dialogue.mjs new file mode 100644 index 0000000..8570ddc --- /dev/null +++ b/scripts/verify-third-battle-return-dialogue.mjs @@ -0,0 +1,422 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'vite'; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); + +try { + const dialogueModule = await server.ssrLoadModule( + '/src/game/data/thirdBattleReturnDialogue.ts' + ); + const { battleScenarios } = await server.ssrLoadModule( + '/src/game/data/battles.ts' + ); + + verifyCanonicalBattleReferences(dialogueModule, battleScenarios); + verifyAllObjectiveCombinations(dialogueModule); + verifyTargetBattleIsolation(dialogueModule); + verifyLegacyReportFallback(dialogueModule); + verifyInvalidLegacyDataFallsBackToStaticDialogue(dialogueModule); + verifyReturnedDataIsCloned(dialogueModule); + + console.log( + 'Third-battle priority return dialogue verification passed (four objective combinations, result-specific priority targets, exact battle isolation, and static-dialogue fallback for invalid legacy data).' + ); +} finally { + await server.close(); +} + +function verifyCanonicalBattleReferences(dialogueModule, battleScenarios) { + const scenario = + battleScenarios[dialogueModule.thirdBattleReturnSourceBattleId]; + assert(scenario, 'The return dialogue must reference a canonical battle.'); + assert.equal(scenario.id, 'third-battle-guangzong-road'); + assert.equal( + scenario.quickVictoryTurnLimit, + dialogueModule.thirdBattleReturnQuickVictoryTurnLimit + ); + + const fortObjective = scenario.objectives.find( + ({ id }) => id === dialogueModule.thirdBattleReturnObjectiveIds.fort + ); + const quickObjective = scenario.objectives.find( + ({ id }) => id === dialogueModule.thirdBattleReturnObjectiveIds.quick + ); + assert.equal(fortObjective?.kind, 'secure-terrain'); + assert.equal(fortObjective?.label, '강가 요새 확보'); + assert.equal(quickObjective?.kind, 'quick-victory'); + assert.equal( + quickObjective?.maxTurn, + dialogueModule.thirdBattleReturnQuickVictoryTurnLimit + ); + assert.deepEqual( + dialogueModule.thirdBattleReturnTargetDialogueIds, + { + fortRecovery: 'liu-guan-after-guangzong-road', + tempoRecovery: 'liu-zhang-after-guangzong-road', + decisiveAdvance: 'guan-zhang-after-guangzong-road' + } + ); + assert.equal( + dialogueModule.thirdBattleReturnPriorityLabel, + '새 이야기 · 우선' + ); +} + +function verifyAllObjectiveCombinations(dialogueModule) { + const testCases = [ + { + fortAchieved: false, + quickAchieved: false, + turnNumber: 35, + expectedVariantId: 'fort-recovery', + expectedTargetDialogueId: 'liu-guan-after-guangzong-road', + expectedSummary: /강가 요새 미확보 · 35턴 · 30턴 제한 초과/, + expectedLine: /35턴 만에/, + forbiddenText: /강가 요새까지 확보했습니다/ + }, + { + fortAchieved: false, + quickAchieved: true, + turnNumber: 24, + expectedVariantId: 'fort-recovery', + expectedTargetDialogueId: 'liu-guan-after-guangzong-road', + expectedSummary: /강가 요새 미확보 · 24턴 신속 승리/, + expectedLine: /전령을 쫓는 속도는 늦지 않았지만/, + forbiddenText: /강가 요새까지 확보했습니다/ + }, + { + fortAchieved: true, + quickAchieved: false, + turnNumber: 34, + expectedVariantId: 'tempo-recovery', + expectedTargetDialogueId: 'liu-zhang-after-guangzong-road', + expectedSummary: /강가 요새 확보 · 34턴 · 30턴 제한 초과/, + expectedLine: /34턴이 걸려/, + forbiddenText: /신속 승리/ + }, + { + fortAchieved: true, + quickAchieved: true, + turnNumber: 22, + expectedVariantId: 'decisive-advance', + expectedTargetDialogueId: 'guan-zhang-after-guangzong-road', + expectedSummary: /강가 요새 확보 · 22턴 신속 승리/, + expectedLine: /22턴 만에/, + forbiddenText: /확보하지 못했습니다/ + } + ]; + + testCases.forEach((testCase) => { + const resolved = + dialogueModule.resolveThirdBattlePriorityReturnDialogue( + campaignWithHistory(dialogueModule, testCase) + ); + assert(resolved, `Expected a dialogue for ${JSON.stringify(testCase)}.`); + assert.equal(resolved.reportSource, 'battle-history'); + assert.equal( + resolved.sourceBattleId, + dialogueModule.thirdBattleReturnSourceBattleId + ); + assert.equal( + resolved.targetDialogueId, + testCase.expectedTargetDialogueId + ); + assert.equal(resolved.priorityLabel, '새 이야기 · 우선'); + assert.equal(resolved.variantId, testCase.expectedVariantId); + assert.equal(resolved.turnNumber, testCase.turnNumber); + assert.equal(resolved.fortAchieved, testCase.fortAchieved); + assert.equal(resolved.quickAchieved, testCase.quickAchieved); + assert.equal(resolved.lines.length, 3); + assert.match(resolved.summary, testCase.expectedSummary); + assert.match(resolved.lines.join('\n'), testCase.expectedLine); + assert.doesNotMatch( + `${resolved.summary}\n${resolved.lines.join('\n')}`, + testCase.forbiddenText, + 'A missed objective must never receive false praise.' + ); + }); + + const failedStatusOverridesAchieved = + dialogueModule.resolveThirdBattlePriorityReturnDialogue({ + battleHistory: { + [dialogueModule.thirdBattleReturnSourceBattleId]: victoryReport( + dialogueModule, + { + fortAchieved: true, + quickAchieved: false, + turnNumber: 35, + fortStatus: 'failed' + } + ) + } + }); + assert.equal( + failedStatusOverridesAchieved?.fortAchieved, + false, + 'A failed objective status must prevent phantom achievement praise.' + ); + assert.equal( + failedStatusOverridesAchieved?.variantId, + 'fort-recovery' + ); +} + +function verifyTargetBattleIsolation(dialogueModule) { + const targetVictory = victoryReport(dialogueModule, { + fortAchieved: true, + quickAchieved: true, + turnNumber: 20 + }); + const otherVictory = { + ...targetVictory, + battleId: 'fourth-battle-guangzong-camp' + }; + + assert.equal( + dialogueModule.resolveThirdBattlePriorityReturnDialogue({ + battleHistory: { + 'fourth-battle-guangzong-camp': otherVictory + }, + firstBattleReport: otherVictory + }), + undefined, + 'An unrelated victory must not create a third-camp priority dialogue.' + ); + assert.equal( + dialogueModule.resolveThirdBattlePriorityReturnDialogue({ + battleHistory: { + [dialogueModule.thirdBattleReturnSourceBattleId]: otherVictory + }, + firstBattleReport: targetVictory + }), + undefined, + 'A mismatched canonical history entry must block stale report fallback.' + ); + assert.equal( + dialogueModule.resolveThirdBattlePriorityReturnDialogue({ + battleHistory: { + [dialogueModule.thirdBattleReturnSourceBattleId]: { + ...targetVictory, + outcome: 'defeat' + } + }, + firstBattleReport: targetVictory + }), + undefined, + 'A canonical defeat must not be replaced by a stale victory report.' + ); + assert.equal( + dialogueModule.resolveThirdBattlePriorityReturnDialogue({ + battleHistory: { + 'legacy-wrong-key': targetVictory + } + }), + undefined, + 'A target settlement stored under another battle key must be ignored.' + ); + + const historyWins = + dialogueModule.resolveThirdBattlePriorityReturnDialogue({ + battleHistory: { + [dialogueModule.thirdBattleReturnSourceBattleId]: targetVictory + }, + firstBattleReport: otherVictory + }); + assert.equal(historyWins?.reportSource, 'battle-history'); + assert.equal(historyWins?.variantId, 'decisive-advance'); +} + +function verifyLegacyReportFallback(dialogueModule) { + const legacyReport = victoryReport(dialogueModule, { + fortAchieved: true, + quickAchieved: true, + turnNumber: 19, + omitStatus: true + }); + const fromMissingHistory = + dialogueModule.resolveThirdBattlePriorityReturnDialogue({ + firstBattleReport: legacyReport + }); + assert.equal(fromMissingHistory?.reportSource, 'legacy-report'); + assert.equal(fromMissingHistory?.variantId, 'decisive-advance'); + assert.equal(fromMissingHistory?.fortAchieved, true); + assert.equal(fromMissingHistory?.quickAchieved, true); + assert.match(fromMissingHistory?.lines.join('\n') ?? '', /19턴 만에/); + + const fromEmptyHistory = + dialogueModule.resolveThirdBattlePriorityReturnDialogue({ + battleHistory: {}, + firstBattleReport: legacyReport + }); + assert.equal(fromEmptyHistory?.reportSource, 'legacy-report'); + assert.equal(fromEmptyHistory?.variantId, 'decisive-advance'); + + const missingTurn = + dialogueModule.resolveThirdBattlePriorityReturnDialogue({ + firstBattleReport: { + ...legacyReport, + turnNumber: 'unknown' + } + }); + assert.equal( + missingTurn, + undefined, + 'A legacy report without a trustworthy turn must preserve the static dialogue.' + ); +} + +function verifyInvalidLegacyDataFallsBackToStaticDialogue( + dialogueModule +) { + const complete = victoryReport(dialogueModule, { + fortAchieved: true, + quickAchieved: true, + turnNumber: 21 + }); + const missingObjective = + dialogueModule.resolveThirdBattlePriorityReturnDialogue({ + firstBattleReport: { + ...complete, + objectives: complete.objectives.filter( + ({ id }) => id !== dialogueModule.thirdBattleReturnObjectiveIds.quick + ) + } + }); + assertNoPriorityOverride( + missingObjective, + 'A legacy report with a missing objective' + ); + + const duplicateObjective = + dialogueModule.resolveThirdBattlePriorityReturnDialogue({ + battleHistory: { + [dialogueModule.thirdBattleReturnSourceBattleId]: { + ...complete, + objectives: [ + ...complete.objectives, + { + id: dialogueModule.thirdBattleReturnObjectiveIds.fort, + achieved: false, + status: 'failed' + } + ] + } + } + }); + assertNoPriorityOverride( + duplicateObjective, + 'A report with duplicate objective ids' + ); + + const invalidObjective = + dialogueModule.resolveThirdBattlePriorityReturnDialogue({ + firstBattleReport: { + ...complete, + objectives: complete.objectives.map((objective) => + objective.id === dialogueModule.thirdBattleReturnObjectiveIds.fort + ? { ...objective, achieved: 'yes' } + : objective + ) + } + }); + assertNoPriorityOverride( + invalidObjective, + 'A legacy report with a non-boolean objective result' + ); + + const inconsistentTurn = + dialogueModule.resolveThirdBattlePriorityReturnDialogue({ + firstBattleReport: victoryReport(dialogueModule, { + fortAchieved: true, + quickAchieved: true, + turnNumber: 31 + }) + }); + assertNoPriorityOverride( + inconsistentTurn, + 'A quick-victory result that contradicts its turn number' + ); +} + +function verifyReturnedDataIsCloned(dialogueModule) { + const campaign = campaignWithHistory(dialogueModule, { + fortAchieved: true, + quickAchieved: true, + turnNumber: 18 + }); + const first = + dialogueModule.resolveThirdBattlePriorityReturnDialogue(campaign); + assert(first); + first.lines[0] = 'mutated'; + + const second = + dialogueModule.resolveThirdBattlePriorityReturnDialogue(campaign); + assert(second); + assert.notEqual(second.lines[0], 'mutated'); +} + +function campaignWithHistory(dialogueModule, options) { + return { + battleHistory: { + [dialogueModule.thirdBattleReturnSourceBattleId]: + victoryReport(dialogueModule, options) + }, + firstBattleReport: { + ...victoryReport(dialogueModule, options), + battleId: 'fourth-battle-guangzong-camp' + } + }; +} + +function victoryReport( + dialogueModule, + { + fortAchieved, + quickAchieved, + turnNumber, + fortStatus, + quickStatus, + omitStatus = false + } +) { + const objective = (id, achieved, status) => ({ + id, + achieved, + ...(omitStatus + ? {} + : { + status: + status ?? + (achieved ? 'done' : 'failed') + }) + }); + return { + battleId: dialogueModule.thirdBattleReturnSourceBattleId, + outcome: 'victory', + turnNumber, + objectives: [ + objective( + dialogueModule.thirdBattleReturnObjectiveIds.fort, + fortAchieved, + fortStatus + ), + objective( + dialogueModule.thirdBattleReturnObjectiveIds.quick, + quickAchieved, + quickStatus + ) + ] + }; +} + +function assertNoPriorityOverride(resolved, condition) { + assert.equal( + resolved, + undefined, + `${condition} must leave the existing static dialogue unchanged.` + ); +} diff --git a/src/game/data/thirdBattleReturnDialogue.ts b/src/game/data/thirdBattleReturnDialogue.ts new file mode 100644 index 0000000..42c7bef --- /dev/null +++ b/src/game/data/thirdBattleReturnDialogue.ts @@ -0,0 +1,304 @@ +export const thirdBattleReturnSourceBattleId = + 'third-battle-guangzong-road'; +export const thirdBattleReturnCampStep = 'third-camp'; +export const thirdBattleReturnPriorityLabel = '새 이야기 · 우선'; +export const thirdBattleReturnQuickVictoryTurnLimit = 30; +export const thirdBattleReturnObjectiveIds = { + fort: 'fort', + quick: 'quick' +} as const; +export const thirdBattleReturnTargetDialogueIds = { + fortRecovery: 'liu-guan-after-guangzong-road', + tempoRecovery: 'liu-zhang-after-guangzong-road', + decisiveAdvance: 'guan-zhang-after-guangzong-road' +} as const; + +export type ThirdBattleReturnDialogueVariantId = + | 'fort-recovery' + | 'tempo-recovery' + | 'decisive-advance'; + +export type ThirdBattleReturnTargetDialogueId = + (typeof thirdBattleReturnTargetDialogueIds)[ + keyof typeof thirdBattleReturnTargetDialogueIds + ]; + +export type ThirdBattleReturnReportSource = + | 'battle-history' + | 'legacy-report'; + +export type ThirdBattleReturnReport = { + battleId?: unknown; + outcome?: unknown; + turnNumber?: unknown; + objectives?: unknown; +}; + +export type ThirdBattleReturnCampaignState = { + battleHistory?: Readonly< + Record + >; + firstBattleReport?: ThirdBattleReturnReport | null; +}; + +export type ThirdBattlePriorityReturnDialogue = { + sourceBattleId: typeof thirdBattleReturnSourceBattleId; + campStep: typeof thirdBattleReturnCampStep; + targetDialogueId: ThirdBattleReturnTargetDialogueId; + priorityLabel: typeof thirdBattleReturnPriorityLabel; + reportSource: ThirdBattleReturnReportSource; + variantId: ThirdBattleReturnDialogueVariantId; + turnNumber: number; + fortAchieved: boolean; + quickAchieved: boolean; + title: string; + summary: string; + lines: [string, string, string]; +}; + +type VictoryReportSelection = { + report: ThirdBattleReturnReport; + source: ThirdBattleReturnReportSource; +}; + +export function resolveThirdBattlePriorityReturnDialogue( + campaign?: ThirdBattleReturnCampaignState | null +): ThirdBattlePriorityReturnDialogue | undefined { + const selection = selectVictoryReport(campaign); + if (!selection) { + return undefined; + } + + const turnNumber = positiveInteger(selection.report.turnNumber); + const fortAchieved = readObjective( + selection.report.objectives, + thirdBattleReturnObjectiveIds.fort + ); + const quickAchieved = readObjective( + selection.report.objectives, + thirdBattleReturnObjectiveIds.quick + ); + if ( + turnNumber === undefined || + fortAchieved === undefined || + quickAchieved === undefined || + quickAchieved !== + (turnNumber <= thirdBattleReturnQuickVictoryTurnLimit) + ) { + return undefined; + } + + return createResultDialogue({ + reportSource: selection.source, + turnNumber, + fortAchieved, + quickAchieved + }); +} + +function selectVictoryReport( + campaign?: ThirdBattleReturnCampaignState | null +): VictoryReportSelection | undefined { + const history = campaign?.battleHistory; + if ( + history && + Object.prototype.hasOwnProperty.call( + history, + thirdBattleReturnSourceBattleId + ) + ) { + const report = history[thirdBattleReturnSourceBattleId]; + return validThirdBattleVictory(report) + ? { report, source: 'battle-history' } + : undefined; + } + + const report = campaign?.firstBattleReport; + return validThirdBattleVictory(report) + ? { report, source: 'legacy-report' } + : undefined; +} + +function validThirdBattleVictory( + report?: ThirdBattleReturnReport | null +): report is ThirdBattleReturnReport { + return Boolean( + report && + report.battleId === thirdBattleReturnSourceBattleId && + report.outcome === 'victory' + ); +} + +function readObjective( + rawObjectives: unknown, + objectiveId: string +): boolean | undefined { + if (!Array.isArray(rawObjectives)) { + return undefined; + } + + const matches = rawObjectives.filter( + (objective) => + isRecord(objective) && + objective.id === objectiveId + ); + if ( + matches.length !== 1 || + typeof matches[0].achieved !== 'boolean' + ) { + return undefined; + } + + return ( + matches[0].achieved === true && + matches[0].status !== 'failed' + ); +} + +function createResultDialogue(options: { + reportSource: ThirdBattleReturnReportSource; + turnNumber: number; + fortAchieved: boolean; + quickAchieved: boolean; +}): ThirdBattlePriorityReturnDialogue { + if (!options.fortAchieved) { + return createFortRecoveryDialogue(options); + } + if (!options.quickAchieved) { + return createTempoRecoveryDialogue(options); + } + return createDecisiveAdvanceDialogue(options); +} + +function createFortRecoveryDialogue(options: { + reportSource: ThirdBattleReturnReportSource; + turnNumber: number; + fortAchieved: boolean; + quickAchieved: boolean; +}): ThirdBattlePriorityReturnDialogue { + const pursuitLine = options.quickAchieved + ? '유비: 전령을 쫓는 속도는 늦지 않았지만, 요새를 비워 둔 탓에 다음 길이 불안해졌소.' + : '유비: 전령을 쫓는 데도 시간이 걸렸고 요새까지 남겼소. 다음에는 길목부터 확실히 끊어야 하오.'; + return baseDialogue({ + ...options, + targetDialogueId: + thirdBattleReturnTargetDialogueIds.fortRecovery, + variantId: 'fort-recovery', + title: '남겨 둔 강가 요새', + summary: + `강가 요새 미확보 · ${quickResultText( + options.quickAchieved, + options.turnNumber + )}`, + lines: [ + `관우: ${options.turnNumber}턴 만에 전령 마원은 막았으나 강가 요새를 끝내 확보하지 못했습니다.`, + pursuitLine, + '관우: 다음 본영전에서는 먼저 물러설 자리를 지우고, 그 뒤에 적장을 몰아붙이겠습니다.' + ] + }); +} + +function createTempoRecoveryDialogue(options: { + reportSource: ThirdBattleReturnReportSource; + turnNumber: number; + fortAchieved: boolean; + quickAchieved: boolean; +}): ThirdBattlePriorityReturnDialogue { + return baseDialogue({ + ...options, + targetDialogueId: + thirdBattleReturnTargetDialogueIds.tempoRecovery, + variantId: 'tempo-recovery', + title: '늦어진 전령 추격', + summary: + `강가 요새 확보 · ${quickResultText( + options.quickAchieved, + options.turnNumber + )}`, + lines: [ + `장비: 강가 요새는 지켰지만 마원을 꺾는 데 ${options.turnNumber}턴이 걸려, 추격의 때를 놓쳤습니다.`, + '유비: 지킬 곳을 지킨 것은 옳소. 이제 전열을 세운 뒤 곧바로 익덕의 창이 이어지게 호흡을 줄이면 되오.', + '장비: 다음 본영전에서는 형님이 길을 잡는 순간 제가 바로 돌파하겠습니다.' + ] + }); +} + +function createDecisiveAdvanceDialogue(options: { + reportSource: ThirdBattleReturnReportSource; + turnNumber: number; + fortAchieved: boolean; + quickAchieved: boolean; +}): ThirdBattlePriorityReturnDialogue { + return baseDialogue({ + ...options, + targetDialogueId: + thirdBattleReturnTargetDialogueIds.decisiveAdvance, + variantId: 'decisive-advance', + title: '본영으로 잇는 승리', + summary: + `강가 요새 확보 · ${quickResultText( + options.quickAchieved, + options.turnNumber + )}`, + lines: [ + `관우: ${options.turnNumber}턴 만에 전령 마원을 꺾고 강가 요새까지 확보했습니다. 광종 본영으로 가는 길이 열렸습니다.`, + '장비: 지킬 곳도 지키고 쫓을 놈도 놓치지 않았으니, 이제 장각의 본영까지 이 기세를 잇읍시다.', + '관우: 좋다. 이번 성과를 자랑으로만 남기지 말고 본영 진입의 군율로 삼자.' + ] + }); +} + +function baseDialogue(options: { + reportSource: ThirdBattleReturnReportSource; + targetDialogueId: ThirdBattleReturnTargetDialogueId; + variantId: ThirdBattleReturnDialogueVariantId; + turnNumber: number; + fortAchieved: boolean; + quickAchieved: boolean; + title: string; + summary: string; + lines: [string, string, string]; +}): ThirdBattlePriorityReturnDialogue { + return { + sourceBattleId: thirdBattleReturnSourceBattleId, + campStep: thirdBattleReturnCampStep, + targetDialogueId: options.targetDialogueId, + priorityLabel: thirdBattleReturnPriorityLabel, + reportSource: options.reportSource, + variantId: options.variantId, + turnNumber: options.turnNumber, + fortAchieved: options.fortAchieved, + quickAchieved: options.quickAchieved, + title: options.title, + summary: options.summary, + lines: [...options.lines] + }; +} + +function quickResultText( + quickAchieved: boolean, + turnNumber: number +) { + return quickAchieved + ? `${turnNumber}턴 신속 승리` + : `${turnNumber}턴 · ${thirdBattleReturnQuickVictoryTurnLimit}턴 제한 초과`; +} + +function positiveInteger(value: unknown) { + return ( + typeof value === 'number' && + Number.isInteger(value) && + value > 0 && + value <= 9999 + ) + ? value + : undefined; +} + +function isRecord(value: unknown): value is Record { + return Boolean( + value && + typeof value === 'object' && + !Array.isArray(value) + ); +} diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 8f11fa3..1b0903e 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -44,6 +44,11 @@ import { secondBattleReliefSourceBattleId, secondBattleReliefVisitId } from '../data/secondBattleReliefExploration'; +import { + resolveThirdBattlePriorityReturnDialogue, + thirdBattleReturnCampStep, + thirdBattleReturnSourceBattleId +} from '../data/thirdBattleReturnDialogue'; import { findCityStayAfterBattle, type CityEquipmentOffer, @@ -11643,7 +11648,18 @@ export class CampScene extends Phaser.Scene { this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds); this.sortieFormationAssignments = this.normalizedSortieFormationAssignments(this.campaign.sortieFormationAssignments); this.sortieItemAssignments = this.normalizedSortieItemAssignments(this.campaign.sortieItemAssignments); - this.selectedDialogueId = this.availableCampDialogues()[0]?.id ?? campDialogues[0].id; + const availableCampDialogues = this.availableCampDialogues(); + const priorityReturnDialogue = this.thirdBattlePriorityReturnDialogue(); + const pendingPriorityReturnDialogueId = + priorityReturnDialogue && + !this.completedCampDialogues().includes(priorityReturnDialogue.targetDialogueId) && + availableCampDialogues.some((dialogue) => dialogue.id === priorityReturnDialogue.targetDialogueId) + ? priorityReturnDialogue.targetDialogueId + : undefined; + this.selectedDialogueId = + pendingPriorityReturnDialogueId ?? + availableCampDialogues[0]?.id ?? + campDialogues[0].id; this.selectedVisitId = this.availableCampVisits()[0]?.id ?? campVisits[0].id; const cityStay = this.currentCityStay(); if (cityStay) { @@ -12883,6 +12899,18 @@ export class CampScene extends Phaser.Scene { return resolveFirstBattleCamaraderieMemory(this.campaign); } + private thirdBattlePriorityReturnDialogue() { + if ( + !this.campaign || + this.retrySortieBattleId || + this.campaign.step !== thirdBattleReturnCampStep || + this.currentCampBattleId() !== thirdBattleReturnSourceBattleId + ) { + return undefined; + } + return resolveThirdBattlePriorityReturnDialogue(this.campaign); + } + private firstPursuitScoutMemoryForNextSortie() { const campaign = this.campaign; const scenario = this.nextSortieScenario(); @@ -12917,6 +12945,18 @@ export class CampScene extends Phaser.Scene { }; } + private campDialogueWithThirdBattlePriorityReturn(dialogue: CampDialogue) { + const priorityReturnDialogue = this.thirdBattlePriorityReturnDialogue(); + if (!priorityReturnDialogue || dialogue.id !== priorityReturnDialogue.targetDialogueId) { + return dialogue; + } + return { + ...dialogue, + title: priorityReturnDialogue.title, + lines: [...priorityReturnDialogue.lines] + }; + } + private hasPendingFirstBattleCampFollowup() { const followup = this.firstBattleCampFollowup(); return Boolean( @@ -12933,6 +12973,14 @@ export class CampScene extends Phaser.Scene { ); } + private hasPendingThirdBattlePriorityReturn() { + const priorityReturnDialogue = this.thirdBattlePriorityReturnDialogue(); + return Boolean( + priorityReturnDialogue && + !this.completedCampDialogues().includes(priorityReturnDialogue.targetDialogueId) + ); + } + private availableCampDialogues(): CampDialogue[] { const battleId = this.currentCampBattleId(); const battleDialogues = campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(battleId)); @@ -12940,7 +12988,9 @@ export class CampScene extends Phaser.Scene { const available = dialogues.length > 0 ? dialogues : campDialogues.filter((dialogue) => dialogue.availableAfterBattleIds.includes(defaultBattleScenario.id)); - const resolved = available.map((dialogue) => this.campDialogueWithFirstBattleFollowup(dialogue)); + const resolved = available + .map((dialogue) => this.campDialogueWithFirstBattleFollowup(dialogue)) + .map((dialogue) => this.campDialogueWithThirdBattlePriorityReturn(dialogue)); const camaraderieDialogue = this.firstBattleCamaraderieDialogue(); return camaraderieDialogue && !resolved.some((dialogue) => dialogue.id === camaraderieDialogue.id) ? [...resolved, camaraderieDialogue] @@ -13936,6 +13986,7 @@ export class CampScene extends Phaser.Scene { const active = this.activeTab === tab; const pendingFirstBattleFollowup = tab === 'dialogue' && this.hasPendingFirstBattleCampFollowup(); const pendingCamaraderieMemory = tab === 'dialogue' && this.hasPendingFirstBattleCamaraderieMemory(); + const pendingThirdBattleReturn = tab === 'dialogue' && this.hasPendingThirdBattlePriorityReturn(); const pendingCampVisit = tab === 'visit' && this.availableCampVisits().some( (visit) => !this.completedCampVisits().includes(visit.id) ); @@ -13946,11 +13997,19 @@ export class CampScene extends Phaser.Scene { indicator.setVisible(active || hovered); indicator.setAlpha(active ? 1 : 0.66); if (tab === 'dialogue' && newBadge) { - newBadge.setText(pendingFirstBattleFollowup ? '회고' : pendingCamaraderieMemory ? '전우' : '회고'); + newBadge.setText( + pendingThirdBattleReturn + ? '우선' + : pendingFirstBattleFollowup + ? '회고' + : pendingCamaraderieMemory + ? '전우' + : '회고' + ); } newBadge?.setVisible(Boolean( (tab === 'city' && this.hasPendingCityStayActions()) || - (tab === 'dialogue' && (pendingFirstBattleFollowup || pendingCamaraderieMemory)) || + (tab === 'dialogue' && (pendingThirdBattleReturn || pendingFirstBattleFollowup || pendingCamaraderieMemory)) || pendingCampVisit || (tab === 'supplies' && pendingCategories.has('supplies')) || (tab === 'equipment' && pendingCategories.has('equipment')) @@ -19838,6 +19897,11 @@ export class CampScene extends Phaser.Scene { bg.setStrokeStyle(1, palette.gold, 0.4); const availableDialogues = this.availableCampDialogues(); const completedDialogues = this.completedAvailableDialogues().length; + const thirdBattlePriorityReturn = this.thirdBattlePriorityReturnDialogue(); + const priorityDialogueCompleted = Boolean( + thirdBattlePriorityReturn && + this.completedCampDialogues().includes(thirdBattlePriorityReturn.targetDialogueId) + ); const availableVisits = this.availableCampVisits(); const completedVisits = this.completedAvailableVisits().length; const selectedUnits = this.selectedSortieUnits(); @@ -19852,8 +19916,14 @@ export class CampScene extends Phaser.Scene { const sortieNote = this.nextSortieRule().note; const recoveryHint = this.sortieRecoveryHint(); const hasBattle = Boolean(this.nextSortieScenario()); + const dialogueProgress = thirdBattlePriorityReturn + ? `대화 우선 ${priorityDialogueCompleted ? 1 : 0}/1 · 선택 ${Math.max( + 0, + completedDialogues - Number(priorityDialogueCompleted) + )}/${Math.max(0, availableDialogues.length - 1)}` + : `대화 ${completedDialogues}/${availableDialogues.length}`; const readyLine = [ - `대화 ${completedDialogues}/${availableDialogues.length}`, + dialogueProgress, `방문 ${completedVisits}/${availableVisits.length}`, `${hasBattle ? '출전' : '동행'} ${selectedSummary}`, `보유 ${inventorySummary}` @@ -20111,6 +20181,20 @@ export class CampScene extends Phaser.Scene { const assignedSupplyCount = campSupplies.reduce((total, supply) => total + this.totalAssignedSupplyCount(supply.usableId), 0); const availableDialogues = this.availableCampDialogues(); const completedDialogues = this.completedAvailableDialogues().length; + const thirdBattlePriorityReturn = this.thirdBattlePriorityReturnDialogue(); + const priorityDialogueCompleted = Boolean( + thirdBattlePriorityReturn && + this.completedCampDialogues().includes(thirdBattlePriorityReturn.targetDialogueId) + ); + const dialogueChecklistComplete = thirdBattlePriorityReturn + ? priorityDialogueCompleted + : availableDialogues.length === 0 || completedDialogues >= availableDialogues.length; + const dialogueChecklistDetail = thirdBattlePriorityReturn + ? `우선 ${priorityDialogueCompleted ? 1 : 0}/1 · 선택 ${Math.max( + 0, + completedDialogues - Number(priorityDialogueCompleted) + )}/${Math.max(0, availableDialogues.length - 1)}` + : `${completedDialogues}/${availableDialogues.length} 완료`; const availableVisits = this.availableCampVisits(); const completedVisits = this.completedAvailableVisits().length; const selected = this.selectedSortieUnits(); @@ -20176,8 +20260,8 @@ export class CampScene extends Phaser.Scene { }, { label: '공명 대화', - complete: availableDialogues.length === 0 || completedDialogues >= availableDialogues.length, - detail: `${completedDialogues}/${availableDialogues.length} 완료`, + complete: dialogueChecklistComplete, + detail: dialogueChecklistDetail, priority: 'recommended' }, { @@ -22740,6 +22824,7 @@ export class CampScene extends Phaser.Scene { const dialogues = this.availableCampDialogues(); const firstBattleFollowup = this.firstBattleCampFollowup(); const camaraderieDialogue = this.firstBattleCamaraderieDialogue(); + const thirdBattlePriorityReturn = this.thirdBattlePriorityReturnDialogue(); const compactDialogueList = dialogues.length > 3; const dialogueRowGap = compactDialogueList ? 35 : 64; const dialogueRowHeight = compactDialogueList ? 30 : 48; @@ -22764,12 +22849,22 @@ export class CampScene extends Phaser.Scene { }); const isFirstBattleFollowup = firstBattleFollowup?.targetDialogueId === dialogue.id; const isCamaraderieMemory = camaraderieDialogue?.id === dialogue.id; - const title = isFirstBattleFollowup || isCamaraderieMemory + const isThirdBattlePriorityReturn = + thirdBattlePriorityReturn?.targetDialogueId === dialogue.id; + const pendingThirdBattlePriorityReturn = + isThirdBattlePriorityReturn && !completed; + const title = isFirstBattleFollowup || isCamaraderieMemory || isThirdBattlePriorityReturn ? this.compactText(dialogue.title, compactDialogueList ? 20 : 14) : dialogue.title; this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 3 : 8), title, this.textStyle(compactDialogueList ? 11 : 15, completed ? '#a8ffd0' : '#f2e3bf', true))); - if (isFirstBattleFollowup || isCamaraderieMemory) { - const badgeLabel = completed ? '완료' : isCamaraderieMemory ? '전우 기억' : '회고'; + if (isFirstBattleFollowup || isCamaraderieMemory || pendingThirdBattlePriorityReturn) { + const badgeLabel = pendingThirdBattlePriorityReturn + ? thirdBattlePriorityReturn?.priorityLabel ?? '새 이야기 · 우선' + : completed + ? '완료' + : isCamaraderieMemory + ? '전우 기억' + : '회고'; this.track( this.add.text( x + 320, @@ -22777,7 +22872,13 @@ export class CampScene extends Phaser.Scene { badgeLabel, { ...this.textStyle(compactDialogueList ? 8 : 9, completed ? '#a8ffd0' : '#fff2b8', true), - backgroundColor: completed ? '#21402d' : isCamaraderieMemory ? '#36507a' : '#8a3f25', + backgroundColor: completed + ? '#21402d' + : pendingThirdBattlePriorityReturn + ? '#8a3f25' + : isCamaraderieMemory + ? '#36507a' + : '#8a3f25', padding: { x: 4, y: 2 } } ) @@ -22803,12 +22904,25 @@ export class CampScene extends Phaser.Scene { return; } const completed = this.completedCampDialogues().includes(dialogue.id); + const thirdBattlePriorityReturn = this.thirdBattlePriorityReturnDialogue(); + const isThirdBattlePriorityReturn = + thirdBattlePriorityReturn?.targetDialogueId === dialogue.id; const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92)); bg.setOrigin(0); bg.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.5); this.track(this.add.text(x + 18, y + 16, dialogue.title, this.textStyle(20, '#f2e3bf', true))); + if (isThirdBattlePriorityReturn) { + this.track( + this.add.text( + x + 18, + y + 45, + `전투 기록 · ${thirdBattlePriorityReturn.summary}`, + this.textStyle(11, completed ? '#a8ffd0' : '#ffdf7b', true) + ) + ); + } const dialogueBody = this.track( - this.add.text(x + 18, y + 52, dialogue.lines.join('\n'), { + this.add.text(x + 18, y + (isThirdBattlePriorityReturn ? 70 : 52), dialogue.lines.join('\n'), { ...this.textStyle(14, '#d4dce6'), wordWrap: { width: width - 36, useAdvancedWrap: true }, lineSpacing: 3 @@ -24152,11 +24266,14 @@ export class CampScene extends Phaser.Scene { private showDialogueReward(dialogue: CampDialogue, choice: CampDialogueChoice, rewardExp: number) { const followup = this.firstBattleCampFollowup(); const camaraderieMemory = this.firstBattleCamaraderieMemory(); + const thirdBattlePriorityReturn = this.thirdBattlePriorityReturnDialogue(); const preparationLine = camaraderieMemory?.dialogue.id === dialogue.id ? `\n다음 준비 · ${camaraderieMemory.unitNames.join('↔')}를 직접 핵심 공명조로 지정하면 추격 +${camaraderieMemory.bonusRate}%p · 자동 지정 없음` : followup?.targetDialogueId === dialogue.id ? `\n다음 준비 · ${followup.recommendationTitle}` - : ''; + : thirdBattlePriorityReturn?.targetDialogueId === dialogue.id + ? '\n귀환 회고 완료 · 나머지 동료 대화는 자유 선택' + : ''; this.showCampNotice(`획득 내역 · ${choice.label} · 공명 +${rewardExp}\n응답 · ${choice.response}${preparationLine}`); } @@ -25155,6 +25272,7 @@ export class CampScene extends Phaser.Scene { const firstBattleCampFollowup = this.firstBattleCampFollowup(); const firstBattleCamaraderieMemory = this.firstBattleCamaraderieMemory(); const firstBattleCamaraderieDialogue = this.firstBattleCamaraderieDialogue(); + const thirdBattlePriorityReturn = this.thirdBattlePriorityReturnDialogue(); const firstPursuitScoutVisit = this.availableCampVisits().find( (visit) => visit.id === firstPursuitScoutVisitId ); @@ -25326,6 +25444,32 @@ export class CampScene extends Phaser.Scene { } } : null, + thirdBattlePriorityReturn: thirdBattlePriorityReturn + ? { + available: true, + sourceBattleId: thirdBattlePriorityReturn.sourceBattleId, + sourceTurn: thirdBattlePriorityReturn.turnNumber, + reportSource: thirdBattlePriorityReturn.reportSource, + fortAchieved: thirdBattlePriorityReturn.fortAchieved, + quickAchieved: thirdBattlePriorityReturn.quickAchieved, + variant: thirdBattlePriorityReturn.variantId, + targetDialogueId: thirdBattlePriorityReturn.targetDialogueId, + priorityLabel: thirdBattlePriorityReturn.priorityLabel, + summary: thirdBattlePriorityReturn.summary, + dialogueTitle: thirdBattlePriorityReturn.title, + dialogueLines: [...thirdBattlePriorityReturn.lines], + selected: this.selectedDialogueId === thirdBattlePriorityReturn.targetDialogueId, + completed: this.completedCampDialogues().includes( + thirdBattlePriorityReturn.targetDialogueId + ), + pending: !this.completedCampDialogues().includes( + thirdBattlePriorityReturn.targetDialogueId + ), + rowBounds: this.sortieObjectBoundsDebug( + this.campDialogueRowButtons[thirdBattlePriorityReturn.targetDialogueId] + ) + } + : null, firstPursuitScoutMemory: { visitId: firstPursuitScoutVisitId, available: Boolean(firstPursuitScoutVisit), @@ -25396,6 +25540,8 @@ export class CampScene extends Phaser.Scene { completed: this.completedCampDialogues().includes(selectedCampDialogue.id), firstVictoryFollowup: firstBattleCampFollowup?.targetDialogueId === selectedCampDialogue.id, camaraderieMemory: firstBattleCamaraderieMemory?.dialogue.id === selectedCampDialogue.id, + thirdBattlePriorityReturn: + thirdBattlePriorityReturn?.targetDialogueId === selectedCampDialogue.id, rowBounds: this.sortieObjectBoundsDebug(this.campDialogueRowButtons[selectedCampDialogue.id]), bodyBounds: this.sortieTextBoundsDebug(this.campDialogueBodyText), choices: selectedCampDialogue.choices.map((choice) => ({