diff --git a/docs/game-design-improvement-goal.md b/docs/game-design-improvement-goal.md index c606127..35dbfbd 100644 --- a/docs/game-design-improvement-goal.md +++ b/docs/game-design-improvement-goal.md @@ -317,10 +317,41 @@ RPG로 다듬는다. 전투 예보와 일치하는지를 Canvas와 WebGL 모두 1920x1080 CSS viewport, 100% 확대, DPR 1에서 자동 검증하는 계약으로 고정한다. -다음 묶음은 -[Xbox 목표 명료성 지침](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/109)에 -맞춰 현재 목표·완료 목표·다음 목표를 어디에서 다시 확인할 수 있는지 -캠페인 전반에서 감사한다. +### 완료한 16차 묶음: 길을 잃지 않고 미래를 미리 보지 않는 진군 기록 + +- 현재 목표를 언제든 다시 보고 완료 기록을 구분해 보존하라는 + [Xbox XAG 109](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/109)와, + 목표 알림을 상시·사용자 요청·장시간 비활동 가운데 알맞은 방식으로 + 제공하라는 + [Game Accessibility Guidelines](https://gameaccessibilityguidelines.com/indicate-allow-reminder-of-current-objectives-during-gameplay/)를 + 공통 기록의 기준으로 삼았다. +- 다음 주 임무와 현재 임무 목록을 분리하고 완료 내역은 별도 기록에서 + 다시 보게 하는 + [Final Fantasy XIV 공식 안내](https://na.finalfantasyxiv.com/game_manual/view/)와, + 최근 사건·다음 단서·지금까지의 이야기를 서로 다른 기록으로 나누는 + [Dragon Quest VII 공식 설명서](https://www.nintendo.com/eu/media/downloads/games_8/emanuals/nintendo_3ds_2/dragonquest7/ElectronicManual_Nintendo3DS_DragonQuest7_EN.pdf)를 + 정보 계층의 기준으로 적용했다. +- 캠페인 상태만으로 `◆ 진행`, `✓ 완료`, `? 실마리`를 계산하는 공통 + 진군 기록을 만들었다. 첫 마을·의용군 막사·승전 후일담·성 체류·전투 + 사이 탐색·출진 준비·엔딩까지 같은 규칙으로 최근 완료, 지금 할 일, + 다음 단서를 연결한다. +- 마을·막사·성·막간 탐색과 이야기 화면에서는 상단 버튼이나 `J`로 + 진군 기록을 열고 `J`·`Esc`로 닫는다. 군영에서는 기존 연표를 + `기록 J` 탭으로 바꾸고 같은 세 항목을 한 화면에서 보여 준다. +- 군영 기록은 도달한 큰 흐름과 현재 장만 공개한다. 시작 직후부터 + 형주 붕괴·백제성·남만·오장원 같은 먼 미래 장과 전장 조건을 보여 주던 + 스포일러를 제거하고, 패널 하단이 FHD 화면 밖으로 잘리던 배치도 함께 + 바로잡았다. +- 타이틀의 저장 기록은 막연한 캠페인 단계 대신 저장 당시의 `지금 할 일`을 + 요약한다. 전투 안의 승리 조건과 전술 목표는 기존 전투 HUD가 계속 + 담당해 캠페인 단서와 전술 지시를 섞지 않는다. +- 66개 전투 경계의 미래 정보 격리와 목표 순서를 정적으로 검사하고, + Canvas·WebGL의 1920x1080 CSS viewport, 100% 확대, DPR 1에서 + 포인터·J·Esc 재열기, 카드 경계·비겹침·저장 불변을 자동 검증한다. + +다음 묶음은 탐색 도중 저장한 기록이 군영으로 되돌아가지 않도록 +`activeCampVisitId`와 이어하기 요약을 보존하고, 저장 슬롯에서 +`방금 마친 일 → 지금 할 일`까지 충분히 읽을 수 있는 재개 흐름을 다듬는다. ## 장기 개선 순서 diff --git a/package.json b/package.json index 2f7b26b..115f3ce 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "verify:campaign-save": "node scripts/verify-campaign-save-normalization.mjs", "verify:campaign-completion": "node scripts/verify-campaign-completion.mjs", "verify:campaign-presentation": "node scripts/verify-campaign-presentation-profiles.mjs", + "verify:campaign-objectives": "node scripts/verify-campaign-objective-journal.mjs", + "verify:campaign-objectives:browser": "node scripts/verify-campaign-objective-journal-browser.mjs", "verify:growth-consistency": "node scripts/verify-growth-consistency.mjs", "verify:battle-save": "node scripts/verify-battle-save-normalization.mjs", "verify:battle-usables": "node scripts/verify-battle-usables.mjs", @@ -86,7 +88,7 @@ "verify:title-accessibility:browser": "node scripts/verify-title-accessibility-browser.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:turn-end-risk:browser && pnpm run verify:title-accessibility:browser && pnpm run verify:battle-keyboard:browser && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:xuzhou-stay-payoff: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:third-camp-preparation: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:turn-end-risk:browser && pnpm run verify:campaign-objectives:browser && pnpm run verify:title-accessibility:browser && pnpm run verify:battle-keyboard:browser && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:xuzhou-stay-payoff: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:third-camp-preparation: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/qa-representative-battles.mjs b/scripts/qa-representative-battles.mjs index 582fcb0..1c185f8 100644 --- a/scripts/qa-representative-battles.mjs +++ b/scripts/qa-representative-battles.mjs @@ -1348,7 +1348,7 @@ async function setupBattle(page, battle) { return ( state?.scene === 'BattleScene' && state?.battleId === battleId && - (state?.phase === 'deployment' || state?.phase === 'idle') && + state?.phase === 'deployment' && state?.mapBackgroundReady === true ); }, @@ -1536,7 +1536,7 @@ async function setupCumulativeBattle(page, battle, firstBattle, route) { return ( state?.scene === 'BattleScene' && state?.battleId === battleId && - (state?.phase === 'deployment' || state?.phase === 'idle') && + state?.phase === 'deployment' && state?.mapBackgroundReady === true ); }, @@ -1649,7 +1649,9 @@ async function startDeploymentIfNeeded(page, battleId) { }, battleId); if (state?.phase !== 'deployment') { - return; + throw new Error( + `Expected ${battleId} to expose deployment before battle start: ${JSON.stringify(state)}` + ); } await clickBattleDeploymentStart(page); @@ -1844,6 +1846,20 @@ async function enterCampaignCampFromBattleResult(page, battle, route) { settlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement); } + await page.waitForFunction( + () => { + const resultSettlement = window.__HEROS_DEBUG__?.battle()?.resultSettlement; + return ( + resultSettlement?.status === 'complete' && + resultSettlement?.cta?.interactive === true && + resultSettlement?.cta?.bounds + ); + }, + undefined, + { timeout: 30000 } + ); + settlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement); + const expectedDestination = 'victory-story'; if (settlement?.cta?.destination !== expectedDestination) { throw new Error( @@ -1915,6 +1931,21 @@ async function enterFinalCampFromBattleResult(page) { ); settlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement); } + + await page.waitForFunction( + () => { + const resultSettlement = window.__HEROS_DEBUG__?.battle()?.resultSettlement; + return ( + resultSettlement?.status === 'complete' && + resultSettlement?.cta?.interactive === true && + resultSettlement?.cta?.bounds + ); + }, + undefined, + { timeout: 30000 } + ); + settlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement); + if (settlement?.cta?.destination !== 'victory-story') { throw new Error(`Final victory must continue through its aftermath before the final camp: ${JSON.stringify(settlement)}`); } diff --git a/scripts/verify-campaign-objective-journal-browser.mjs b/scripts/verify-campaign-objective-journal-browser.mjs new file mode 100644 index 0000000..2e862a0 --- /dev/null +++ b/scripts/verify-campaign-objective-journal-browser.mjs @@ -0,0 +1,1540 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; +import { + desktopBrowserContextOptions, + desktopBrowserDeviceScaleFactor, + desktopBrowserViewport, + desktopBrowserViewportBounds +} from './desktop-browser-viewport.mjs'; + +const sourceBattleId = 'first-battle-zhuo-commandery'; +const visitId = 'first-pursuit-scout-tent'; +const renderer = + process.env.VERIFY_CAMPAIGN_OBJECTIVE_JOURNAL_RENDERER; +const rendererNames = ['canvas', 'webgl']; +const forbiddenFutureTerms = [ + '제갈량', + '형주 붕괴와 이릉', + '백제성', + '맹획', + '오장원' +]; + +if (!renderer) { + for (const requestedRenderer of rendererNames) { + const result = spawnSync( + process.execPath, + [fileURLToPath(import.meta.url)], + { + cwd: process.cwd(), + env: { + ...process.env, + VERIFY_CAMPAIGN_OBJECTIVE_JOURNAL_RENDERER: + requestedRenderer + }, + stdio: 'inherit' + } + ); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + } + process.exit(0); +} + +assert( + rendererNames.includes(renderer), + `Unsupported campaign objective journal renderer "${renderer}".` +); + +const baseUrl = + process.env.VERIFY_CAMPAIGN_OBJECTIVE_JOURNAL_URL ?? + 'http://127.0.0.1:41808/heros_web/'; +const targetUrl = withDebugOptions(baseUrl, renderer); +const screenshotPath = + `dist/verification-campaign-objective-journal-${renderer}-1920x1080.png`; + +let serverProcess; +let browser; + +try { + mkdirSync('dist', { recursive: true }); + serverProcess = await ensureLocalServer(targetUrl); + browser = await chromium.launch({ + headless: + process.env.VERIFY_CAMPAIGN_OBJECTIVE_JOURNAL_HEADLESS !== + '0', + args: + renderer === 'webgl' + ? [ + '--use-angle=swiftshader', + '--enable-unsafe-swiftshader' + ] + : [] + }); + 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()); + } + }); + + await page.addInitScript(() => window.localStorage.clear()); + await page.goto(targetUrl, { + waitUntil: 'domcontentloaded', + timeout: 90000 + }); + await waitForDebugApi(page); + const seeded = await seedFirstVictoryCamp(page); + assert.deepEqual( + seeded, + { + step: 'first-camp', + latestBattleId: sourceBattleId, + reportBattleId: sourceBattleId, + reportOutcome: 'victory', + settlementOutcome: 'victory', + pendingAftermathBattleId: null + }, + `${renderer}: failed to seed the first-victory camp.` + ); + + await page.evaluate(async () => { + await window.__HEROS_DEBUG__.goToCamp(); + }); + await waitForCampJournalReady(page); + + const runtime = await readRuntimeProbe(page, 'CampScene'); + assertRuntimeBaseline(runtime, renderer); + + const savesBeforeCampJournal = await readCampaignSaves(page); + const openedBy = await openCampJournalWithPointer(page); + const firstOpen = await readSceneJournal( + page, + 'CampScene' + ); + const firstContract = assertJournalContract( + firstOpen, + `${renderer} first-victory camp` + ); + assertFirstVictoryMeaning(firstOpen.snapshot, firstOpen.view); + + const screenshot = await page.screenshot({ + path: screenshotPath, + fullPage: true + }); + assert( + screenshot.byteLength >= 100_000, + `Expected a painted ${renderer} campaign objective journal ` + + `screenshot: ${screenshot.byteLength} bytes.` + ); + + await page.keyboard.press('Escape'); + await waitForJournalVisibility(page, 'CampScene', false); + + await page.keyboard.press('j'); + await waitForJournalVisibility(page, 'CampScene', true); + const keyboardOpen = await readSceneJournal( + page, + 'CampScene' + ); + const keyboardContract = assertJournalContract( + keyboardOpen, + `${renderer} keyboard-opened camp` + ); + assert.deepEqual( + stableJournalContent(keyboardOpen), + stableJournalContent(firstOpen), + `${renderer}: objective journal content changed after Escape and J.` + ); + assert.equal( + keyboardContract.objectCount, + firstContract.objectCount, + `${renderer}: objective journal object count changed after reopen.` + ); + + await page.keyboard.press('j'); + await waitForJournalVisibility(page, 'CampScene', false); + await page.keyboard.press('j'); + await waitForJournalVisibility(page, 'CampScene', true); + const keyboardReopen = await readSceneJournal( + page, + 'CampScene' + ); + const reopenedContract = assertJournalContract( + keyboardReopen, + `${renderer} keyboard-reopened camp` + ); + assert.deepEqual( + stableJournalContent(keyboardReopen), + stableJournalContent(firstOpen), + `${renderer}: objective journal content was not stable across ` + + 'repeated J toggles.' + ); + assert.equal( + reopenedContract.objectCount, + firstContract.objectCount, + `${renderer}: repeated J toggles created duplicate display objects.` + ); + assert.deepEqual( + reopenedContract.cardKeys, + firstContract.cardKeys, + `${renderer}: repeated J toggles duplicated or reordered cards.` + ); + + await page.keyboard.press('Escape'); + await waitForJournalVisibility(page, 'CampScene', false); + assert.deepEqual( + await readCampaignSaves(page), + savesBeforeCampJournal, + `${renderer}: merely viewing the objective journal changed a save.` + ); + + const explorationResult = + await verifyExplorationJournalIfAvailable(page); + const modalIsolationResult = + await verifyModalInputIsolation(page); + + assert.deepEqual( + pageErrors, + [], + `${renderer}: unexpected page errors: ${pageErrors.join(' | ')}` + ); + assert.deepEqual( + consoleErrors, + [], + `${renderer}: unexpected console errors: ` + + consoleErrors.join(' | ') + ); + + console.log( + `Verified ${renderer} campaign objective journal from the ` + + `${openedBy} at ${desktopBrowserViewport.width}x` + + `${desktopBrowserViewport.height}, 100% zoom, DPR ` + + `${desktopBrowserDeviceScaleFactor}; exploration ` + + `${explorationResult}; modal input isolation ` + + `${modalIsolationResult}; captured ${screenshotPath}.` + ); +} finally { + await browser?.close(); + await stopServerProcess(serverProcess); +} + +function withDebugOptions(url, requestedRenderer) { + const parsed = new URL(url); + parsed.searchParams.set('debug', '1'); + parsed.searchParams.set('renderer', requestedRenderer); + return parsed.toString(); +} + +async function waitForDebugApi(page) { + await page.waitForFunction( + () => + document.querySelector('canvas') !== null && + window.__HEROS_GAME__ !== undefined && + window.__HEROS_DEBUG__ !== undefined, + undefined, + { timeout: 90000 } + ); +} + +async function seedFirstVictoryCamp(page) { + return page.evaluate(async (battleId) => { + 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}.`); + } + + campaignModule.resetCampaignState(); + campaignModule.setFirstBattleReport({ + battleId: scenario.id, + battleTitle: scenario.title, + outcome: 'victory', + turnNumber: 6, + 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-28T00:00:00.000Z' + }); + campaignModule.completeCampaignAftermath(scenario.id); + const campaign = campaignModule.getCampaignState(); + return { + step: campaign.step, + latestBattleId: campaign.latestBattleId ?? null, + reportBattleId: + campaign.firstBattleReport?.battleId ?? null, + reportOutcome: + campaign.firstBattleReport?.outcome ?? null, + settlementOutcome: + campaign.battleHistory[battleId]?.outcome ?? null, + pendingAftermathBattleId: + campaign.pendingAftermathBattleId ?? null + }; + }, sourceBattleId); +} + +async function waitForCampJournalReady(page) { + try { + await page.waitForFunction( + () => { + const debug = window.__HEROS_DEBUG__; + const camp = debug?.camp?.(); + const journal = camp?.campaignObjectiveJournal; + const progressTab = camp?.campTabs?.find( + (tab) => tab.id === 'progress' + ); + return ( + debug?.activeScenes?.().includes('CampScene') && + camp?.campaign?.step === 'first-camp' && + journal?.snapshot?.current && + journal?.snapshot?.recentCompleted && + journal?.snapshot?.nextClue && + journal?.view && + progressTab?.interactive === true && + progressTab?.bounds + ); + }, + undefined, + { timeout: 90000 } + ); + } catch (error) { + const diagnostic = await page.evaluate(() => ({ + activeScenes: + window.__HEROS_DEBUG__?.activeScenes?.() ?? [], + camp: window.__HEROS_DEBUG__?.camp?.() ?? null + })); + throw new Error( + `Campaign objective journal did not become ready in ` + + `CampScene: ${JSON.stringify(diagnostic)}`, + { cause: error } + ); + } + await afterTwoFrames(page); +} + +async function openCampJournalWithPointer(page) { + const candidates = await page.evaluate(() => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + const view = camp?.campaignObjectiveJournal?.view; + const progressTab = camp?.campTabs?.find( + (tab) => tab.id === 'progress' + ); + const bounds = (candidate) => + candidate?.bounds ?? candidate ?? null; + const proposed = [ + { + id: 'progress tab', + bounds: + progressTab?.interactive === false + ? null + : bounds(progressTab) + }, + { + id: 'journal trigger', + bounds: + bounds(view?.triggerBounds) ?? + bounds(view?.toggleButtonBounds) ?? + bounds(view?.openButtonBounds) ?? + bounds(view?.buttonBounds) ?? + bounds(view?.trigger) ?? + bounds(view?.toggleButton) ?? + bounds(view?.openButton) + } + ].filter((candidate) => candidate.bounds); + return proposed.filter( + (candidate, index, entries) => + entries.findIndex( + (entry) => + JSON.stringify(entry.bounds) === + JSON.stringify(candidate.bounds) + ) === index + ); + }); + + assert( + candidates.length > 0, + `${renderer}: no interactive progress tab or journal trigger ` + + 'was exposed.' + ); + for (const candidate of candidates) { + assertBoundsContained( + assertFiniteBounds( + candidate.bounds, + `${renderer} ${candidate.id}` + ), + desktopBrowserViewportBounds, + `${renderer} ${candidate.id} in viewport` + ); + await clickNamedSceneBounds( + page, + 'CampScene', + candidate.bounds + ); + try { + await waitForJournalVisibility( + page, + 'CampScene', + true, + 3500 + ); + return candidate.id; + } catch { + // Some campaign builds keep the legacy progress tab and add a + // separate trigger. Try the next real pointer target. + } + } + + const diagnostic = await page.evaluate(() => + window.__HEROS_DEBUG__?.camp?.() + ); + throw new Error( + `${renderer}: pointer targets did not open the objective ` + + `journal: ${JSON.stringify({ candidates, diagnostic })}` + ); +} + +async function waitForJournalVisibility( + page, + sceneKey, + expected, + timeout = 30000 +) { + await page.waitForFunction( + ({ key, visible }) => { + const debug = window.__HEROS_DEBUG__; + const sceneState = + key === 'CampScene' + ? debug?.camp?.() + : debug?.scene(key)?.getDebugState?.(); + const view = sceneState?.campaignObjectiveJournal?.view; + const actual = + typeof view?.visible === 'boolean' + ? view.visible + : typeof view?.open === 'boolean' + ? view.open + : typeof view?.isOpen === 'boolean' + ? view.isOpen + : typeof view?.panelVisible === 'boolean' + ? view.panelVisible + : typeof view?.panel?.visible === 'boolean' + ? view.panel.visible + : null; + return actual === visible; + }, + { key: sceneKey, visible: expected }, + { timeout } + ); + await afterTwoFrames(page); +} + +async function readSceneJournal(page, sceneKey) { + const journal = await page.evaluate((key) => { + const debug = window.__HEROS_DEBUG__; + const sceneState = + key === 'CampScene' + ? debug?.camp?.() + : debug?.scene(key)?.getDebugState?.(); + return sceneState?.campaignObjectiveJournal ?? null; + }, sceneKey); + assert( + journal?.snapshot && journal?.view, + `${renderer}: missing ${sceneKey} campaignObjectiveJournal ` + + `debug contract: ${JSON.stringify(journal)}` + ); + return journal; +} + +function assertJournalContract(journal, label) { + const { snapshot, view } = journal; + assert( + isJournalOpen(view), + `${label}: debug view did not report an open journal.` + ); + ['current', 'recentCompleted', 'nextClue'].forEach((key) => { + assertMeaningfulEntry(snapshot?.[key], `${label} ${key}`); + }); + + const panel = assertFiniteBounds( + findBounds(view, [ + 'panelBounds', + 'overlayBounds', + 'dialogBounds', + 'bounds' + ]) ?? findNestedBounds(view.panel), + `${label} panel` + ); + assertBoundsContained( + panel, + desktopBrowserViewportBounds, + `${label} panel in viewport` + ); + + const buttons = collectButtons(view); + assert( + buttons.length > 0, + `${label}: the view must expose at least one button bound.` + ); + buttons.forEach((button, index) => { + const bounds = assertFiniteBounds( + button.bounds, + `${label} button ${button.id ?? index + 1}` + ); + assertBoundsContained( + bounds, + desktopBrowserViewportBounds, + `${label} button ${button.id ?? index + 1} in viewport` + ); + if ( + button.id !== 'trigger' && + button.id !== 'toggle' && + boundsCenterInside(bounds, panel) + ) { + assertBoundsContained( + bounds, + panel, + `${label} button ${button.id ?? index + 1} in panel` + ); + } + }); + + const cards = collectCards(view); + assert.equal( + cards.length, + 3, + `${label}: expected current, recent-completed, and next-clue ` + + `cards: ${JSON.stringify(cards)}` + ); + const cardBounds = cards.map((card, index) => { + const bounds = assertFiniteBounds( + card.bounds, + `${label} card ${card.id ?? index + 1}` + ); + assertBoundsContained( + bounds, + panel, + `${label} card ${card.id ?? index + 1} in panel` + ); + assertBoundsContained( + bounds, + desktopBrowserViewportBounds, + `${label} card ${card.id ?? index + 1} in viewport` + ); + const titleBounds = assertFiniteBounds( + findBounds(card.source, [ + 'titleBounds', + 'headingBounds', + 'labelBounds' + ]), + `${label} card ${card.id ?? index + 1} title` + ); + const bodyBounds = assertFiniteBounds( + findBounds(card.source, [ + 'bodyBounds', + 'detailBounds', + 'descriptionBounds', + 'textBounds' + ]), + `${label} card ${card.id ?? index + 1} body` + ); + [titleBounds, bodyBounds].forEach((textBounds, textIndex) => { + assertBoundsContained( + textBounds, + bounds, + `${label} card ${card.id ?? index + 1} ` + + `${textIndex === 0 ? 'title' : 'body'} in card` + ); + assertBoundsContained( + textBounds, + panel, + `${label} card ${card.id ?? index + 1} text in panel` + ); + }); + return bounds; + }); + assertPairwiseNonOverlapping(cardBounds, `${label} cards`); + + const visibleText = collectVisibleText(view); + const joinedVisibleText = visibleText.join('\n'); + assert( + /◆\s*진행/.test(joinedVisibleText), + `${label}: visible text is missing "◆ 진행": ` + + JSON.stringify(visibleText) + ); + assert( + /✓\s*완료/.test(joinedVisibleText), + `${label}: visible text is missing "✓ 완료": ` + + JSON.stringify(visibleText) + ); + assert( + /\?\s*실마리/.test(joinedVisibleText), + `${label}: visible text is missing "? 실마리": ` + + JSON.stringify(visibleText) + ); + + const publicText = [ + joinedVisibleText, + ...['current', 'recentCompleted', 'nextClue'].map((key) => + collectStrings(snapshot[key]).join('\n') + ) + ].join('\n'); + forbiddenFutureTerms.forEach((term) => { + assert( + !publicText.includes(term), + `${label}: unrevealed future term "${term}" leaked into ` + + `the visible journal: ${publicText}` + ); + }); + + const cardKeys = cards.map( + (card, index) => card.id ?? `card-${index + 1}` + ); + assert.equal( + new Set(cardKeys).size, + cardKeys.length, + `${label}: duplicate card identities were exposed: ` + + JSON.stringify(cardKeys) + ); + + const objectCount = readObjectCount(view, cards, buttons); + assert( + Number.isInteger(objectCount) && objectCount > 0, + `${label}: expected a finite positive display-object count.` + ); + return { cardKeys, objectCount }; +} + +function assertFirstVictoryMeaning(snapshot, view) { + const currentText = collectStrings(snapshot.current).join(' '); + const recentText = collectStrings( + snapshot.recentCompleted + ).join(' '); + const nextText = collectStrings(snapshot.nextClue).join(' '); + const visibleText = collectVisibleText(view).join(' '); + + assert( + /정찰막|둘러보기/.test(currentText), + `${renderer}: first-camp current objective should direct the ` + + `player to the scout tent: ${currentText}` + ); + assert( + /탁현의 전투|한석 선봉/.test(recentText), + `${renderer}: first-camp recent completion should recount the ` + + `first victory: ${recentText}` + ); + assert( + nextText.trim().length >= 2, + `${renderer}: first-camp next clue must be meaningful.` + ); + assert.notEqual( + normalizeWhitespace(nextText), + normalizeWhitespace(currentText), + `${renderer}: next clue must not duplicate the current action.` + ); + assert.notEqual( + normalizeWhitespace(nextText), + normalizeWhitespace(recentText), + `${renderer}: next clue must not duplicate the recent result.` + ); + assert( + /정찰막|둘러보기/.test(visibleText) && + /탁현의 전투|한석 선봉/.test(visibleText), + `${renderer}: meaningful first-camp journal content was not ` + + `rendered visibly: ${visibleText}` + ); +} + +async function verifyExplorationJournalIfAvailable(page) { + const available = await page.evaluate( + () => + typeof window.__HEROS_DEBUG__ + ?.goToCampVisitExploration === 'function' + ); + if (!available) { + return 'skipped (debug navigation unavailable)'; + } + + await page.evaluate(async (requestedVisitId) => { + await window.__HEROS_DEBUG__.goToCampVisitExploration( + requestedVisitId + ); + }, visitId); + await page.waitForFunction( + () => { + const debug = window.__HEROS_DEBUG__; + const state = debug + ?.scene('CampVisitExplorationScene') + ?.getDebugState?.(); + return ( + debug + ?.activeScenes?.() + .includes('CampVisitExplorationScene') && + state?.ready === true + ); + }, + undefined, + { timeout: 90000 } + ); + await afterTwoFrames(page); + + const state = await page.evaluate(() => + window.__HEROS_DEBUG__ + ?.scene('CampVisitExplorationScene') + ?.getDebugState?.() + ); + if (!state?.campaignObjectiveJournal?.view) { + return 'skipped (exploration journal contract unavailable)'; + } + + const savesBefore = await readCampaignSaves(page); + await page.keyboard.press('j'); + await waitForJournalVisibility( + page, + 'CampVisitExplorationScene', + true + ); + const openJournal = await readSceneJournal( + page, + 'CampVisitExplorationScene' + ); + assertJournalContract( + openJournal, + `${renderer} scout-tent exploration` + ); + await page.keyboard.press('Escape'); + await waitForJournalVisibility( + page, + 'CampVisitExplorationScene', + false + ); + assert.deepEqual( + await readCampaignSaves(page), + savesBefore, + `${renderer}: viewing the exploration journal changed a save.` + ); + return 'verified'; +} + +async function verifyModalInputIsolation(page) { + if (renderer !== 'canvas') { + return 'covered in canvas'; + } + + const verifiedScenes = []; + const visitScene = await page.evaluate(() => { + const scene = window.__HEROS_DEBUG__ + ?.scene('CampVisitExplorationScene'); + if (!scene || typeof scene.openChoicePanel !== 'function') { + return false; + } + scene.openChoicePanel(); + return true; + }); + if (visitScene) { + await page.waitForFunction( + () => { + const state = window.__HEROS_DEBUG__ + ?.scene('CampVisitExplorationScene') + ?.getDebugState?.(); + return state?.choice?.open === true && + state?.choice?.ready === true; + } + ); + const before = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__ + ?.scene('CampVisitExplorationScene') + ?.getDebugState?.(); + return { + choiceOpen: state?.choice?.open ?? false, + choiceId: state?.visit?.choiceId ?? null, + completed: state?.visit?.completed ?? false, + dialogueActive: state?.dialogue?.active ?? false + }; + }); + const savesBefore = await readCampaignSaves(page); + await page.keyboard.press('j'); + await waitForJournalVisibility( + page, + 'CampVisitExplorationScene', + true + ); + await page.keyboard.press('2'); + await afterTwoFrames(page); + const after = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__ + ?.scene('CampVisitExplorationScene') + ?.getDebugState?.(); + return { + choiceOpen: state?.choice?.open ?? false, + choiceId: state?.visit?.choiceId ?? null, + completed: state?.visit?.completed ?? false, + dialogueActive: state?.dialogue?.active ?? false, + journalVisible: + state?.campaignObjectiveJournal?.view?.visible ?? false + }; + }); + assert.deepEqual( + { + choiceOpen: after.choiceOpen, + choiceId: after.choiceId, + completed: after.completed, + dialogueActive: after.dialogueActive + }, + before, + 'canvas: journal leaked numeric input into the exploration choice.' + ); + assert.equal( + after.journalVisible, + true, + 'canvas: exploration journal closed while blocking a choice.' + ); + assert.deepEqual( + await readCampaignSaves(page), + savesBefore, + 'canvas: blocked exploration choice changed a save.' + ); + await page.keyboard.press('Escape'); + await waitForJournalVisibility( + page, + 'CampVisitExplorationScene', + false + ); + verifiedScenes.push('exploration'); + } + + const militiaAvailable = await page.evaluate( + () => typeof window.__HEROS_DEBUG__?.goToMilitiaCamp === 'function' + ); + if (militiaAvailable) { + await page.evaluate(async () => { + await window.__HEROS_DEBUG__.goToMilitiaCamp(); + }); + await page.waitForFunction( + () => { + const debug = window.__HEROS_DEBUG__; + return debug?.activeScenes?.().includes( + 'PrologueMilitiaCampScene' + ) && debug?.militiaCamp?.()?.ready === true; + }, + undefined, + { timeout: 90000 } + ); + await page.evaluate(() => { + const scene = window.__HEROS_DEBUG__ + ?.scene('PrologueMilitiaCampScene'); + for (let index = 0; index < 20 && scene?.dialogueState; index += 1) { + scene.advanceDialogue(); + } + scene?.openFirstCommandChoice?.(); + }); + await page.waitForFunction( + () => { + const choice = window.__HEROS_DEBUG__ + ?.militiaCamp?.()?.commandChoice; + return choice?.open === true && choice?.ready === true; + } + ); + const before = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.militiaCamp?.(); + return { + open: state?.commandChoice?.open ?? false, + pendingId: state?.commandChoice?.pendingId ?? null, + selectedId: state?.commandChoice?.selectedId ?? null, + dialogueActive: state?.dialogue?.active ?? false + }; + }); + const savesBefore = await readCampaignSaves(page); + await page.keyboard.press('j'); + await waitForJournalVisibility( + page, + 'PrologueMilitiaCampScene', + true + ); + await page.keyboard.press('2'); + await page.keyboard.press('Enter'); + await afterTwoFrames(page); + const after = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.militiaCamp?.(); + return { + open: state?.commandChoice?.open ?? false, + pendingId: state?.commandChoice?.pendingId ?? null, + selectedId: state?.commandChoice?.selectedId ?? null, + dialogueActive: state?.dialogue?.active ?? false, + journalVisible: + state?.campaignObjectiveJournal?.view?.visible ?? false + }; + }); + assert.deepEqual( + { + open: after.open, + pendingId: after.pendingId, + selectedId: after.selectedId, + dialogueActive: after.dialogueActive + }, + before, + 'canvas: journal leaked 2/Enter into the militia command choice.' + ); + assert.equal( + after.journalVisible, + true, + 'canvas: militia journal closed while blocking a command choice.' + ); + assert.deepEqual( + await readCampaignSaves(page), + savesBefore, + 'canvas: blocked militia command input changed a save.' + ); + await page.keyboard.press('Escape'); + await waitForJournalVisibility( + page, + 'PrologueMilitiaCampScene', + false + ); + verifiedScenes.push('militia command'); + } + + const cityAvailable = await page.evaluate( + () => typeof window.__HEROS_DEBUG__?.goToCityStay === 'function' + ); + if (cityAvailable) { + await page.evaluate(async () => { + await window.__HEROS_DEBUG__.goToCityStay('xuzhou'); + }); + await page.waitForFunction( + () => { + const debug = window.__HEROS_DEBUG__; + return debug?.activeScenes?.().includes('CityStayScene') && + debug?.cityStay?.()?.ready === true; + }, + undefined, + { timeout: 90000 } + ); + await page.evaluate(() => { + const scene = window.__HEROS_DEBUG__?.scene('CityStayScene'); + for (let index = 0; index < 20 && scene?.dialogueState; index += 1) { + scene.advanceDialogue(); + } + scene?.openChoicePanel?.(); + }); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.cityStay?.()?.choice?.open === true + ); + const before = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.cityStay?.(); + return { + choiceOpen: state?.choice?.open ?? false, + dialogueActive: state?.dialogue?.active ?? false, + campaign: state?.campaign ?? null + }; + }); + const savesBefore = await readCampaignSaves(page); + await page.keyboard.press('j'); + await waitForJournalVisibility(page, 'CityStayScene', true); + await page.keyboard.press('2'); + await afterTwoFrames(page); + const after = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.cityStay?.(); + return { + choiceOpen: state?.choice?.open ?? false, + dialogueActive: state?.dialogue?.active ?? false, + campaign: state?.campaign ?? null, + journalVisible: + state?.campaignObjectiveJournal?.view?.visible ?? false + }; + }); + assert.deepEqual( + { + choiceOpen: after.choiceOpen, + dialogueActive: after.dialogueActive, + campaign: after.campaign + }, + before, + 'canvas: journal leaked numeric input into the city choice.' + ); + assert.equal( + after.journalVisible, + true, + 'canvas: city journal closed while blocking a dialogue choice.' + ); + assert.deepEqual( + await readCampaignSaves(page), + savesBefore, + 'canvas: blocked city choice changed a save.' + ); + await page.keyboard.press('Escape'); + await waitForJournalVisibility(page, 'CityStayScene', false); + verifiedScenes.push('city dialogue'); + } + + return verifiedScenes.length > 0 + ? `verified (${verifiedScenes.join(', ')})` + : 'skipped (debug navigation unavailable)'; +} + +function isJournalOpen(view) { + if (typeof view?.visible === 'boolean') { + return view.visible; + } + if (typeof view?.open === 'boolean') { + return view.open; + } + if (typeof view?.isOpen === 'boolean') { + return view.isOpen; + } + if (typeof view?.panelVisible === 'boolean') { + return view.panelVisible; + } + return view?.panel?.visible === true; +} + +function findBounds(source, keys) { + for (const key of keys) { + const value = source?.[key]; + const bounds = findNestedBounds(value); + if (bounds) { + return bounds; + } + } + return null; +} + +function findNestedBounds(value) { + if (!value || typeof value !== 'object') { + return null; + } + if ( + Number.isFinite(value.x) && + Number.isFinite(value.y) && + Number.isFinite(value.width) && + Number.isFinite(value.height) + ) { + return { + x: value.x, + y: value.y, + width: value.width, + height: value.height + }; + } + return findNestedBounds(value.bounds); +} + +function collectButtons(view) { + const buttons = []; + const append = (id, candidate) => { + const bounds = findNestedBounds(candidate); + if (!bounds) { + return; + } + const signature = JSON.stringify(bounds); + if ( + buttons.some( + (button) => JSON.stringify(button.bounds) === signature + ) + ) { + return; + } + buttons.push({ id, bounds }); + }; + + [ + ['trigger', view.triggerBounds], + ['toggle', view.toggleButtonBounds], + ['open', view.openButtonBounds], + ['button', view.buttonBounds], + ['close', view.closeButtonBounds], + ['trigger', view.trigger], + ['toggle', view.toggleButton], + ['open', view.openButton], + ['close', view.closeButton] + ].forEach(([id, candidate]) => append(id, candidate)); + for (const collectionKey of [ + 'buttons', + 'actions', + 'controls' + ]) { + (view?.[collectionKey] ?? []).forEach( + (button, index) => + append( + button?.id ?? + button?.role ?? + `${collectionKey}-${index + 1}`, + button + ) + ); + } + return buttons; +} + +function collectCards(view) { + const cards = []; + const append = (id, source) => { + const bounds = findNestedBounds(source); + if (!bounds) { + return; + } + const signature = JSON.stringify(bounds); + if ( + cards.some( + (card) => JSON.stringify(card.bounds) === signature + ) + ) { + return; + } + cards.push({ + id: source?.id ?? source?.role ?? id, + bounds, + source + }); + }; + + for (const collectionKey of ['cards', 'sections', 'entries']) { + (view?.[collectionKey] ?? []).forEach((card, index) => + append( + card?.id ?? + card?.role ?? + `${collectionKey}-${index + 1}`, + card + ) + ); + } + [ + ['current', view.currentCard ?? view.current], + [ + 'recentCompleted', + view.recentCompletedCard ?? view.recentCompleted + ], + ['nextClue', view.nextClueCard ?? view.nextClue] + ].forEach(([id, card]) => append(id, card)); + return cards; +} + +function collectVisibleText(view) { + if (Array.isArray(view?.visibleText)) { + return view.visibleText + .flatMap((value) => + typeof value === 'string' + ? [value] + : collectStrings(value) + ) + .filter(Boolean); + } + if (typeof view?.visibleText === 'string') { + return [view.visibleText]; + } + return [ + ...collectStrings(view?.title), + ...collectStrings(view?.cards), + ...collectStrings(view?.sections) + ]; +} + +function collectStrings(value, seen = new Set()) { + if (typeof value === 'string') { + return value.trim() ? [value.trim()] : []; + } + if ( + value === null || + value === undefined || + typeof value !== 'object' || + seen.has(value) + ) { + return []; + } + seen.add(value); + if (Array.isArray(value)) { + return value.flatMap((entry) => + collectStrings(entry, seen) + ); + } + return Object.entries(value).flatMap(([key, entry]) => + /bounds|texture|color|font|id$/i.test(key) + ? [] + : collectStrings(entry, seen) + ); +} + +function assertMeaningfulEntry(entry, label) { + const strings = collectStrings(entry); + assert( + entry && + strings.some( + (value) => + normalizeWhitespace(value).length >= 2 && + !['current', 'completed', 'clue'].includes( + value.toLowerCase() + ) + ), + `${label} must contain meaningful player-facing text: ` + + JSON.stringify(entry) + ); +} + +function readObjectCount(view, cards, buttons) { + const explicit = [ + view?.objectCount, + view?.displayObjectCount, + view?.childCount, + view?.objects?.length, + view?.displayObjects?.length + ].find( + (value) => Number.isInteger(value) && value > 0 + ); + return explicit ?? cards.length + buttons.length + 1; +} + +function stableJournalContent(journal) { + return { + snapshot: stripPresentationState(journal.snapshot), + visibleText: collectVisibleText(journal.view).map( + normalizeWhitespace + ), + cards: collectCards(journal.view).map((card) => ({ + id: card.id, + content: stripPresentationState(card.source) + })) + }; +} + +function stripPresentationState(value) { + if (Array.isArray(value)) { + return value.map(stripPresentationState); + } + if (!value || typeof value !== 'object') { + return value; + } + return Object.fromEntries( + Object.entries(value) + .filter( + ([key]) => + !/bounds|^(x|y|width|height|visible|open|isOpen|panelVisible|interactive|hovered|focused|objectCount|displayObjectCount|childCount)$/i.test( + key + ) + ) + .map(([key, entry]) => [ + key, + stripPresentationState(entry) + ]) + ); +} + +function normalizeWhitespace(value) { + return String(value).replace(/\s+/g, ' ').trim(); +} + +function assertFiniteBounds(bounds, label) { + assert( + bounds && + Number.isFinite(bounds.x) && + Number.isFinite(bounds.y) && + Number.isFinite(bounds.width) && + Number.isFinite(bounds.height) && + bounds.width > 0 && + bounds.height > 0, + `Expected finite positive bounds for ${label}: ` + + JSON.stringify(bounds) + ); + return bounds; +} + +function assertBoundsContained(inner, outer, label) { + const tolerance = 1; + assert( + inner.x >= outer.x - tolerance && + inner.y >= outer.y - tolerance && + inner.x + inner.width <= + outer.x + outer.width + tolerance && + inner.y + inner.height <= + outer.y + outer.height + tolerance, + `${label} escaped its container: ` + + JSON.stringify({ inner, outer }) + ); +} + +function boundsCenterInside(inner, outer) { + const x = inner.x + inner.width / 2; + const y = inner.y + inner.height / 2; + return ( + x >= outer.x && + y >= outer.y && + x <= outer.x + outer.width && + y <= outer.y + outer.height + ); +} + +function boundsOverlap(left, right) { + const tolerance = 1; + return ( + left.x < right.x + right.width - tolerance && + left.x + left.width > right.x + tolerance && + left.y < right.y + right.height - tolerance && + left.y + left.height > right.y + tolerance + ); +} + +function assertPairwiseNonOverlapping(bounds, label) { + for (let leftIndex = 0; leftIndex < bounds.length; leftIndex += 1) { + for ( + let rightIndex = leftIndex + 1; + rightIndex < bounds.length; + rightIndex += 1 + ) { + assert( + !boundsOverlap(bounds[leftIndex], bounds[rightIndex]), + `${label} ${leftIndex + 1} and ${rightIndex + 1} ` + + `overlap: ${JSON.stringify({ + left: bounds[leftIndex], + right: bounds[rightIndex] + })}` + ); + } + } +} + +async function clickNamedSceneBounds( + page, + sceneKey, + bounds +) { + const point = await page.evaluate( + ({ key, requestedBounds }) => { + const scene = window.__HEROS_DEBUG__?.scene(key); + const canvasBounds = + document.querySelector('canvas')?.getBoundingClientRect(); + if (!scene || !canvasBounds) { + 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 + }; + }, + { key: sceneKey, requestedBounds: bounds } + ); + assert( + point && + Number.isFinite(point.x) && + Number.isFinite(point.y), + `Unable to map ${sceneKey} bounds ${JSON.stringify(bounds)}.` + ); + await page.mouse.click(point.x, point.y); +} + +async function readCampaignSaves(page) { + return page.evaluate(() => + Object.fromEntries( + Object.keys(window.localStorage) + .filter((key) => + key.startsWith('heros-web:campaign-state') + ) + .sort() + .map((key) => [ + key, + window.localStorage.getItem(key) + ]) + ) + ); +} + +async function afterTwoFrames(page) { + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => + requestAnimationFrame(resolve) + ); + }) + ); +} + +async function readRuntimeProbe(page, sceneKey) { + return page.evaluate((key) => { + const game = window.__HEROS_GAME__; + const scene = game?.scene.getScene(key); + const canvas = document.querySelector('canvas'); + const canvasBounds = canvas?.getBoundingClientRect(); + return { + viewport: { + width: window.innerWidth, + height: window.innerHeight, + dpr: window.devicePixelRatio, + zoom: window.visualViewport?.scale ?? 1 + }, + canvas: canvas + ? { + width: canvas.width, + height: canvas.height, + bounds: canvasBounds + ? { + x: canvasBounds.x, + y: canvasBounds.y, + width: canvasBounds.width, + height: canvasBounds.height + } + : null + } + : null, + renderer: { + requested: new URLSearchParams( + window.location.search + ).get('renderer'), + type: game?.renderer?.type ?? null, + name: game?.renderer?.constructor?.name ?? null + }, + scene: scene + ? { + width: scene.scale.width, + height: scene.scale.height + } + : null + }; + }, sceneKey); +} + +function assertRuntimeBaseline(runtime, requestedRenderer) { + assert.deepEqual(runtime.viewport, { + width: desktopBrowserViewport.width, + height: desktopBrowserViewport.height, + dpr: desktopBrowserDeviceScaleFactor, + zoom: 1 + }); + assert.deepEqual(runtime.canvas, { + width: desktopBrowserViewport.width, + height: desktopBrowserViewport.height, + bounds: { + x: 0, + y: 0, + width: desktopBrowserViewport.width, + height: desktopBrowserViewport.height + } + }); + assert.deepEqual(runtime.scene, { + width: desktopBrowserViewport.width, + height: desktopBrowserViewport.height + }); + assert.equal(runtime.renderer.requested, requestedRenderer); + assert.equal( + runtime.renderer.type, + requestedRenderer === 'canvas' ? 1 : 2, + `Expected ${requestedRenderer} renderer: ` + + JSON.stringify(runtime.renderer) + ); +} + +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 || '41808', + '--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); + } + + await stopServerProcess(child); + 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)); +} + +async function stopServerProcess(child) { + if (!child || child.exitCode !== null) { + return; + } + const exited = new Promise((resolve) => + child.once('exit', resolve) + ); + child.kill(); + await Promise.race([exited, delay(5000)]); + if (child.exitCode === null) { + child.kill('SIGKILL'); + await Promise.race([exited, delay(2000)]); + } +} diff --git a/scripts/verify-campaign-objective-journal.mjs b/scripts/verify-campaign-objective-journal.mjs new file mode 100644 index 0000000..a30e16e --- /dev/null +++ b/scripts/verify-campaign-objective-journal.mjs @@ -0,0 +1,619 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { createServer } from 'vite'; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); + +try { + const journalModule = await server.ssrLoadModule( + '/src/game/state/campaignObjectiveJournal.ts' + ); + const campaignModule = await server.ssrLoadModule( + '/src/game/state/campaignState.ts' + ); + const routingModule = await server.ssrLoadModule( + '/src/game/state/campaignRouting.ts' + ); + const { battleScenarios } = await server.ssrLoadModule( + '/src/game/data/battles.ts' + ); + const { getSortieFlow } = await server.ssrLoadModule( + '/src/game/data/campaignFlow.ts' + ); + const secondReliefModule = await server.ssrLoadModule( + '/src/game/data/secondBattleReliefExploration.ts' + ); + const thirdExplorationModule = await server.ssrLoadModule( + '/src/game/data/thirdCampExploration.ts' + ); + + const resolve = journalModule.resolveCampaignObjectiveJournal; + const source = await readFile( + new URL( + '../src/game/state/campaignObjectiveJournal.ts', + import.meta.url + ), + 'utf8' + ); + + verifyPureModuleContract(journalModule, source); + verifyInitialAndStoryStates(resolve, campaignModule); + verifyPrologueVillageProgress(resolve, campaignModule); + verifyPrologueCampProgress(resolve, campaignModule); + verifyAftermathIsolation( + resolve, + campaignModule, + battleScenarios, + getSortieFlow + ); + verifyCityStayIsolation( + resolve, + campaignModule, + battleScenarios + ); + verifyEarlyInterludes( + resolve, + campaignModule, + battleScenarios, + secondReliefModule, + thirdExplorationModule + ); + verifyAllBattleAndCampRoutesHideFutureDetails( + resolve, + campaignModule, + routingModule, + battleScenarios, + getSortieFlow + ); + + console.log( + 'Campaign objective journal verification passed (current/completed/clue triplet, known/open progress, short resume context, early exploration sequencing, and future battle/title/condition isolation).' + ); +} finally { + await server.close(); +} + +function verifyPureModuleContract(journalModule, source) { + assert.deepEqual(journalModule.campaignObjectiveJournalStatusLabels, { + current: '◆ 진행', + completed: '✓ 완료', + clue: '? 실마리' + }); + assert.doesNotMatch( + source, + /(?:from|import\s+type)\s+['"]phaser['"]/i, + 'The objective journal must remain Phaser-independent.' + ); + assert.doesNotMatch( + source, + /\.(?:victoryConditionLabel|defeatConditionLabel|openingObjectiveLines|objectives|units|leaderUnitId)\b/, + 'The journal must not read battle win conditions, future units, or hidden tactical data.' + ); + assert.doesNotMatch( + source, + /getSortieFlow\([^)]*\)\.(?:title|description|rewardHint)/, + 'Campaign flow prose must not be copied into the spoiler-safe journal.' + ); +} + +function verifyInitialAndStoryStates(resolve, campaignModule) { + const initial = campaignModule.createInitialCampaignState(); + const initialSnapshot = resolve(initial); + assertSnapshotShape(initialSnapshot); + assert.equal(initialSnapshot.contextKind, 'not-started'); + assert.equal(initialSnapshot.current.statusLabel, '◆ 진행'); + assert.equal(initialSnapshot.recentCompleted.statusLabel, '✓ 완료'); + assert.equal(initialSnapshot.nextClue.statusLabel, '? 실마리'); + assert.deepEqual( + pickProgress(initialSnapshot.progress), + { + completed: 0, + total: 1, + knownTotal: true + } + ); + + const prologue = freshState(campaignModule, { step: 'prologue' }); + const prologueSnapshot = resolve(prologue); + assert.equal(prologueSnapshot.contextKind, 'story'); + assert.match(prologueSnapshot.current.title, /서막/); + assert.equal(prologueSnapshot.resumeContext.lines.length, 3); + assert( + prologueSnapshot.resumeContext.lines.every( + (line) => line.length <= 110 + ), + 'Resume context lines must stay short enough for a compact recap card.' + ); +} + +function verifyPrologueVillageProgress(resolve, campaignModule) { + const ids = campaignModule.prologueVillageCampaignTutorialIds; + const village = freshState(campaignModule, { + step: 'prologue-town' + }); + + let snapshot = resolve(village); + assert.equal(snapshot.contextKind, 'village-exploration'); + assert.equal(snapshot.current.title, '격문 앞의 장비'); + assert.match(snapshot.nextClue.detail, /서쪽 모병 격문/); + assert.deepEqual(pickProgress(snapshot.progress), { + completed: 0, + total: 4, + knownTotal: true + }); + + village.completedTutorialIds.push(ids.meetZhangFei); + snapshot = resolve(village); + assert.equal(snapshot.current.title, '주점의 관우'); + assert.equal(snapshot.recentCompleted.title, '격문 앞의 장비'); + assert.equal(snapshot.progress.completed, 1); + + village.completedTutorialIds.push( + ids.meetGuanYu, + ids.registerVolunteers + ); + snapshot = resolve(village); + assert.equal(snapshot.current.title, '도원결의'); + assert.match(snapshot.nextClue.detail, /복숭아 동산/); + assert.equal(snapshot.progress.completed, 3); + + village.completedTutorialIds.push(ids.complete); + snapshot = resolve(village); + assert.match(snapshot.current.title, /결의 뒤 이야기/); + assert.equal(snapshot.recentCompleted.title, '도원결의'); + assert.equal(snapshot.progress.completed, 4); +} + +function verifyPrologueCampProgress(resolve, campaignModule) { + const ids = campaignModule.prologueMilitiaCampCampaignTutorialIds; + const camp = freshState(campaignModule, { + step: 'prologue-camp' + }); + + camp.completedTutorialIds.push(ids.inspectArms); + let snapshot = resolve(camp); + assert.equal(snapshot.contextKind, 'militia-camp-exploration'); + assert.equal(snapshot.current.title, '관우의 정찰'); + assert.equal( + snapshot.recentCompleted.title, + '병기·보급 점검', + 'The recent completion must follow persisted insertion order, not fixed objective order.' + ); + assert.equal(snapshot.progress.completed, 1); + + camp.completedTutorialIds.push( + ids.readyVanguard, + ids.reviewScoutReport + ); + snapshot = resolve(camp); + assert.equal(snapshot.current.title, '첫 출전 명령'); + assert.match(snapshot.nextClue.detail, /북쪽 지휘 막사/); + assert.equal(snapshot.progress.completed, 3); + + camp.completedTutorialIds.push(ids.complete); + snapshot = resolve(camp); + assert.match(snapshot.current.title, /출정 이야기/); + assert.equal(snapshot.recentCompleted.title, '첫 출전 군령'); + assert.equal(snapshot.progress.completed, 4); + assert.doesNotMatch( + stringify(snapshot), + /한석 선봉 격퇴/, + 'The first battle victory condition must not leak before the battlefield opens.' + ); +} + +function verifyAftermathIsolation( + resolve, + campaignModule, + battleScenarios, + getSortieFlow +) { + const battleId = 'first-battle-zhuo-commandery'; + const state = campaignWithSettlement( + campaignModule, + battleScenarios, + battleId, + 'first-camp' + ); + state.pendingAftermathBattleId = battleId; + + const snapshot = resolve(state); + const flow = getSortieFlow(state.latestBattleId, state.step); + assert.equal(snapshot.contextKind, 'aftermath'); + assert.match(snapshot.current.title, /승리 후 이야기/); + assert.equal(snapshot.recentCompleted.statusLabel, '✓ 완료'); + assertHiddenFlowDetailsAbsent(snapshot, flow, battleScenarios); + assert.doesNotMatch( + stringify(snapshot), + new RegExp(escapeRegExp(battleScenarios[battleId].victoryConditionLabel)) + ); +} + +function verifyCityStayIsolation( + resolve, + campaignModule, + battleScenarios +) { + const sourceBattleId = 'seventh-battle-xuzhou-rescue'; + const state = campaignWithSettlement( + campaignModule, + battleScenarios, + sourceBattleId, + 'seventh-camp' + ); + state.activeCityStayId = 'xuzhou'; + + let snapshot = resolve(state); + assert.equal(snapshot.contextKind, 'city-stay'); + assert.match(snapshot.current.title, /서주.*현지 정보/); + assert.deepEqual(pickProgress(snapshot.progress), { + completed: 0, + total: 2, + knownTotal: true + }); + assert.doesNotMatch( + stringify(snapshot), + new RegExp( + escapeRegExp( + battleScenarios['eighth-battle-xiaopei-supply-road'].title + ) + ), + 'A city stay must not expose the title of its future battle.' + ); + assert.doesNotMatch( + stringify(snapshot), + /소패 보급로 장부 확인|여포군 기병/, + 'A city stay journal must not copy future battle briefing prose.' + ); + + state.completedCampVisits.push('city-xuzhou-xiaopei-ledger'); + snapshot = resolve(state); + assert.match(snapshot.current.title, /동료와 대화/); + assert.equal(snapshot.recentCompleted.title, '현지 정보 확보'); + assert.equal(snapshot.progress.completed, 1); + + state.completedCampDialogues.push( + 'city-xuzhou-liu-mi-supply-trust' + ); + snapshot = resolve(state); + assert.match(snapshot.current.title, /군영으로 돌아가/); + assert.equal(snapshot.progress.completed, 2); + assert.match(snapshot.progress.label, /시장은 선택/); +} + +function verifyEarlyInterludes( + resolve, + campaignModule, + battleScenarios, + secondReliefModule, + thirdExplorationModule +) { + const firstCamp = campaignWithSettlement( + campaignModule, + battleScenarios, + 'first-battle-zhuo-commandery', + 'first-camp' + ); + let snapshot = resolve(firstCamp); + assert.equal(snapshot.contextKind, 'interlude-exploration'); + assert.match(snapshot.current.title, /북문 정찰/); + assert.doesNotMatch( + stringify(snapshot), + new RegExp( + escapeRegExp( + battleScenarios['second-battle-yellow-turban-pursuit'].title + ) + ) + ); + + firstCamp.completedCampVisits.push('first-pursuit-scout-tent'); + firstCamp.campVisitChoiceIds['first-pursuit-scout-tent'] = + 'trace-river-ambush'; + snapshot = resolve(firstCamp); + assert.equal(snapshot.progress.completed, 1); + assert.equal( + snapshot.recentCompleted.title, + '강가 매복 흔적을 쫓는다' + ); + + const secondCamp = campaignWithSettlement( + campaignModule, + battleScenarios, + secondReliefModule.secondBattleReliefSourceBattleId, + 'second-camp' + ); + snapshot = resolve(secondCamp); + assert.match(snapshot.current.title, /북쪽 마을/); + assert.equal(snapshot.progress.completed, 0); + + const firstObjectiveId = 'reassure-northern-village'; + secondCamp.completedCampVisits.push( + secondReliefModule.secondBattleReliefObjectiveProgressId( + firstObjectiveId + ) + ); + snapshot = resolve(secondCamp); + assert.match(snapshot.current.title, /나루 사공/); + assert.equal(snapshot.progress.completed, 1); + assert.match(snapshot.recentCompleted.title, /북쪽 마을/); + + [ + 'question-ferry-witness', + 'inspect-messenger-trail' + ].forEach((objectiveId) => { + secondCamp.completedCampVisits.push( + secondReliefModule.secondBattleReliefObjectiveProgressId( + objectiveId + ) + ); + }); + snapshot = resolve(secondCamp); + assert.match(snapshot.current.title, /다음 출정의 우선 방침/); + assert.doesNotMatch( + stringify(snapshot), + new RegExp( + escapeRegExp( + battleScenarios[ + secondReliefModule.secondBattleReliefTargetBattleId + ].title + ) + ), + 'The final relief decision must use generic current-action wording instead of the future battle title.' + ); + + secondCamp.completedCampVisits.push( + secondReliefModule.secondBattleReliefVisitId + ); + secondCamp.campVisitChoiceIds[ + secondReliefModule.secondBattleReliefVisitId + ] = 'restore-northern-village'; + snapshot = resolve(secondCamp); + assert.equal(snapshot.progress.completed, 4); + assert.equal( + snapshot.recentCompleted.title, + '북쪽 마을 보급선을 먼저 세운다' + ); + + const thirdCamp = campaignWithSettlement( + campaignModule, + battleScenarios, + thirdExplorationModule.thirdCampExplorationSourceBattleId, + 'third-camp', + { + objectives: [ + { id: 'fort', achieved: true, status: 'done' }, + { id: 'quick', achieved: true, status: 'done' } + ], + turnNumber: 22 + } + ); + thirdCamp.firstBattleReport = { + ...minimalFirstBattleReport( + thirdCamp.battleHistory[ + thirdExplorationModule.thirdCampExplorationSourceBattleId + ] + ) + }; + snapshot = resolve(thirdCamp); + assert.equal(snapshot.contextKind, 'interlude-exploration'); + assert.match(snapshot.current.detail, /모두 선택/); + assert.equal(snapshot.progress.total, 3); + assert.doesNotMatch( + stringify(snapshot), + new RegExp( + escapeRegExp( + battleScenarios[ + thirdExplorationModule.thirdCampExplorationTargetBattleId + ].title + ) + ) + ); + + thirdCamp.completedCampVisits.push( + thirdExplorationModule.thirdCampExplorationActivityProgressId( + 'equipment' + ) + ); + snapshot = resolve(thirdCamp); + assert.equal(snapshot.progress.completed, 1); + assert.equal(snapshot.recentCompleted.title, '장비 정비'); +} + +function verifyAllBattleAndCampRoutesHideFutureDetails( + resolve, + campaignModule, + routingModule, + battleScenarios, + getSortieFlow +) { + const routes = routingModule.campaignBattleRouteEntries(); + routes.forEach(({ step, battleId }, index) => { + const battleState = freshState(campaignModule, { step }); + const battleSnapshot = resolve(battleState); + assertSnapshotShape(battleSnapshot); + assert.equal( + battleSnapshot.current.title, + `${battleScenarios[battleId].title} 진행` + ); + assert.doesNotMatch( + stringify(battleSnapshot), + new RegExp( + escapeRegExp(battleScenarios[battleId].victoryConditionLabel) + ), + `Current battle ${battleId} must not expose its victory condition through the campaign journal.` + ); + + const nextRoute = routes[index + 1]; + if (nextRoute) { + assert.doesNotMatch( + stringify(battleSnapshot), + new RegExp( + escapeRegExp(battleScenarios[nextRoute.battleId].title) + ), + `Battle step ${step} must not expose future title ${nextRoute.battleId}.` + ); + } + + const campStep = step.replace(/-battle$/, '-camp'); + const campState = campaignWithSettlement( + campaignModule, + battleScenarios, + battleId, + campStep + ); + const campSnapshot = resolve(campState); + assertSnapshotShape(campSnapshot); + const flow = getSortieFlow( + campState.latestBattleId, + campState.step + ); + assertHiddenFlowDetailsAbsent( + campSnapshot, + flow, + battleScenarios + ); + }); +} + +function freshState(campaignModule, overrides = {}) { + return Object.assign( + campaignModule.createInitialCampaignState(), + overrides + ); +} + +function campaignWithSettlement( + campaignModule, + battleScenarios, + battleId, + step, + overrides = {} +) { + const state = freshState(campaignModule, { step }); + const settlement = minimalSettlement( + battleScenarios[battleId], + overrides + ); + state.latestBattleId = battleId; + state.battleHistory[battleId] = settlement; + return state; +} + +function minimalSettlement(scenario, overrides = {}) { + return { + battleId: scenario.id, + battleTitle: scenario.title, + outcome: 'victory', + turnNumber: 12, + rewardGold: 0, + itemRewards: [], + objectives: [], + units: [], + bonds: [], + completedAt: '2026-07-28T00:00:00.000Z', + ...overrides + }; +} + +function minimalFirstBattleReport(settlement) { + return { + battleId: settlement.battleId, + battleTitle: settlement.battleTitle, + outcome: settlement.outcome, + turnNumber: settlement.turnNumber, + rewardGold: settlement.rewardGold, + defeatedEnemies: 1, + totalEnemies: 1, + objectives: structuredClone(settlement.objectives), + units: [], + bonds: [], + itemRewards: [], + completedCampDialogues: [], + completedCampVisits: [], + createdAt: settlement.completedAt + }; +} + +function assertSnapshotShape(snapshot) { + assert(snapshot); + assert.equal(typeof snapshot.contextKind, 'string'); + assertEntry(snapshot.current, '◆ 진행'); + assertEntry(snapshot.recentCompleted, '✓ 완료'); + assertEntry(snapshot.nextClue, '? 실마리'); + assert.equal(typeof snapshot.progress.completed, 'number'); + assert.equal(typeof snapshot.progress.knownTotal, 'boolean'); + assert.equal(typeof snapshot.progress.label, 'string'); + if (snapshot.progress.knownTotal) { + assert.equal(typeof snapshot.progress.total, 'number'); + assert(snapshot.progress.completed <= snapshot.progress.total); + } else { + assert.equal(snapshot.progress.total, undefined); + } + assert.equal(typeof snapshot.resumeContext.title, 'string'); + assert.equal(typeof snapshot.resumeContext.summary, 'string'); + assert.equal(snapshot.resumeContext.lines.length, 3); +} + +function assertEntry(entry, expectedLabel) { + assert(entry); + assert.equal(entry.statusLabel, expectedLabel); + assert.equal(typeof entry.id, 'string'); + assert(entry.id.length > 0); + assert.equal(typeof entry.title, 'string'); + assert(entry.title.length > 0); + assert.equal(typeof entry.detail, 'string'); + assert(entry.detail.length > 0); +} + +function assertHiddenFlowDetailsAbsent( + snapshot, + flow, + battleScenarios +) { + const serialized = stringify(snapshot); + if (!flow?.nextBattleId) { + return; + } + const future = battleScenarios[flow.nextBattleId]; + assert(future); + assert.doesNotMatch( + serialized, + new RegExp(escapeRegExp(future.title)), + `Future battle title ${future.title} must remain hidden.` + ); + assert.doesNotMatch( + serialized, + new RegExp(escapeRegExp(future.victoryConditionLabel)), + `Future victory condition ${future.victoryConditionLabel} must remain hidden.` + ); + assert.equal( + serialized.includes(flow.description), + false, + 'Future campaign-flow description must not be copied into the journal.' + ); + assert.equal( + serialized.includes(flow.rewardHint), + false, + 'Future reward/recruit hints must not be copied into the journal.' + ); +} + +function pickProgress(progress) { + return { + completed: progress.completed, + total: progress.total, + knownTotal: progress.knownTotal + }; +} + +function stringify(value) { + return JSON.stringify(value); +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 714cf9c..827d2e0 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -1,4 +1,4 @@ -import { spawn } from 'node:child_process'; +import { spawn } from 'node:child_process'; import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import { basename, dirname, resolve } from 'node:path'; import { chromium } from 'playwright'; @@ -84,12 +84,9 @@ const legacyUiClickPoints = new Set([ '962,310', '962,314', '738,642', - '1120,38', - '966,38', '724,38', '1068,646', '974,436', - '1160,38', '576,38', '180,315', '798,38', @@ -157,25 +154,35 @@ async function moveLegacyUi(page, x, y, options) { } async function clickBattleDeploymentStart(page) { - const point = await page.evaluate(() => { - const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); - const layout = scene?.layout; - const canvas = document.querySelector('canvas'); - const bounds = canvas?.getBoundingClientRect(); - if (!scene || !layout || !canvas || !bounds) { - return null; - } - const logicalX = layout.panelX + 24 + (layout.panelWidth - 48) / 2; - const logicalY = layout.panelY + layout.panelHeight - 76 + 17; - return { - x: bounds.left + logicalX * bounds.width / scene.scale.width, - y: bounds.top + logicalY * bounds.height / scene.scale.height - }; - }); - if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { - throw new Error(`Expected a runtime deployment start point: ${JSON.stringify(point)}`); + await page.waitForFunction(() => { + const battle = window.__HEROS_DEBUG__?.battle(); + return ( + battle?.phase === 'idle' || + Boolean( + battle?.battleHud?.deploymentPanel?.startButtonBounds + ) + ); + }, undefined, { timeout: 30000 }); + const deploymentPanel = await page.evaluate( + () => + window.__HEROS_DEBUG__?.battle()?.battleHud + ?.deploymentPanel ?? null + ); + const currentPhase = await page.evaluate( + () => window.__HEROS_DEBUG__?.battle()?.phase ?? null + ); + if (currentPhase === 'idle') { + return; } - await page.mouse.click(point.x, point.y); + const startButtonBounds = deploymentPanel?.startButtonBounds; + if (!startButtonBounds) { + throw new Error( + `Expected runtime deployment start-button bounds: ${JSON.stringify( + deploymentPanel + )}` + ); + } + await clickBattleBounds(page, startButtonBounds); } async function clickSceneBounds(page, sceneKey, bounds, options) { @@ -201,6 +208,61 @@ async function clickBattleBounds(page, bounds, options) { await clickSceneBounds(page, 'BattleScene', bounds, options); } +async function clickCampTab(page, tabId) { + const tab = await page.evaluate((requestedTabId) => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + return ( + camp?.campTabs?.find( + (candidate) => candidate.id === requestedTabId + ) ?? null + ); + }, tabId); + if (!tab?.interactive || !tab.bounds) { + throw new Error( + `Expected an interactive ${tabId} camp tab: ` + + JSON.stringify(tab) + ); + } + await clickSceneBounds(page, 'CampScene', tab.bounds); +} + +async function openCampProgressJournal(page) { + const alreadyOpen = await page.evaluate(() => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + return ( + camp?.activeTab === 'progress' && + camp?.campaignObjectiveJournal?.view?.visible === true + ); + }); + if (!alreadyOpen) { + await clickCampTab(page, 'progress'); + } + await page.waitForFunction(() => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + return ( + camp?.activeTab === 'progress' && + camp?.campaignObjectiveJournal?.view?.visible === true + ); + }); +} + +async function clickCampSortieCommand(page) { + const sortieCommand = await page.evaluate( + () => window.__HEROS_DEBUG__?.camp?.()?.sortieCommand ?? null + ); + if (!sortieCommand?.interactive || !sortieCommand.bounds) { + throw new Error( + 'Expected an interactive camp sortie command: ' + + JSON.stringify(sortieCommand) + ); + } + await clickSceneBounds( + page, + 'CampScene', + sortieCommand.bounds + ); +} + async function clickBattleResultContinue(page) { await page.waitForFunction(() => { const battle = window.__HEROS_DEBUG__?.battle(); @@ -1557,7 +1619,7 @@ try { throw new Error(`Expected campaign save to persist second battle victory: ${JSON.stringify(campaignSaveAfterSecondBattle)}`); } - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(160); const secondCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!secondCampSortieState?.sortieVisible) { @@ -1624,7 +1686,7 @@ try { throw new Error(`Expected campaign save to persist third battle victory: ${JSON.stringify(campaignSaveAfterThirdBattle)}`); } - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(160); const thirdCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!thirdCampSortieState?.sortieVisible) { @@ -1695,7 +1757,7 @@ try { } if (shouldRunFlowSegment('anti-dong')) { - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(160); const fourthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!fourthCampSortieState?.sortieVisible) { @@ -1772,7 +1834,7 @@ try { throw new Error(`Expected fifth camp to expose Gongsun Zan/Sishui dialogue set: ${JSON.stringify(fifthCampState)}`); } - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(160); const fifthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!fifthCampSortieState?.sortieVisible) { @@ -1853,7 +1915,7 @@ try { } if (shouldRunFlowSegment('xuzhou')) { - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(160); const sixthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!sixthCampSortieState?.sortieVisible) { @@ -1958,7 +2020,7 @@ try { const seventhCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( seventhCampState?.campBattleId !== 'seventh-battle-xuzhou-rescue' || - seventhCampState.campTitle !== '서주 인수 준비 군영' || + seventhCampState.campTitle !== '서주 성내 체류' || seventhCampState.availableDialogueIds?.length !== 5 || !seventhCampState.availableDialogueIds.every((id) => id.endsWith('xuzhou')) || !seventhCampState.campaign?.roster?.some((unit) => unit.id === 'guan-yu') || @@ -1968,7 +2030,17 @@ try { throw new Error(`Expected seventh camp to expose Xu Province dialogue set, preserved roster, and new recruits: ${JSON.stringify(seventhCampState)}`); } - await page.mouse.click(1120, 38); + if ( + !seventhCampState.sortieCommand?.interactive || + !seventhCampState.sortieCommand?.bounds + ) { + throw new Error(`Expected seventh camp to expose an interactive sortie command: ${JSON.stringify(seventhCampState.sortieCommand)}`); + } + await clickSceneBounds( + page, + 'CampScene', + seventhCampState.sortieCommand.bounds + ); await page.waitForTimeout(160); const seventhCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!seventhCampSortieState?.sortieVisible) { @@ -2047,7 +2119,7 @@ try { throw new Error(`Expected eighth camp to expose Xiaopei dialogue set and preserve recruits: ${JSON.stringify(eighthCampState)}`); } - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(160); const eighthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!eighthCampSortieState?.sortieVisible) { @@ -2130,7 +2202,7 @@ try { } if (shouldRunFlowSegment('wandering')) { - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(160); const ninthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!ninthCampSortieState?.sortieVisible) { @@ -2210,7 +2282,7 @@ try { throw new Error(`Expected tenth camp to expose Xuzhou loss dialogue set and preserve full roster: ${JSON.stringify(tenthCampState)}`); } - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(160); const tenthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!tenthCampSortieState?.sortieVisible) { @@ -2282,7 +2354,7 @@ try { throw new Error(`Expected eleventh camp to expose Cao Cao refuge dialogue set and preserve full roster: ${JSON.stringify(eleventhCampState)}`); } - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(160); const eleventhCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!eleventhCampSortieState?.sortieVisible || !eleventhCampSortieState.sortiePlan?.objectiveLine?.includes('하비 외곽전')) { @@ -2355,7 +2427,7 @@ try { throw new Error(`Expected twelfth camp to expose Xiapi siege dialogue set and preserve full roster: ${JSON.stringify(twelfthCampState)}`); } - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(160); const twelfthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -2438,7 +2510,7 @@ try { throw new Error(`Expected thirteenth camp to expose Lu Bu fall dialogue set, recruit Sun Qian, and preserve full roster: ${JSON.stringify(thirteenthCampState)}`); } - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(160); const thirteenthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -2516,7 +2588,7 @@ try { throw new Error(`Expected fourteenth camp to expose Cao break dialogue set and preserve Sun Qian in roster: ${JSON.stringify(fourteenthCampState)}`); } - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(160); const fourteenthCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -2717,7 +2789,7 @@ try { } await page.screenshot({ path: 'dist/verification-liu-biao-visits.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(160); const sixteenthCampWolongSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -2770,7 +2842,9 @@ try { if ( seventeenthBattleState.camera?.mapWidth !== 34 || seventeenthBattleState.camera?.mapHeight !== 26 || - seventeenthBattleState.victoryConditionLabel !== '채순 격파' || + !seventeenthBattleState.victoryConditionLabel?.includes( + '채순 격파' + ) || seventeenthEnemies.length < 15 || !seventeenthEnemyBehaviors.has('aggressive') || !seventeenthEnemyBehaviors.has('guard') || @@ -2794,7 +2868,7 @@ try { const seventeenthCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( seventeenthCampState?.campBattleId !== 'seventeenth-battle-wolong-visit-road' || - seventeenthCampState.campTitle !== '와룡 출려 후 군영' || + seventeenthCampState.campTitle !== '신야 성내 체류' || seventeenthCampState.availableDialogueIds?.length !== 3 || !seventeenthCampState.availableDialogueIds.every((id) => id.endsWith('wolong-visit')) || seventeenthCampState.availableVisitIds?.length !== 2 || @@ -2806,7 +2880,7 @@ try { } await page.screenshot({ path: 'dist/verification-zhuge-recruit-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const progressTabState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -2821,7 +2895,7 @@ try { } await page.screenshot({ path: 'dist/verification-campaign-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(160); const seventeenthCampBowangSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -2952,7 +3026,7 @@ try { } await page.screenshot({ path: 'dist/verification-bowang-roster.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postBowangProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -2971,7 +3045,7 @@ try { } if (shouldRunFlowSegment('red-cliffs')) { - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const changbanSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -3089,7 +3163,7 @@ try { } await page.screenshot({ path: 'dist/verification-changban-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postChangbanProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -3104,7 +3178,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-changban-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const jiangdongSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -3222,7 +3296,7 @@ try { } await page.screenshot({ path: 'dist/verification-jiangdong-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postJiangdongProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -3237,7 +3311,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-jiangdong-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const redCliffsSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -3357,7 +3431,7 @@ try { } await page.screenshot({ path: 'dist/verification-redcliffs-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postRedCliffsProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -3372,7 +3446,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-redcliffs-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const fireAttackSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -3453,7 +3527,7 @@ try { if ( twentySecondBattleState.camera?.mapWidth !== 44 || twentySecondBattleState.camera?.mapHeight !== 30 || - twentySecondBattleState.victoryConditionLabel !== '조조 본선 퇴각' || + !twentySecondBattleState.victoryConditionLabel?.includes('조조 본선 퇴각') || twentySecondEnemies.length < 18 || !twentySecondEnemyBehaviors.has('aggressive') || !twentySecondEnemyBehaviors.has('guard') || @@ -3492,7 +3566,7 @@ try { } await page.screenshot({ path: 'dist/verification-redcliffs-fire-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postFireAttackProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -3511,7 +3585,7 @@ try { } if (shouldRunFlowSegment('jing-yi')) { - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const jingzhouSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -3634,7 +3708,7 @@ try { } await page.screenshot({ path: 'dist/verification-jingzhou-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postJingzhouProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -3649,7 +3723,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-jingzhou-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const guiyangSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -3771,7 +3845,7 @@ try { } await page.screenshot({ path: 'dist/verification-guiyang-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postGuiyangProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -3786,7 +3860,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-guiyang-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const wulingSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -3911,7 +3985,7 @@ try { } await page.screenshot({ path: 'dist/verification-wuling-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postWulingProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -3926,7 +4000,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-wuling-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const changshaSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -4059,7 +4133,7 @@ try { } await page.screenshot({ path: 'dist/verification-changsha-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postChangshaProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -4074,7 +4148,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-changsha-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const yizhouSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -4199,7 +4273,7 @@ try { } await page.screenshot({ path: 'dist/verification-yizhou-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postYizhouProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -4214,7 +4288,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-yizhou-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const fuPassSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -4343,7 +4417,7 @@ try { } await page.screenshot({ path: 'dist/verification-fupass-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postFuPassProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -4358,7 +4432,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-fupass-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const luoSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -4491,7 +4565,7 @@ try { } await page.screenshot({ path: 'dist/verification-luo-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postLuoProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -4506,7 +4580,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-luo-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const luofengSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -4632,7 +4706,7 @@ try { } await page.screenshot({ path: 'dist/verification-luofeng-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postLuofengProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -4647,7 +4721,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-luofeng-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const luoMainSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -4784,7 +4858,7 @@ try { } await page.screenshot({ path: 'dist/verification-luo-main-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postLuoMainProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -4799,7 +4873,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-luo-main-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const mianzhuSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -4932,7 +5006,7 @@ try { } await page.screenshot({ path: 'dist/verification-mianzhu-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postMianzhuProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -4947,7 +5021,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-mianzhu-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const chengduSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -5040,7 +5114,7 @@ try { if ( thirtyThirdBattleState.camera?.mapWidth !== 66 || thirtyThirdBattleState.camera?.mapHeight !== 48 || - thirtyThirdBattleState.victoryConditionLabel !== '황권 방어선 격파' || + !thirtyThirdBattleState.victoryConditionLabel?.includes('황권 방어선 격파') || thirtyThirdEnemies.length < 26 || !thirtyThirdEnemyBehaviors.has('aggressive') || !thirtyThirdEnemyBehaviors.has('guard') || @@ -5069,7 +5143,7 @@ try { const thirtyThirdCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( thirtyThirdCampState?.campBattleId !== 'thirty-third-battle-chengdu-surrender' || - thirtyThirdCampState.campTitle !== '성도 항복 후 군영' || + thirtyThirdCampState.campTitle !== '성도 성내 체류' || thirtyThirdCampState.availableDialogueIds?.length !== 3 || !thirtyThirdCampState.availableDialogueIds.every((id) => id.endsWith('chengdu')) || thirtyThirdCampState.availableVisitIds?.length !== 2 || @@ -5081,7 +5155,7 @@ try { } await page.screenshot({ path: 'dist/verification-chengdu-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postChengduProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -5096,7 +5170,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-chengdu-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const jiamengSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -5235,7 +5309,7 @@ try { } await page.screenshot({ path: 'dist/verification-jiameng-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postJiamengProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -5254,7 +5328,7 @@ try { } if (shouldRunFlowSegment('hanzhong-shuhan')) { - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const yangpingSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -5390,7 +5464,7 @@ try { } await page.screenshot({ path: 'dist/verification-yangping-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postYangpingProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -5405,7 +5479,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-yangping-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const dingjunSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -5547,7 +5621,7 @@ try { } await page.screenshot({ path: 'dist/verification-dingjun-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postDingjunProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -5562,7 +5636,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-dingjun-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const hanzhongSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -5707,7 +5781,7 @@ try { } await page.screenshot({ path: 'dist/verification-hanzhong-decisive-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postHanzhongProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -5722,7 +5796,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-hanzhong-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const hanzhongKingSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -5762,7 +5836,7 @@ try { } await page.screenshot({ path: 'dist/verification-hanzhong-king-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postKingCouncilProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -5776,7 +5850,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-king-council-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const shuHanFoundationSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -5816,7 +5890,7 @@ try { } await page.screenshot({ path: 'dist/verification-shu-han-foundation-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postFoundationProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -5834,7 +5908,7 @@ try { } if (shouldRunFlowSegment('jingzhou-crisis')) { - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const jingDefenseSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -5966,7 +6040,7 @@ try { } await page.screenshot({ path: 'dist/verification-jing-defense-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postJingDefenseProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -5980,7 +6054,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-jing-defense-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const fanCastleSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -6114,7 +6188,7 @@ try { } await page.screenshot({ path: 'dist/verification-fan-castle-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postFanCastleProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -6128,7 +6202,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-fan-castle-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const hanRiverSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -6263,7 +6337,7 @@ try { } await page.screenshot({ path: 'dist/verification-han-river-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postHanRiverProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -6277,7 +6351,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-han-river-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const fanSiegeSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -6443,7 +6517,7 @@ try { } await page.screenshot({ path: 'dist/verification-fan-siege-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postFanSiegeProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -6457,7 +6531,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-fan-siege-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const jingRearSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -6600,7 +6674,7 @@ try { } await page.screenshot({ path: 'dist/verification-jing-rear-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postJingRearProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -6614,7 +6688,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-jing-rear-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const gonganSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -6750,7 +6824,7 @@ try { } await page.screenshot({ path: 'dist/verification-gongan-collapse-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postGonganProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -6764,7 +6838,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-gongan-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const maichengSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -6903,7 +6977,7 @@ try { } await page.screenshot({ path: 'dist/verification-maicheng-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postMaichengProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -6921,7 +6995,7 @@ try { } if (shouldRunFlowSegment('yiling-baidi')) { - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const yilingSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -7059,7 +7133,7 @@ try { } await page.screenshot({ path: 'dist/verification-yiling-vanguard-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postYilingProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -7073,13 +7147,13 @@ try { } await page.screenshot({ path: 'dist/verification-post-yiling-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const yilingFireSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( !yilingFireSortieState?.sortieVisible || yilingFireSortieState.campaign?.step !== 'forty-fifth-camp' || - yilingFireSortieState.sortiePlan?.objectiveLine !== '이릉 화공전 · 오군 화공망 돌파' || + !yilingFireSortieState.sortiePlan?.objectiveLine?.includes('화공망 돌파') || yilingFireSortieState.sortiePlan?.maxCount !== 7 || yilingFireSortieState.sortiePlan?.recommendedTotal !== 7 || !yilingFireSortieState.sortieRoster?.some((unit) => unit.id === 'liu-bei' && unit.selected && unit.required) || @@ -7169,7 +7243,7 @@ try { if ( fortySixthBattleState.camera?.mapWidth !== 92 || fortySixthBattleState.camera?.mapHeight !== 74 || - fortySixthBattleState.victoryConditionLabel !== '오군 화공망 돌파' || + !fortySixthBattleState.victoryConditionLabel?.includes('화공망 돌파') || fortySixthEnemies.length < 38 || !fortySixthEnemyBehaviors.has('aggressive') || !fortySixthEnemyBehaviors.has('guard') || @@ -7212,7 +7286,7 @@ try { } await page.screenshot({ path: 'dist/verification-yiling-fire-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postYilingFireProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -7226,7 +7300,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-yiling-fire-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const baidiEntrustmentSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -7271,7 +7345,7 @@ try { } await page.screenshot({ path: 'dist/verification-baidi-entrustment-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postBaidiProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -7289,7 +7363,7 @@ try { } if (shouldRunFlowSegment('nanzhong')) { - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const nanzhongSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -7431,7 +7505,7 @@ try { } await page.screenshot({ path: 'dist/verification-nanzhong-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postNanzhongProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -7445,7 +7519,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-nanzhong-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const mengHuoSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -7588,7 +7662,7 @@ try { } await page.screenshot({ path: 'dist/verification-meng-huo-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postMengHuoProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -7602,7 +7676,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-meng-huo-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const secondCaptureSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -7745,7 +7819,7 @@ try { } await page.screenshot({ path: 'dist/verification-second-capture-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postSecondCaptureProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -7759,7 +7833,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-second-capture-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const thirdCaptureSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -7905,7 +7979,7 @@ try { } await page.screenshot({ path: 'dist/verification-third-capture-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postThirdCaptureProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -7919,7 +7993,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-third-capture-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const fourthCaptureSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -8066,7 +8140,7 @@ try { } await page.screenshot({ path: 'dist/verification-fourth-capture-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postFourthCaptureProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -8080,7 +8154,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-fourth-capture-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const fifthCaptureSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -8227,7 +8301,7 @@ try { } await page.screenshot({ path: 'dist/verification-fifth-capture-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postFifthCaptureProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -8241,7 +8315,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-fifth-capture-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const sixthCaptureSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -8387,7 +8461,7 @@ try { } await page.screenshot({ path: 'dist/verification-sixth-capture-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postSixthCaptureProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -8401,7 +8475,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-sixth-capture-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const finalCaptureSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -8548,7 +8622,7 @@ try { await page.screenshot({ path: 'dist/verification-final-capture-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postFinalCaptureProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -8563,7 +8637,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-final-capture-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const northernPrepSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -8615,7 +8689,7 @@ try { } await page.screenshot({ path: 'dist/verification-northern-prep-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postNorthernPrepProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -8634,7 +8708,7 @@ try { } if (shouldRunFlowSegment('northern')) { - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const firstNorthernSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -8773,7 +8847,7 @@ try { } await page.screenshot({ path: 'dist/verification-fifty-fifth-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postFiftyFifthProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -8788,7 +8862,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-fifty-fifth-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const secondNorthernSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -8918,7 +8992,7 @@ try { } await page.screenshot({ path: 'dist/verification-fifty-sixth-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postFiftySixthProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -8933,7 +9007,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-fifty-sixth-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const jietingSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -9068,7 +9142,7 @@ try { } await page.screenshot({ path: 'dist/verification-fifty-seventh-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postFiftySeventhProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -9083,7 +9157,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-fifty-seventh-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const qishanRetreatSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -9214,7 +9288,7 @@ try { } await page.screenshot({ path: 'dist/verification-fifty-eighth-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postFiftyEighthProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -9229,7 +9303,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-fifty-eighth-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const chencangSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -9360,7 +9434,7 @@ try { } await page.screenshot({ path: 'dist/verification-fifty-ninth-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postFiftyNinthProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -9375,7 +9449,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-fifty-ninth-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const wuduSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -9512,7 +9586,7 @@ try { } await page.screenshot({ path: 'dist/verification-sixtieth-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postSixtiethProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -9527,7 +9601,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-sixtieth-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const hanzhongRainSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -9666,7 +9740,7 @@ try { } await page.screenshot({ path: 'dist/verification-sixty-first-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postSixtyFirstProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -9681,7 +9755,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-sixty-first-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const qishanRenewedSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -9823,7 +9897,7 @@ try { } await page.screenshot({ path: 'dist/verification-sixty-second-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postSixtySecondProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -9838,7 +9912,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-sixty-second-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const luchengPursuitSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -10003,7 +10077,7 @@ try { } await page.screenshot({ path: 'dist/verification-sixty-third-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postSixtyThirdProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -10018,7 +10092,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-sixty-third-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const weishuiCampsSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -10188,7 +10262,7 @@ try { } await page.screenshot({ path: 'dist/verification-sixty-fourth-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postSixtyFourthProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -10203,7 +10277,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-sixty-fourth-progress.png', fullPage: true }); - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); await page.waitForTimeout(180); const northbankSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -10388,7 +10462,7 @@ try { } await page.screenshot({ path: 'dist/verification-sixty-fifth-camp.png', fullPage: true }); - await page.mouse.click(966, 38); + await openCampProgressJournal(page); await page.waitForTimeout(180); const postSixtyFifthProgressState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -10402,7 +10476,7 @@ try { } await page.screenshot({ path: 'dist/verification-post-sixty-fifth-progress.png', fullPage: true }); - await page.mouse.click(1160, 38); + await clickCampSortieCommand(page); await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieVisible === true); const wuzhangPriorityUnits = ['zhuge-liang', 'jiang-wei', 'wang-ping', 'zhao-yun', 'ma-dai', 'ma-chao', 'huang-quan', 'li-yan']; const wuzhangSortieReadyState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -10440,7 +10514,7 @@ try { ); if ( !wuzhangStoryState?.currentPageId?.startsWith('sixty-sixth-') || - !['story-weishui-northbank', 'story-northern', 'story-hanzhong-rain'].includes(wuzhangStoryState?.requestedBackground) || + wuzhangStoryState?.requestedBackground !== 'story-wuzhang-jiangwei-inheritance' || wuzhangStoryState?.backgroundReady !== true ) { throw new Error(`Expected Wuzhang finale story to load the new sixty-sixth intro pages and backgrounds: ${JSON.stringify(wuzhangStoryState)}`); @@ -10478,7 +10552,12 @@ try { !sixtySixthBattleState.objectives?.some((objective) => objective.id === 'hold-wuzhang-main-camp' && objective.label === '사마의 오장원 본대 격파') || !sixtySixthBattleState.objectives?.some((objective) => objective.id === 'stop-zhang-he-final-pursuit' && objective.label === '장합 최종 추격대 저지') || !sixtySixthBattleState.objectives?.some((objective) => objective.id === 'silence-guo-huai-wuzhang-beacons' && objective.label === '곽회 봉화 차단') || - !sixtySixthBattleState.objectives?.some((objective) => objective.id === 'preserve-wuzhang-camps' && objective.label === '오장원 본영 확보') || + !sixtySixthBattleState.objectives?.some( + (objective) => + objective.id === 'preserve-wuzhang-camps' && + objective.label.includes('오장원 본영') && + objective.label.includes('귀환 장부') + ) || !sixtySixthBattleState.objectives?.some((objective) => objective.id === 'protect-returning-villages' && objective.label === '귀환로 마을 보호') || sixtySixthEnemies.length < 40 || !sixtySixthEnemies.some((unit) => unit.id === 'northern-twelfth-leader-sima-yi' && unit.combatPortraitKey === 'portrait-sima-yi' && (unit.combatPortraitReady === true || allowCombatAssetFallback) && unit.textureBase === 'unit-sima-yi') || @@ -10514,6 +10593,15 @@ try { await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement?.status === 'complete'); finalSettlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement); } + await page.waitForFunction(() => { + const settlement = window.__HEROS_DEBUG__?.battle()?.resultSettlement; + return ( + settlement?.status === 'complete' && + settlement?.cta?.interactive === true && + settlement?.cta?.bounds + ); + }, undefined, { timeout: 30000 }); + finalSettlement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.resultSettlement); if (finalSettlement?.cta?.destination !== 'victory-story') { throw new Error(`Expected the final battle result to show its aftermath before the reward camp: ${JSON.stringify(finalSettlement)}`); } @@ -10550,19 +10638,26 @@ try { await page.screenshot({ path: 'dist/verification-final-camp-reward.png', fullPage: true }); await clickSceneBounds(page, 'CampScene', closeRewardAction.bounds); await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.victoryRewardAcknowledgement?.visible === false); - await clickLegacyUi(page, 1160, 38); + await clickCampSortieCommand(page); await waitForStoryReady(page); await page.waitForFunction(() => { const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.(); + const wuzhangBackgroundKeys = [ + 'story-wuzhang-jiangwei-inheritance', + 'story-hanzhong-rain-mountain-shrine', + 'story-qishan-renewed-snow-relay', + 'story-weishui-camps-frozen-watch', + 'story-weishui-northbank-frozen-ferry' + ]; return ( story?.currentPageId === 'ending-wuzhang-council' && story?.requestedBackground === 'story-wuzhang-jiangwei-inheritance' && story?.ready === true && story?.backgroundReady === true && - story?.background?.startsWith('story-wuzhang-jiangwei-inheritance') && + wuzhangBackgroundKeys.includes(story?.background) && story?.activeTexture === story?.background ); - }); + }, undefined, { timeout: 30000 }); await page.screenshot({ path: 'dist/verification-ending-epilogue-story.png', fullPage: true }); await page.screenshot({ path: 'dist/verification-wuzhang-inheritance-story.png', fullPage: true }); await page.evaluate(async () => { @@ -10771,23 +10866,49 @@ async function startDeploymentIfNeeded(page, battleId) { return battle?.battleId === expectedBattleId ? battle : undefined; }, battleId); - if (!state || !['deployment', 'idle'].includes(state.phase)) { + if (!state || state.phase !== 'deployment') { throw new Error(`Expected ${battleId} to be ready for deployment: ${JSON.stringify(state)}`); } - if (state.phase === 'idle') { - await page.waitForTimeout(250); - await recordFlowRuntimeProgress(page, 'battle', { battleId }); - return; - } if (!Array.isArray(state.deployedAllyIds) || state.deployedAllyIds.length === 0) { throw new Error(`Expected ${battleId} deployment to contain allied officers: ${JSON.stringify(state)}`); } - await clickBattleDeploymentStart(page); - await page.waitForFunction((expectedBattleId) => { - const battle = window.__HEROS_DEBUG__?.battle(); - return battle?.battleId === expectedBattleId && battle?.phase === 'idle' && battle?.triggeredBattleEvents?.includes('opening'); - }, battleId, { timeout: 30000 }); + let deploymentStarted = false; + for (let attempt = 0; attempt < 6; attempt += 1) { + await clickBattleDeploymentStart(page); + try { + await page.waitForFunction((expectedBattleId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return ( + battle?.battleId === expectedBattleId && + battle?.phase === 'idle' + ); + }, battleId, { timeout: 5000 }); + deploymentStarted = true; + break; + } catch { + // Combat assets can rebuild the deployment button while the + // initial click is in flight. Read the current bounds and retry. + } + } + if (!deploymentStarted) { + const diagnostic = await page.evaluate(() => { + const battle = window.__HEROS_DEBUG__?.battle(); + return { + battleId: battle?.battleId ?? null, + phase: battle?.phase ?? null, + triggeredBattleEvents: + battle?.triggeredBattleEvents ?? [], + deploymentPanel: + battle?.battleHud?.deploymentPanel ?? null, + combatAssets: battle?.combatAssets ?? null + }; + }); + throw new Error( + `Expected ${battleId} deployment to start after pointer retries: ` + + JSON.stringify(diagnostic) + ); + } await page.waitForTimeout(250); await recordFlowRuntimeProgress(page, 'battle', { battleId }); } @@ -11157,7 +11278,7 @@ async function openSortiePrep(page) { return; } - await page.mouse.click(1120, 38); + await clickCampSortieCommand(page); try { await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieVisible === true, undefined, { timeout: 1200 }); return; diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 202af1d..7855917 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -7,6 +7,7 @@ const checks = [ 'scripts/verify-campaign-save-normalization.mjs', 'scripts/verify-campaign-completion.mjs', 'scripts/verify-campaign-presentation-profiles.mjs', + 'scripts/verify-campaign-objective-journal.mjs', 'scripts/verify-growth-consistency.mjs', 'scripts/verify-combat-presentation-settings.mjs', 'scripts/verify-visual-motion-settings.mjs', diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 6b70297..f0b24e1 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -259,6 +259,10 @@ import { type CampaignVictoryRewardReport, type FirstBattleReport } from '../state/campaignState'; +import { + resolveCampaignObjectiveJournal, + type CampaignObjectiveJournalEntry +} from '../state/campaignObjectiveJournal'; import { chooseCityStayResonance, collectCityStayInformation, @@ -356,6 +360,14 @@ type CampTabButtonView = { hovered: boolean; }; +type CampObjectiveJournalCardView = { + kind: 'current' | 'completed' | 'next-clue'; + background: Phaser.GameObjects.Rectangle; + status: Phaser.GameObjects.Text; + title: Phaser.GameObjects.Text; + detail: Phaser.GameObjects.Text; +}; + type VictoryRewardCardId = CampaignVictoryRewardCategoryId; type VictoryRewardActionId = 'equipment' | 'supplies' | 'sortie' | 'close'; @@ -11437,6 +11449,11 @@ export class CampScene extends Phaser.Scene { private campRosterPage = 0; private campRosterLayout?: CampRosterLayout; private activeTab: CampTab = 'status'; + private campaignJournalReturnTab: Exclude = 'status'; + private campaignJournalPanel?: Phaser.GameObjects.Rectangle; + private campaignJournalCardViews: CampObjectiveJournalCardView[] = []; + private campaignJournalVisibleText: string[] = []; + private campaignJournalVisibleChapterTitles: string[] = []; private selectedCityLocation: CityStayLocationId = 'information'; private cityPanelBackground?: Phaser.GameObjects.Rectangle; private cityExploreButton?: Phaser.GameObjects.Rectangle; @@ -11593,6 +11610,11 @@ export class CampScene extends Phaser.Scene { this.navigationPending = false; this.visualMotionReduced = isVisualMotionReduced(); this.activeTab = 'status'; + this.campaignJournalReturnTab = 'status'; + this.campaignJournalPanel = undefined; + this.campaignJournalCardViews = []; + this.campaignJournalVisibleText = []; + this.campaignJournalVisibleChapterTitles = []; this.selectedCityLocation = 'information'; this.campNoticeRemovalTimer = undefined; this.contentObjects = []; @@ -11770,7 +11792,10 @@ export class CampScene extends Phaser.Scene { this.input.keyboard?.on('keydown-ESC', () => this.handleEscapeKey()); this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.handleSortieEnterKey(event)); this.input.keyboard?.on('keydown', (event: KeyboardEvent) => { - if (!this.handleThirdCampPreparationKey(event)) { + if ( + !this.handleCampaignJournalKey(event) && + !this.handleThirdCampPreparationKey(event) + ) { this.handleSaveSlotKey(event); } }); @@ -13236,14 +13261,14 @@ export class CampScene extends Phaser.Scene { 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); + this.addTabButton('기록 J', 'progress', 950, 38, 60, 13); } 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.addTabButton('기록 J', 'progress', 946, 38, 76, 14); } this.addCommandButton('저장', 1030, 38, 76, () => { soundDirector.playSelect(); @@ -14118,6 +14143,38 @@ export class CampScene extends Phaser.Scene { return { key, ready: true }; } + private handleCampaignJournalKey(event: KeyboardEvent) { + if (event.key.toLowerCase() !== 'j') { + return false; + } + if (event.repeat) { + return true; + } + event.preventDefault(); + if ( + this.navigationPending || + this.victoryRewardObjects.length > 0 || + this.equipmentSwapConfirmObjects.length > 0 || + this.saveSlotObjects.length > 0 || + this.saveSlotConfirmObjects.length > 0 || + this.reportFormationReviewVisible || + this.reportFormationHistoryVisible || + this.sortieObjects.length > 0 + ) { + return true; + } + soundDirector.playSelect(); + if (this.activeTab === 'progress') { + this.activeTab = this.campaignJournalReturnTab; + } else { + this.clearCampNotice(); + this.campaignJournalReturnTab = this.activeTab; + this.activeTab = 'progress'; + } + this.render(); + return true; + } + private handleEscapeKey() { if (this.navigationPending) { return; @@ -14175,6 +14232,13 @@ export class CampScene extends Phaser.Scene { if (this.sortieObjects.length > 0) { soundDirector.playSelect(); this.hideSortiePrep(); + return; + } + + if (this.activeTab === 'progress') { + soundDirector.playSelect(); + this.activeTab = this.campaignJournalReturnTab; + this.render(); } } @@ -14308,6 +14372,10 @@ export class CampScene extends Phaser.Scene { const selectTab = () => { soundDirector.playSelect(); + if (tab === 'progress' && this.activeTab !== 'progress') { + this.clearCampNotice(); + this.campaignJournalReturnTab = this.activeTab; + } this.activeTab = tab; this.render(); }; @@ -23541,46 +23609,235 @@ export class CampScene extends Phaser.Scene { const width = 828; const height = 444; const progress = this.campaignTimelineProgress(); + const journal = resolveCampaignObjectiveJournal( + this.campaign ?? getCampaignState() + ); const bg = this.track(this.add.rectangle(x, y, width, height, 0x101820, 0.9)); bg.setOrigin(0); bg.setStrokeStyle(1, palette.gold, 0.56); + this.campaignJournalPanel = bg; - this.track(this.add.text(x + 24, y + 22, '군영 연표', this.textStyle(24, '#f2e3bf', true))); - this.track(this.add.text(x + 24, y + 56, '유비군의 장기 서사와 현재 위치를 확인합니다.', this.textStyle(14, '#d4dce6'))); + const title = this.track(this.add.text(x + 24, y + 18, '진군 기록', this.textStyle(24, '#f2e3bf', true))); + const subtitle = this.track(this.add.text( + x + 164, + y + 26, + 'J · 지금 할 일과 이어질 실마리를 언제든 다시 확인', + this.textStyle(12, '#d4dce6', true) + )); + const progressLabel = this.track(this.add.text( + x + width - 24, + y + 24, + `전장 ${progress.completedKnown}/${progress.totalKnown}`, + this.textStyle(13, '#fff2b8', true) + )); + progressLabel.setOrigin(1, 0); + const ratio = progress.totalKnown > 0 + ? progress.completedKnown / progress.totalKnown + : 0; + this.drawBar(x + 24, y + 58, width - 48, 8, ratio, palette.gold); - const statY = y + 82; - this.renderProgressStatCard('완료 전장', `${progress.completedKnown}/${progress.totalKnown}`, x + 24, statY, 176); - this.renderProgressStatCard('현재 장', progress.activeChapter.title, x + 212, statY, 198); - this.renderProgressStatCard('최근 전장', progress.latestBattleTitle, x + 422, statY, 176); - this.renderProgressStatCard('다음 전장', progress.nextBattleTitle, x + 610, statY, 190); + this.campaignJournalVisibleText = [ + title.text, + subtitle.text, + progressLabel.text + ]; + this.campaignJournalCardViews = []; + + this.renderCampaignJournalCard( + 'current', + journal.current, + x + 24, + y + 82, + width - 48, + 92 + ); + this.renderCampaignJournalCard( + 'completed', + journal.recentCompleted, + x + 24, + y + 186, + 384, + 104 + ); + this.renderCampaignJournalCard( + 'next-clue', + journal.nextClue, + x + 420, + y + 186, + 384, + 104 + ); const completedIds = this.completedBattleIds(); - const listX = x + 24; - const listY = y + 142; - const compactChapterList = campaignTimelineChapters.length > 9; - const chapterRowGap = compactChapterList ? 22 : 31; - const chapterRowHeight = compactChapterList ? 20 : 27; - this.track(this.add.text(listX, listY, '큰 흐름', this.textStyle(17, '#f2e3bf', true))); - campaignTimelineChapters.forEach((chapter, index) => { - const status = this.timelineChapterStatus(chapter, index, progress.activeChapterIndex, completedIds); - const rowY = listY + 30 + index * chapterRowGap; - const active = index === progress.activeChapterIndex; - const complete = status === '완료'; - const row = this.track(this.add.rectangle(listX, rowY, 360, chapterRowHeight, active ? 0x25384a : complete ? 0x17231d : 0x151f2a, active ? 0.96 : 0.84)); - row.setOrigin(0); - row.setStrokeStyle(1, active ? palette.gold : complete ? palette.green : palette.blue, active ? 0.7 : 0.34); - const markerColor = active ? '#fff2b8' : complete ? '#a8ffd0' : '#9fb0bf'; - const textY = rowY + (compactChapterList ? 2 : 5); - this.track(this.add.text(listX + 12, textY, status, this.textStyle(compactChapterList ? 10 : 11, markerColor, true))); - this.track(this.add.text(listX + 64, rowY + (compactChapterList ? 1 : 4), chapter.title, this.textStyle(compactChapterList ? 11 : 13, active ? '#f2e3bf' : '#d4dce6', active))); - const countText = chapter.battleIds.length > 0 - ? `${chapter.battleIds.filter((id) => completedIds.has(id)).length}/${chapter.battleIds.length}` - : '예정'; - const count = this.track(this.add.text(listX + 342, textY, countText, this.textStyle(compactChapterList ? 10 : 11, markerColor, true))); - count.setOrigin(1, 0); - }); + const journeyX = x + 24; + const journeyY = y + 304; + const journeyWidth = width - 48; + const journeyHeight = 116; + const journeyBackground = this.track( + this.add.rectangle( + journeyX, + journeyY, + journeyWidth, + journeyHeight, + 0x0d141c, + 0.92 + ) + ); + journeyBackground.setOrigin(0); + journeyBackground.setStrokeStyle(1, palette.blue, 0.42); + const journeyTitle = this.track(this.add.text( + journeyX + 16, + journeyY + 12, + '공개된 큰 흐름', + this.textStyle(13, '#f2e3bf', true) + )); + const hiddenHint = this.track(this.add.text( + journeyX + journeyWidth - 16, + journeyY + 13, + '앞으로의 장은 도달할 때 공개됩니다.', + this.textStyle(11, '#9fb0bf', true) + )); + hiddenHint.setOrigin(1, 0); - this.renderProgressChapterDetail(progress.activeChapter, progress.activeChapterIndex, x + 410, listY, 390, 310, completedIds); + const revealedChapters = campaignTimelineChapters + .slice(0, progress.activeChapterIndex + 1) + .slice(-3); + this.campaignJournalVisibleChapterTitles = revealedChapters.map( + (chapter) => chapter.title + ); + this.campaignJournalVisibleText.push( + journeyTitle.text, + hiddenHint.text, + ...this.campaignJournalVisibleChapterTitles + ); + revealedChapters.forEach((chapter, index) => { + const absoluteIndex = campaignTimelineChapters.indexOf(chapter); + const active = absoluteIndex === progress.activeChapterIndex; + const complete = absoluteIndex < progress.activeChapterIndex; + const rowY = journeyY + 40 + index * 24; + const row = this.track( + this.add.rectangle( + journeyX + 16, + rowY, + journeyWidth - 32, + 20, + active ? 0x25384a : 0x17231d, + 0.9 + ) + ); + row.setOrigin(0); + row.setStrokeStyle( + 1, + active ? palette.gold : palette.green, + active ? 0.68 : 0.38 + ); + const status = active ? '◆ 현재' : complete ? '✓ 완료' : '• 공개'; + this.track(this.add.text( + journeyX + 28, + rowY + 2, + status, + this.textStyle(10, active ? '#fff2b8' : '#a8ffd0', true) + )); + this.track(this.add.text( + journeyX + 90, + rowY + 1, + chapter.title, + this.textStyle(11, '#d4dce6', active) + )); + const chapterProgress = chapter.battleIds.length > 0 + ? `${chapter.battleIds.filter((id) => completedIds.has(id)).length}/${chapter.battleIds.length}` + : active + ? '진행' + : '완료'; + const count = this.track(this.add.text( + journeyX + journeyWidth - 28, + rowY + 2, + chapterProgress, + this.textStyle(10, active ? '#fff2b8' : '#a8ffd0', true) + )); + count.setOrigin(1, 0); + this.campaignJournalVisibleText.push(status, chapterProgress); + }); + } + + private renderCampaignJournalCard( + kind: CampObjectiveJournalCardView['kind'], + entry: CampaignObjectiveJournalEntry, + x: number, + y: number, + width: number, + height: number + ) { + const cardStyle = kind === 'current' + ? { + fill: 0x25384a, + stroke: palette.gold, + status: '◆ 진행 · 지금 할 일', + statusColor: '#fff2b8' + } + : kind === 'completed' + ? { + fill: 0x17231d, + stroke: palette.green, + status: '✓ 완료 · 마친 일', + statusColor: '#a8ffd0' + } + : { + fill: 0x1b2430, + stroke: palette.blue, + status: '? 실마리 · 다음 단서', + statusColor: '#b9d8f2' + }; + const background = this.track( + this.add.rectangle(x, y, width, height, cardStyle.fill, 0.94) + ); + background.setOrigin(0); + background.setStrokeStyle( + kind === 'current' ? 2 : 1, + cardStyle.stroke, + kind === 'current' ? 0.82 : 0.56 + ); + const status = this.track(this.add.text( + x + 16, + y + 10, + cardStyle.status, + this.textStyle(12, cardStyle.statusColor, true) + )); + const locationText = entry.location ? ` · ${entry.location}` : ''; + const title = this.track(this.add.text( + x + 16, + y + 32, + this.compactText( + `${entry.title}${locationText}`, + kind === 'current' ? 42 : 21 + ), + this.textStyle(kind === 'current' ? 17 : 15, '#f2e3bf', true) + )); + const detail = this.track(this.add.text( + x + 16, + y + (kind === 'current' ? 58 : 59), + this.compactText(entry.detail, kind === 'current' ? 108 : 46), + { + ...this.textStyle(12, '#d4dce6'), + fixedWidth: width - 32, + wordWrap: { + width: width - 32, + useAdvancedWrap: true + } + } + )); + this.campaignJournalCardViews.push({ + kind, + background, + status, + title, + detail + }); + this.campaignJournalVisibleText.push( + cardStyle.status, + title.text, + detail.text + ); } private renderProgressStatCard(label: string, value: string, x: number, y: number, width: number) { @@ -25701,9 +25958,17 @@ export class CampScene extends Phaser.Scene { private showCampNotice(message: string) { this.clearCampNotice(); const depth = this.sortieObjects.length > 0 ? 72 : 30; + const journalOpen = this.activeTab === 'progress'; + const noticeX = journalOpen ? 202 : campLegacyCanvasWidth / 2; + const noticeY = journalOpen ? 116 : 104; + const noticeMinimumWidth = journalOpen ? 300 : 420; + const noticeMaximumWidth = journalOpen ? 340 : 760; const longestLineLength = Math.max(...message.split('\n').map((line) => line.length)); - const noticeWidth = Math.min(760, Math.max(420, longestLineLength * 13)); - const text = this.scaleLegacyCampUi(this.add.text(campLegacyCanvasWidth / 2, 104, message, { + const noticeWidth = Math.min( + noticeMaximumWidth, + Math.max(noticeMinimumWidth, longestLineLength * 13) + ); + const text = this.scaleLegacyCampUi(this.add.text(noticeX, noticeY, message, { ...this.textStyle(14, '#f2e3bf', true), align: 'center', wordWrap: { width: noticeWidth - 44, useAdvancedWrap: true } @@ -25712,7 +25977,9 @@ export class CampScene extends Phaser.Scene { text.setDepth(depth + 1); text.setLineSpacing(5); const noticeHeight = Math.max(56, text.height + 28); - const box = this.scaleLegacyCampUi(this.add.rectangle(campLegacyCanvasWidth / 2, 104, noticeWidth, noticeHeight, 0x101820, 0.96)); + const box = this.scaleLegacyCampUi( + this.add.rectangle(noticeX, noticeY, noticeWidth, noticeHeight, 0x101820, 0.96) + ); box.setStrokeStyle(2, palette.gold, 0.84); box.setDepth(depth); const noticeObjects: Phaser.GameObjects.GameObject[] = [box, text]; @@ -26601,6 +26868,10 @@ export class CampScene extends Phaser.Scene { private clearContent() { this.contentObjects.forEach((object) => object.destroy()); this.contentObjects = []; + this.campaignJournalPanel = undefined; + this.campaignJournalCardViews = []; + this.campaignJournalVisibleText = []; + this.campaignJournalVisibleChapterTitles = []; this.campDialogueRowButtons = {}; this.campDialogueChoiceButtons = {}; this.campDialogueBodyText = undefined; @@ -26675,6 +26946,41 @@ export class CampScene extends Phaser.Scene { }; } + private campaignObjectiveJournalDebug() { + const trigger = this.tabButtons.find( + (button) => button.tab === 'progress' + ); + const visible = this.activeTab === 'progress'; + return { + visible, + shortcut: 'J', + triggerBounds: this.sortieObjectBoundsDebug(trigger?.bg), + buttonBounds: this.sortieObjectBoundsDebug(trigger?.bg), + panelBounds: visible + ? this.sortieObjectBoundsDebug(this.campaignJournalPanel) + : null, + closeBounds: null, + objectCount: visible + ? 1 + this.campaignJournalCardViews.length + : 0, + cards: visible + ? this.campaignJournalCardViews.map((card) => ({ + kind: card.kind === 'next-clue' ? 'nextClue' : card.kind, + label: card.status.text, + title: card.title.text, + body: card.detail.text, + bounds: this.sortieObjectBoundsDebug(card.background), + titleBounds: this.sortieTextBoundsDebug(card.title), + bodyBounds: this.sortieTextBoundsDebug(card.detail) + })) + : [], + visibleText: visible ? [...this.campaignJournalVisibleText] : [], + revealedChapterTitles: visible + ? [...this.campaignJournalVisibleChapterTitles] + : [] + }; + } + getDebugState() { const sortieChecklist = this.sortieChecklist(); const sortieScenario = this.nextSortieScenario(); @@ -28158,6 +28464,12 @@ export class CampScene extends Phaser.Scene { rosterCollection: this.rosterCollectionSummary(), reserveTrainingAwards: this.latestReserveTrainingAwards(), campaignProgress: this.campaignTimelineProgress(), + campaignObjectiveJournal: { + snapshot: resolveCampaignObjectiveJournal( + this.campaign ?? getCampaignState() + ), + view: this.campaignObjectiveJournalDebug() + }, campBattleId: this.currentCampBattleId(), campTitle: this.currentCampTitle(), campSoundscape: this.campSoundscape @@ -28234,6 +28546,14 @@ export class CampScene extends Phaser.Scene { sortieItemAssignments: this.campaign.sortieItemAssignments, sortieResonanceSelection: this.campaign.sortieResonanceSelection ?? null, activeSaveSlot: this.campaign.activeSaveSlot, + acknowledgedVictoryRewardCategories: Object.fromEntries( + Object.entries( + this.campaign.acknowledgedVictoryRewardCategories + ).map(([battleId, categories]) => [ + battleId, + [...(categories ?? [])] + ]) + ), reserveTrainingAssignments: this.campaign.reserveTrainingAssignments, reserveTrainingFocus: this.campaign.reserveTrainingFocus, roster: this.campaign.roster.map((unit) => ({ diff --git a/src/game/scenes/CampVisitExplorationScene.ts b/src/game/scenes/CampVisitExplorationScene.ts index 12f1224..ddf77c3 100644 --- a/src/game/scenes/CampVisitExplorationScene.ts +++ b/src/game/scenes/CampVisitExplorationScene.ts @@ -50,6 +50,7 @@ import { ExplorationInputController } from '../input/ExplorationInputController' import { releaseTextureSource } from '../render/loaderLifecycle'; import { isVisualMotionReduced } from '../settings/visualMotion'; import { getCampaignState } from '../state/campaignState'; +import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal'; import { completeFirstPursuitScoutVisit } from '../state/firstPursuitScoutActions'; import { completeSecondBattleReliefExploration, @@ -62,6 +63,7 @@ import { recordThirdCampExplorationActivity, thirdCampExplorationProgress } from '../state/thirdCampExplorationActions'; +import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay'; import { fitDialogueFacePortrait } from '../ui/dialoguePortrait'; import { startGameScene } from './lazyScenes'; @@ -351,6 +353,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { private lastNotice = ''; private ownedPresentationTextureKeys = new Set(); private visualMotionReduced = false; + private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay; constructor() { super('CampVisitExplorationScene'); @@ -400,6 +403,8 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.explorationInput?.destroy(); this.explorationInput = undefined; this.dialogueState = undefined; + this.campaignObjectiveJournal?.destroy(); + this.campaignObjectiveJournal = undefined; this.closeChoicePanel(); releaseExplorationCharacterTextureKeys( this, @@ -445,6 +450,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.createDialoguePanel(); this.ready = true; this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + this.createCampaignObjectiveJournal(); this.refreshObjectiveHud(); this.refreshActorMarkers(); this.refreshInteractionPrompt(); @@ -493,6 +499,11 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.explorationInput?.discardInteractionRequest(); return; } + if (this.campaignObjectiveJournal?.isOpen()) { + this.stopPlayerMovement(); + this.explorationInput?.discardInteractionRequest(); + return; + } if (this.choicePanel) { this.stopPlayerMovement(); this.explorationInput?.discardInteractionRequest(); @@ -560,6 +571,10 @@ export class CampVisitExplorationScene extends Phaser.Scene { ready: this.ready, viewport: { width: this.scale.width, height: this.scale.height }, campaignStep: campaign.step, + campaignObjectiveJournal: { + snapshot: resolveCampaignObjectiveJournal(campaign), + view: this.campaignObjectiveJournal?.getDebugState() ?? null + }, background: { key: this.backgroundImage?.texture.key ?? this.currentBackgroundKey(), ready: this.backgroundImage?.active === true, @@ -1119,6 +1134,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { this.actorViews.clear(); this.blockers = []; this.dialogueState = undefined; + this.campaignObjectiveJournal = undefined; this.choicePanel = undefined; this.choicePanelBounds = undefined; this.choiceViews = []; @@ -2291,7 +2307,16 @@ export class CampVisitExplorationScene extends Phaser.Scene { .bindKey( Phaser.Input.Keyboard.KeyCodes.TWO, () => this.chooseChoiceByIndex(1) + ) + .bindKey( + Phaser.Input.Keyboard.KeyCodes.J, + () => this.campaignObjectiveJournal?.toggle() ); + this.input.keyboard?.on('keydown-ESC', () => { + if (this.campaignObjectiveJournal?.isOpen()) { + this.campaignObjectiveJournal.close(); + } + }); this.input.on( Phaser.Input.Events.POINTER_DOWN, @@ -2299,6 +2324,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { if ( !this.ready || this.navigationPending || + this.campaignObjectiveJournal?.isOpen() || this.time.now < this.inputReadyAt ) { return; @@ -2334,6 +2360,10 @@ export class CampVisitExplorationScene extends Phaser.Scene { ) { return; } + if (this.campaignObjectiveJournal?.isOpen()) { + this.campaignObjectiveJournal.close(); + return; + } if (this.choicePanel) { this.closeChoicePanel(); return; @@ -2351,6 +2381,16 @@ export class CampVisitExplorationScene extends Phaser.Scene { ); } + private createCampaignObjectiveJournal() { + this.campaignObjectiveJournal = new CampaignObjectiveJournalOverlay(this, { + snapshotProvider: () => + resolveCampaignObjectiveJournal(getCampaignState()), + canOpen: () => this.ready && !this.navigationPending, + buttonX: 1215, + buttonY: 21 + }); + } + private updateMovement(time: number, delta: number) { if (!this.player) { return; @@ -3466,6 +3506,7 @@ export class CampVisitExplorationScene extends Phaser.Scene { if ( !this.choicePanel || this.navigationPending || + this.campaignObjectiveJournal?.isOpen() || this.time.now < this.choiceReadyAt ) { return; @@ -3477,6 +3518,13 @@ export class CampVisitExplorationScene extends Phaser.Scene { } private chooseVisitChoice(choice: ChoiceOption) { + if ( + !this.choicePanel || + this.navigationPending || + this.campaignObjectiveJournal?.isOpen() + ) { + return; + } if (this.isThirdCampVisit()) { if ('rewardExp' in choice) { this.chooseThirdCampCompanion(choice); diff --git a/src/game/scenes/CityStayScene.ts b/src/game/scenes/CityStayScene.ts index 06f3c49..72ce99f 100644 --- a/src/game/scenes/CityStayScene.ts +++ b/src/game/scenes/CityStayScene.ts @@ -53,6 +53,8 @@ import { setActiveCityStayId, type CampaignState } from '../state/campaignState'; +import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal'; +import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay'; import { fitDialogueFacePortrait } from '../ui/dialoguePortrait'; import { startGameScene } from './lazyScenes'; @@ -209,6 +211,7 @@ export class CityStayScene extends Phaser.Scene { private lastNotice = ''; private visualMotionReduced = false; private ownedPresentationTextureKeys = new Set(); + private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay; constructor() { super('CityStayScene'); @@ -267,6 +270,7 @@ export class CityStayScene extends Phaser.Scene { this.createDialoguePanel(); this.ready = true; this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + this.createCampaignObjectiveJournal(); this.refreshProgressHud(); this.refreshActorMarkers(); this.refreshInteractionPrompt(); @@ -285,6 +289,8 @@ export class CityStayScene extends Phaser.Scene { this.explorationInput?.destroy(); this.explorationInput = undefined; this.dialogueState = undefined; + this.campaignObjectiveJournal?.destroy(); + this.campaignObjectiveJournal = undefined; releaseExplorationCharacterTextureKeys( this, this.characterTextureKeys() @@ -299,6 +305,12 @@ export class CityStayScene extends Phaser.Scene { return; } + if (this.campaignObjectiveJournal?.isOpen()) { + this.stopPlayerMovement(); + this.explorationInput?.discardInteractionRequest(); + return; + } + if (this.shopPanel || this.choicePanel) { this.stopPlayerMovement(); this.explorationInput?.discardInteractionRequest(); @@ -339,6 +351,10 @@ export class CityStayScene extends Phaser.Scene { ready: this.ready, cityStayId: this.cityStay.id, cityName: this.cityStay.city.name, + campaignObjectiveJournal: { + snapshot: resolveCampaignObjectiveJournal(campaign), + view: this.campaignObjectiveJournal?.getDebugState() ?? null + }, visualMotionReduced: this.visualMotionReduced, viewport: { width: this.scale.width, height: this.scale.height }, activeCityStayId: campaign.activeCityStayId ?? null, @@ -581,6 +597,7 @@ export class CityStayScene extends Phaser.Scene { this.dialoguePortraitFrame = undefined; this.dialoguePortrait = undefined; this.dialogueState = undefined; + this.campaignObjectiveJournal = undefined; this.shopPanel = undefined; this.shopOfferViews = []; this.shopNotice = ''; @@ -1298,10 +1315,24 @@ export class CityStayScene extends Phaser.Scene { .bindKey( Phaser.Input.Keyboard.KeyCodes.TWO, () => this.chooseDialogueByIndex(1) + ) + .bindKey( + Phaser.Input.Keyboard.KeyCodes.J, + () => this.campaignObjectiveJournal?.toggle() ); + this.input.keyboard?.on('keydown-ESC', () => { + if (this.campaignObjectiveJournal?.isOpen()) { + this.campaignObjectiveJournal.close(); + } + }); this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => { - if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) { + if ( + !this.ready || + this.navigationPending || + this.campaignObjectiveJournal?.isOpen() || + this.time.now < this.inputReadyAt + ) { return; } if (this.shopPanel || this.choicePanel) { @@ -1332,6 +1363,10 @@ export class CityStayScene extends Phaser.Scene { if (!this.ready || this.navigationPending || this.time.now < this.inputReadyAt) { return; } + if (this.campaignObjectiveJournal?.isOpen()) { + this.campaignObjectiveJournal.close(); + return; + } if (this.shopPanel) { this.closeShop(); return; @@ -2135,7 +2170,11 @@ export class CityStayScene extends Phaser.Scene { } private chooseDialogueByIndex(index: number) { - if (!this.choicePanel || this.navigationPending) { + if ( + !this.choicePanel || + this.navigationPending || + this.campaignObjectiveJournal?.isOpen() + ) { return; } const choice = this.cityStay.dialogue.choices[index]; @@ -2145,6 +2184,13 @@ export class CityStayScene extends Phaser.Scene { } private chooseDialogue(choice: CityResonanceDialogueChoice) { + if ( + !this.choicePanel || + this.navigationPending || + this.campaignObjectiveJournal?.isOpen() + ) { + return; + } const result = chooseCityStayResonance(this.cityStay.id, choice.id); if (!result.ok) { this.closeChoicePanel(); @@ -2905,7 +2951,22 @@ export class CityStayScene extends Phaser.Scene { } private modalOpen() { - return Boolean(this.dialogueState || this.shopPanel || this.choicePanel); + return Boolean( + this.campaignObjectiveJournal?.isOpen() || + this.dialogueState || + this.shopPanel || + this.choicePanel + ); + } + + private createCampaignObjectiveJournal() { + this.campaignObjectiveJournal = new CampaignObjectiveJournalOverlay(this, { + snapshotProvider: () => + resolveCampaignObjectiveJournal(getCampaignState()), + canOpen: () => this.ready && !this.navigationPending, + buttonX: 1215, + buttonY: 21 + }); } private releasePresentationTextures() { diff --git a/src/game/scenes/PrologueMilitiaCampScene.ts b/src/game/scenes/PrologueMilitiaCampScene.ts index 11f6c29..cfbd493 100644 --- a/src/game/scenes/PrologueMilitiaCampScene.ts +++ b/src/game/scenes/PrologueMilitiaCampScene.ts @@ -41,8 +41,10 @@ import { setCampaignSortieOrderSelection, type CampaignTutorialId } from '../state/campaignState'; +import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { isVisualMotionReduced } from '../settings/visualMotion'; +import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay'; import { fitDialogueFacePortrait } from '../ui/dialoguePortrait'; import { palette } from '../ui/palette'; import { startGameScene } from './lazyScenes'; @@ -228,6 +230,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { private firstVisit = false; private visualMotionReduced = false; private ownedPresentationTextureKeys = new Set(); + private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay; constructor() { super('PrologueMilitiaCampScene'); @@ -276,6 +279,8 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.navigationPending = false; this.clearNavigationTarget(true); this.dialogueState = undefined; + this.campaignObjectiveJournal?.destroy(); + this.campaignObjectiveJournal = undefined; this.closeCommandChoice(); this.stopNpcMovement(); releaseExplorationCharacterTextureKeys(this, characterTextureKeys); @@ -290,6 +295,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.loadingOverlay = undefined; this.ready = true; this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + this.createCampaignObjectiveJournal(); this.refreshObjectiveHud(); this.refreshNpcMarkers(); this.refreshInteractionPrompt(); @@ -313,6 +319,11 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.interactionQueued = false; return; } + if (this.campaignObjectiveJournal?.isOpen()) { + this.stopPlayerMovement(); + this.interactionQueued = false; + return; + } if (this.commandChoicePanel) { this.stopPlayerMovement(); this.interactionQueued = false; @@ -349,6 +360,10 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { scene: this.scene.key, locationId: 'zhuo-volunteer-camp', ready: this.ready, + campaignObjectiveJournal: { + snapshot: resolveCampaignObjectiveJournal(campaign), + view: this.campaignObjectiveJournal?.getDebugState() ?? null + }, visualMotionReduced: this.visualMotionReduced, viewport: { width: this.scale.width, height: this.scale.height }, campaignStep: campaign.step, @@ -622,6 +637,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { this.dialoguePortraitFrame = undefined; this.dialoguePortrait = undefined; this.dialogueState = undefined; + this.campaignObjectiveJournal = undefined; this.commandChoicePanel = undefined; this.commandChoicePanelBounds = undefined; this.commandChoiceViews = []; @@ -1399,7 +1415,15 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER) .on('down', () => this.confirmFirstCommand()); keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ESC) - .on('down', () => this.cancelFirstCommandChoice()); + .on('down', () => { + if (this.campaignObjectiveJournal?.isOpen()) { + this.campaignObjectiveJournal.close(); + return; + } + this.cancelFirstCommandChoice(); + }); + keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.J) + .on('down', () => this.campaignObjectiveJournal?.toggle()); } this.input.on(Phaser.Input.Events.POINTER_DOWN, (pointer: Phaser.Input.Pointer) => { @@ -1407,6 +1431,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { !this.ready || this.navigationPending || this.npcMovementPending || + this.campaignObjectiveJournal?.isOpen() || this.time.now < this.inputReadyAt ) { return; @@ -1432,6 +1457,16 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { }); } + private createCampaignObjectiveJournal() { + this.campaignObjectiveJournal = new CampaignObjectiveJournalOverlay(this, { + snapshotProvider: () => + resolveCampaignObjectiveJournal(getCampaignState()), + canOpen: () => this.ready && !this.navigationPending, + buttonX: 1215, + buttonY: 21 + }); + } + private updateMovement(time: number, delta: number) { if (!this.player) { return; @@ -2395,6 +2430,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { if ( !this.commandChoicePanel || this.navigationPending || + this.campaignObjectiveJournal?.isOpen() || (!bypassInputGuard && this.time.now < this.commandChoiceReadyAt) ) { return false; @@ -2434,6 +2470,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { !this.commandChoicePanel || !this.pendingCommandOrderId || this.navigationPending || + this.campaignObjectiveJournal?.isOpen() || (!bypassInputGuard && this.time.now < this.commandChoiceReadyAt) ) { return false; @@ -2461,6 +2498,7 @@ export class PrologueMilitiaCampScene extends Phaser.Scene { if ( !this.commandChoicePanel || this.navigationPending || + this.campaignObjectiveJournal?.isOpen() || this.time.now < this.commandChoiceReadyAt ) { return false; diff --git a/src/game/scenes/PrologueVillageScene.ts b/src/game/scenes/PrologueVillageScene.ts index c4e5336..8ca917e 100644 --- a/src/game/scenes/PrologueVillageScene.ts +++ b/src/game/scenes/PrologueVillageScene.ts @@ -35,8 +35,10 @@ import { prologueVillageCampaignTutorialIds, type CampaignTutorialId } from '../state/campaignState'; +import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { isVisualMotionReduced } from '../settings/visualMotion'; +import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay'; import { fitDialogueFacePortrait } from '../ui/dialoguePortrait'; import { palette } from '../ui/palette'; import { startGameScene } from './lazyScenes'; @@ -247,6 +249,7 @@ export class PrologueVillageScene extends Phaser.Scene { private firstVisit = false; private visualMotionReduced = false; private ownedPresentationTextureKeys = new Set(); + private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay; constructor() { super('PrologueVillageScene'); @@ -297,6 +300,8 @@ export class PrologueVillageScene extends Phaser.Scene { this.clearNavigationTarget(true); this.queuedMovementIntent = undefined; this.dialogueState = undefined; + this.campaignObjectiveJournal?.destroy(); + this.campaignObjectiveJournal = undefined; this.stopAllNpcMovement(); releaseExplorationCharacterTextureKeys(this, characterTextureKeys); this.releasePresentationTextures(); @@ -310,6 +315,7 @@ export class PrologueVillageScene extends Phaser.Scene { this.loadingOverlay = undefined; this.ready = true; this.inputReadyAt = this.time.now + inputCarryoverGuardMs; + this.createCampaignObjectiveJournal(); this.refreshObjectiveHud(); this.refreshNpcMarkers(); this.refreshInteractionPrompt(); @@ -331,6 +337,12 @@ export class PrologueVillageScene extends Phaser.Scene { return; } + if (this.campaignObjectiveJournal?.isOpen()) { + this.interactionQueued = false; + this.stopPlayerMovement(); + return; + } + if (this.oathGatherPending) { this.interactionQueued = false; this.stopPlayerMovement(); @@ -377,6 +389,10 @@ export class PrologueVillageScene extends Phaser.Scene { visualMotionReduced: this.visualMotionReduced, viewport: { width: this.scale.width, height: this.scale.height }, campaignStep: campaign.step, + campaignObjectiveJournal: { + snapshot: resolveCampaignObjectiveJournal(campaign), + view: this.campaignObjectiveJournal?.getDebugState() ?? null + }, background: { key: this.backgroundImage?.texture.key ?? villageBackgroundKey, ready: this.backgroundImage?.active === true, @@ -577,6 +593,7 @@ export class PrologueVillageScene extends Phaser.Scene { this.dialoguePortraitFrame = undefined; this.dialoguePortrait = undefined; this.dialogueState = undefined; + this.campaignObjectiveJournal = undefined; this.moveTarget = undefined; this.moveWaypoints = []; this.moveTargetNpcId = undefined; @@ -1187,6 +1204,14 @@ export class PrologueVillageScene extends Phaser.Scene { this.interactionQueued = true; }); }); + keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.J) + .on('down', () => this.campaignObjectiveJournal?.toggle()); + keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ESC) + .on('down', () => { + if (this.campaignObjectiveJournal?.isOpen()) { + this.campaignObjectiveJournal.close(); + } + }); keyboard.addCapture([ Phaser.Input.Keyboard.KeyCodes.UP, Phaser.Input.Keyboard.KeyCodes.DOWN, @@ -1201,6 +1226,7 @@ export class PrologueVillageScene extends Phaser.Scene { !this.ready || this.navigationPending || this.oathGatherPending || + this.campaignObjectiveJournal?.isOpen() || this.time.now < this.inputReadyAt ) { return; @@ -1228,6 +1254,16 @@ export class PrologueVillageScene extends Phaser.Scene { }); } + private createCampaignObjectiveJournal() { + this.campaignObjectiveJournal = new CampaignObjectiveJournalOverlay(this, { + snapshotProvider: () => + resolveCampaignObjectiveJournal(getCampaignState()), + canOpen: () => this.ready && !this.navigationPending, + buttonX: 1215, + buttonY: 21 + }); + } + private updateMovement(time: number, delta: number) { if (!this.player) { return; diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index 463228f..aaa4e69 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -57,8 +57,10 @@ import { import { prologueOpeningPages } from '../data/prologueVillage'; import { type PortraitKey, type StoryPage } from '../data/scenario'; import { getCampaignState, getFirstBattleReport } from '../state/campaignState'; +import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { isVisualMotionReduced } from '../settings/visualMotion'; +import { CampaignObjectiveJournalOverlay } from '../ui/CampaignObjectiveJournalOverlay'; import { fitDialogueFacePortrait } from '../ui/dialoguePortrait'; import { palette } from '../ui/palette'; import { ensureLazyScene, startGameScene } from './lazyScenes'; @@ -236,6 +238,7 @@ export class StoryScene extends Phaser.Scene { private storyEnvironmentHaze: StoryEnvironmentHazeView[] = []; private storyEnvironmentRandomState = 1; private storyEnvironmentRevision = 0; + private campaignObjectiveJournal?: CampaignObjectiveJournalOverlay; constructor() { super('StoryScene'); @@ -290,6 +293,7 @@ export class StoryScene extends Phaser.Scene { this.storyEnvironmentHaze = []; this.storyEnvironmentRandomState = 1; this.storyEnvironmentRevision = 0; + this.campaignObjectiveJournal = undefined; } getDebugState() { @@ -309,6 +313,10 @@ export class StoryScene extends Phaser.Scene { return { scene: this.scene.key, ready: this.sys.isActive() && this.loadingText === undefined && this.background?.active === true, + campaignObjectiveJournal: { + snapshot: resolveCampaignObjectiveJournal(getCampaignState()), + view: this.campaignObjectiveJournal?.getDebugState() ?? null + }, pageIndex: this.pageIndex, totalPages: this.pages.length, currentPageId: page?.id, @@ -426,6 +434,8 @@ export class StoryScene extends Phaser.Scene { this.activeStoryLoadErrorHandler = undefined; } this.pageLoadingText = undefined; + this.campaignObjectiveJournal?.destroy(); + this.campaignObjectiveJournal = undefined; this.clearStoryEnvironment(); this.releaseOwnedStoryTextures(); }); @@ -445,10 +455,33 @@ export class StoryScene extends Phaser.Scene { this.drawSceneShade(width, height); this.drawDialogPanel(width, height); this.showPage(0, true); + this.campaignObjectiveJournal = new CampaignObjectiveJournalOverlay( + this, + { + snapshotProvider: () => + resolveCampaignObjectiveJournal(getCampaignState()), + canOpen: () => !this.transitioning && !this.loadingText, + buttonX: 1215, + buttonY: 21 + } + ); this.input.on('pointerdown', () => this.advance()); this.input.keyboard?.on('keydown-SPACE', () => this.advance()); this.input.keyboard?.on('keydown-ENTER', () => this.advance()); + this.input.keyboard?.on( + 'keydown-J', + (event: KeyboardEvent) => { + if (!event.repeat) { + this.campaignObjectiveJournal?.toggle(); + } + } + ); + this.input.keyboard?.on('keydown-ESC', () => { + if (this.campaignObjectiveJournal?.isOpen()) { + this.campaignObjectiveJournal.close(); + } + }); }, true); } @@ -2082,7 +2115,7 @@ export class StoryScene extends Phaser.Scene { } private advance() { - if (this.transitioning) { + if (this.transitioning || this.campaignObjectiveJournal?.isOpen()) { return; } diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index cc1a189..5759445 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -41,6 +41,7 @@ import { startNewCampaign, type CampaignSaveSlotSummary } from '../state/campaignState'; +import { resolveCampaignObjectiveJournal } from '../state/campaignObjectiveJournal'; import { palette } from '../ui/palette'; import { startLazyScene, type LazySceneKey } from './lazyScenes'; @@ -460,8 +461,8 @@ export class TitleScene extends Phaser.Scene { return this.compactSaveDetail(`${Object.keys(campaign.battleHistory).length}전 완료 · ${campaign.roster.length}명 합류`); } - const progress = summarizeCampaignProgress(campaign); - return this.compactSaveDetail(`${progress.meta} · ${progress.title}`); + const journal = resolveCampaignObjectiveJournal(campaign); + return this.compactSaveDetail(`◆ ${journal.current.title}`); } private compactSaveDetail(label: string) { diff --git a/src/game/state/campaignObjectiveJournal.ts b/src/game/state/campaignObjectiveJournal.ts new file mode 100644 index 0000000..15248c5 --- /dev/null +++ b/src/game/state/campaignObjectiveJournal.ts @@ -0,0 +1,1072 @@ +import { battleScenarios } from '../data/battles'; +import { getSortieFlow } from '../data/campaignFlow'; +import { getCityStayDefinition } from '../data/cityStays'; +import { + firstPursuitScoutVisitDefinition, + getFirstPursuitScoutVisitChoice +} from '../data/firstPursuitScoutVisit'; +import { + firstPursuitScoutVisitId +} from '../data/firstPursuitScoutMemory'; +import { + prologueMilitiaCampObjectiveDefinitions, + prologueMilitiaCampRequiredObjectiveIds, + type PrologueMilitiaCampRequiredObjectiveId +} from '../data/prologueMilitiaCamp'; +import { + prologueVillageObjectiveDefinitions, + prologueVillageRequiredObjectiveIds, + type PrologueVillageRequiredObjectiveId +} from '../data/prologueVillage'; +import { + completedSecondBattleReliefObjectiveIds, + getSecondBattleReliefChoice, + getSecondBattleReliefObjective, + secondBattleReliefObjectiveProgressId, + secondBattleReliefVisitId +} from '../data/secondBattleReliefExploration'; +import { + completedThirdCampExplorationActivityIds, + resolveThirdCampExplorationDefinition, + thirdCampExplorationActivityProgressId, + type ThirdCampExplorationActivityId +} from '../data/thirdCampExploration'; +import { battleIdForCampaignStep, isCampCampaignStep } from './campaignRouting'; +import { + prologueMilitiaCampCampaignTutorialIds, + prologueVillageCampaignTutorialIds, + type CampaignBattleSettlement, + type CampaignState, + type CampaignTutorialId +} from './campaignState'; + +export const campaignObjectiveJournalStatusLabels = { + current: '◆ 진행', + completed: '✓ 완료', + clue: '? 실마리' +} as const; + +export type CampaignObjectiveJournalStatus = + keyof typeof campaignObjectiveJournalStatusLabels; + +export type CampaignObjectiveJournalContextKind = + | 'not-started' + | 'story' + | 'village-exploration' + | 'militia-camp-exploration' + | 'battle-preparation' + | 'aftermath' + | 'city-stay' + | 'interlude-exploration' + | 'camp' + | 'ending'; + +export type CampaignObjectiveJournalEntry = Readonly<{ + id: string; + status: CampaignObjectiveJournalStatus; + statusLabel: + (typeof campaignObjectiveJournalStatusLabels)[CampaignObjectiveJournalStatus]; + title: string; + detail: string; + location?: string; +}>; + +export type CampaignObjectiveJournalProgress = Readonly<{ + completed: number; + total?: number; + knownTotal: boolean; + label: string; +}>; + +export type CampaignObjectiveJournalResumeContext = Readonly<{ + title: string; + summary: string; + lines: readonly [string, string, string]; +}>; + +export type CampaignObjectiveJournalSnapshot = Readonly<{ + contextKind: CampaignObjectiveJournalContextKind; + current: CampaignObjectiveJournalEntry; + recentCompleted: CampaignObjectiveJournalEntry; + nextClue: CampaignObjectiveJournalEntry; + progress: CampaignObjectiveJournalProgress; + resumeContext: CampaignObjectiveJournalResumeContext; +}>; + +type SnapshotParts = { + contextKind: CampaignObjectiveJournalContextKind; + contextTitle: string; + current: CampaignObjectiveJournalEntry; + recentCompleted: CampaignObjectiveJournalEntry; + nextClue: CampaignObjectiveJournalEntry; + progress: CampaignObjectiveJournalProgress; +}; + +const villageTutorialIdByObjectiveId = { + 'meet-zhang-fei': prologueVillageCampaignTutorialIds.meetZhangFei, + 'meet-guan-yu': prologueVillageCampaignTutorialIds.meetGuanYu, + 'register-volunteers': prologueVillageCampaignTutorialIds.registerVolunteers +} as const satisfies Record; + +const militiaCampTutorialIdByObjectiveId = { + 'review-scout-report': prologueMilitiaCampCampaignTutorialIds.reviewScoutReport, + 'ready-vanguard': prologueMilitiaCampCampaignTutorialIds.readyVanguard, + 'inspect-arms': prologueMilitiaCampCampaignTutorialIds.inspectArms +} as const satisfies Record; + +const safeThirdCampClues: Record = { + information: '작전판의 표시를 살펴 현재 진영에서 확인한 적 선봉 정보를 기록할 수 있습니다.', + equipment: '보급 수레의 표시를 살펴 지금 출전에 쓸 방호구와 응급 물자를 정비할 수 있습니다.', + companion: '먼저 돌아온 동료의 표시를 찾아 지난 전투의 호흡을 되짚을 수 있습니다.' +}; + +export function resolveCampaignObjectiveJournal( + state: CampaignState +): CampaignObjectiveJournalSnapshot { + if (state.step === 'new') { + return buildSnapshot({ + contextKind: 'not-started', + contextTitle: '새 여정', + current: activeEntry( + 'start-campaign', + '새 캠페인을 시작하십시오', + '새 기록을 선택하면 첫 이야기가 시작됩니다.' + ), + recentCompleted: completedEntry( + 'save-record-ready', + '새 여정 기록 준비', + '진행 상황을 남길 기록이 준비되었습니다.' + ), + nextClue: clueEntry( + 'start-campaign-clue', + '첫 이야기 열기', + '새 캠페인 버튼을 선택해 현재 여정을 시작하십시오.' + ), + progress: knownProgress(0, 1, '여정 시작 전') + }); + } + + if (state.pendingAftermathBattleId) { + return resolvePendingAftermathJournal(state); + } + + if (state.activeCityStayId) { + return resolveCityStayJournal(state); + } + + if (state.step === 'prologue') { + return buildSnapshot({ + contextKind: 'story', + contextTitle: '서막', + current: activeEntry( + 'prologue-story', + '서막 이야기를 확인하십시오', + '대화를 진행해 현재 사건의 배경을 확인하십시오.' + ), + recentCompleted: completedEntry( + 'campaign-started', + '새 캠페인 시작', + '첫 이야기를 확인할 준비를 마쳤습니다.' + ), + nextClue: clueEntry( + 'prologue-story-clue', + '이야기 계속하기', + '현재 대화가 끝날 때까지 계속 진행하십시오.' + ), + progress: knownProgress(0, 1, '서막 확인 0 / 1') + }); + } + + if (state.step === 'prologue-town') { + return resolvePrologueVillageJournal(state); + } + + if (state.step === 'prologue-camp') { + return resolvePrologueMilitiaCampJournal(state); + } + + if (state.step === 'first-victory-story') { + const recentCompleted = defaultRecentCompleted(state); + return buildSnapshot({ + contextKind: 'story', + contextTitle: '승리 뒤 이야기', + current: activeEntry( + 'first-victory-story', + '승리 뒤 이야기를 확인하십시오', + '현재 대화를 마쳐 전투 뒤의 변화를 기록하십시오.' + ), + recentCompleted, + nextClue: clueEntry( + 'first-victory-story-clue', + '이야기 계속하기', + '대화를 끝까지 진행하면 현재 기록이 정리됩니다.' + ), + progress: knownProgress(0, 1, '승리 뒤 이야기 0 / 1') + }); + } + + if (state.step === 'ending-complete') { + return buildSnapshot({ + contextKind: 'ending', + contextTitle: '완료한 여정', + current: activeEntry( + 'campaign-ending', + '완료한 여정을 돌아보십시오', + '마지막 기록과 함께 지금까지 이어 온 뜻을 확인할 수 있습니다.' + ), + recentCompleted: defaultRecentCompleted(state), + nextClue: clueEntry( + 'campaign-ending-clue', + '완료 기록 다시 보기', + '이어하기를 선택하면 이 여정의 마지막 기록을 다시 확인할 수 있습니다.' + ), + progress: knownProgress(1, 1, '캠페인 완료') + }); + } + + const currentBattleId = battleIdForCampaignStep(state.step); + if (currentBattleId) { + return resolveBattlePreparationJournal(state, currentBattleId); + } + + if (isCampCampaignStep(state.step)) { + if (state.step === 'first-camp') { + return resolveFirstCampInterludeJournal(state); + } + if (state.step === 'second-camp') { + return resolveSecondCampInterludeJournal(state); + } + if (state.step === 'third-camp') { + return resolveThirdCampInterludeJournal(state); + } + return resolveGenericCampJournal(state); + } + + return buildSnapshot({ + contextKind: 'story', + contextTitle: '진군 기록', + current: activeEntry( + 'continue-current-story', + '현재 이야기를 계속 확인하십시오', + '화면에 열린 대화와 안내를 끝까지 진행하십시오.' + ), + recentCompleted: defaultRecentCompleted(state), + nextClue: clueEntry( + 'continue-current-story-clue', + '현재 화면에서 계속하기', + '새 정보가 열리기 전에는 현재 화면의 행동만 확인하면 됩니다.' + ), + progress: campaignVictoryProgress(state) + }); +} + +function resolvePendingAftermathJournal( + state: CampaignState +): CampaignObjectiveJournalSnapshot { + const battleId = state.pendingAftermathBattleId!; + const settlement = state.battleHistory[battleId]; + const battleTitle = + settlement?.battleTitle ?? + battleScenarios[battleId]?.title ?? + '최근 전투'; + + return buildSnapshot({ + contextKind: 'aftermath', + contextTitle: '승리 후 이야기', + current: activeEntry( + 'pending-aftermath', + '승리 후 이야기를 확인하십시오', + `${battleTitle} 뒤에 이어지는 현재 이야기를 끝까지 확인하십시오.` + ), + recentCompleted: settlement + ? settlementCompletedEntry(settlement) + : completedEntry( + 'pending-aftermath-battle', + `${battleTitle} 전투 마침`, + '전투 결과가 현재 진군 기록에 남았습니다.' + ), + nextClue: clueEntry( + 'pending-aftermath-clue', + '전후 이야기 계속하기', + '현재 대화를 마치면 전후 정리를 이어 갈 수 있습니다.' + ), + progress: knownProgress(0, 1, '승리 후 이야기 0 / 1') + }); +} + +function resolveCityStayJournal( + state: CampaignState +): CampaignObjectiveJournalSnapshot { + const cityStay = getCityStayDefinition(state.activeCityStayId!); + const informationComplete = state.completedCampVisits.includes( + cityStay.information.id + ); + const dialogueComplete = state.completedCampDialogues.includes( + cityStay.dialogue.id + ); + const completedCount = + Number(informationComplete) + Number(dialogueComplete); + + let current: CampaignObjectiveJournalEntry; + let nextClue: CampaignObjectiveJournalEntry; + + if (!informationComplete) { + current = activeEntry( + `city-information-${cityStay.id}`, + `${cityStay.city.name}의 현지 정보를 확인하십시오`, + '현재 성내에서 정보 표시가 있는 인물에게 다가가 기록을 확인하십시오.', + cityStay.information.location + ); + nextClue = clueEntry( + `city-information-clue-${cityStay.id}`, + '정보 표시 찾기', + `${cityStay.information.location}에서 정보 표시를 찾아 대화하십시오.` + ); + } else if (!dialogueComplete) { + current = activeEntry( + `city-dialogue-${cityStay.id}`, + `${cityStay.city.name}에서 동료와 대화하십시오`, + '현재 성내의 동료에게 다가가 체류 중 나눌 이야기를 확인하십시오.' + ); + nextClue = clueEntry( + `city-dialogue-clue-${cityStay.id}`, + '동료 대화 찾기', + '성내의 대화 표시가 있는 동료에게 다가가 말을 거십시오.' + ); + } else { + current = activeEntry( + `city-exit-${cityStay.id}`, + `${cityStay.city.name} 체류를 마치고 군영으로 돌아가십시오`, + '주요 체류 활동은 끝났습니다. 시장 이용은 선택이며 출구로 바로 돌아갈 수 있습니다.' + ); + nextClue = clueEntry( + `city-exit-clue-${cityStay.id}`, + '군영 출구 찾기', + '시장 이용을 마쳤거나 건너뛸 경우 군영 출구로 이동하십시오.' + ); + } + + const recentCompleted = + informationComplete && dialogueComplete + ? completedEntry( + `city-major-activities-${cityStay.id}`, + `${cityStay.city.name} 주요 체류 활동`, + '현지 정보와 동료 대화를 현재 기록에 남겼습니다.' + ) + : dialogueComplete + ? completedEntry( + `city-dialogue-complete-${cityStay.id}`, + '동료 공명 대화', + '체류 중 나눈 동료 대화를 기록했습니다.' + ) + : informationComplete + ? completedEntry( + `city-information-complete-${cityStay.id}`, + '현지 정보 확보', + '현재 성내에서 확인한 정보를 기록했습니다.' + ) + : defaultRecentCompleted(state); + + return buildSnapshot({ + contextKind: 'city-stay', + contextTitle: `${cityStay.city.name} 체류 기록`, + current, + recentCompleted, + nextClue, + progress: knownProgress( + completedCount, + 2, + `주요 체류 활동 ${completedCount} / 2 · 시장은 선택` + ) + }); +} + +function resolvePrologueVillageJournal( + state: CampaignState +): CampaignObjectiveJournalSnapshot { + const completed = new Set(state.completedTutorialIds); + const requiredCompleted = prologueVillageRequiredObjectiveIds.filter( + (objectiveId) => completed.has(villageTutorialIdByObjectiveId[objectiveId]) + ); + const oathComplete = completed.has( + prologueVillageCampaignTutorialIds.complete + ); + const completedCount = requiredCompleted.length + Number(oathComplete); + const nextObjective = oathComplete + ? undefined + : prologueVillageObjectiveDefinitions.find((definition) => { + if (definition.id === 'make-oath') { + return requiredCompleted.length === + prologueVillageRequiredObjectiveIds.length; + } + const tutorialId = + villageTutorialIdByObjectiveId[definition.id]; + const prerequisiteComplete = + !definition.prerequisiteId || + completed.has( + villageTutorialIdByObjectiveId[definition.prerequisiteId] + ); + return !completed.has(tutorialId) && prerequisiteComplete; + }); + + const current = nextObjective + ? activeEntry( + `village-${nextObjective.id}`, + nextObjective.shortLabel, + nextObjective.label, + nextObjective.location + ) + : activeEntry( + 'village-story-transition', + '결의 뒤 이야기를 확인하십시오', + '현재 대화를 마쳐 세 사람의 결의를 기록하십시오.' + ); + const nextClue = nextObjective + ? clueEntry( + `village-clue-${nextObjective.id}`, + nextObjective.location, + `${nextObjective.location}으로 이동해 표시된 대상과 상호작용하십시오.` + ) + : clueEntry( + 'village-story-transition-clue', + '이야기 계속하기', + '현재 대화를 끝까지 진행하면 다음 즉시 행동이 열립니다.' + ); + + return buildSnapshot({ + contextKind: 'village-exploration', + contextTitle: '탁현에서 모인 뜻', + current, + recentCompleted: recentVillageCompletion(state), + nextClue, + progress: knownProgress( + completedCount, + 4, + `마을 목표 ${completedCount} / 4` + ) + }); +} + +function resolvePrologueMilitiaCampJournal( + state: CampaignState +): CampaignObjectiveJournalSnapshot { + const completed = new Set(state.completedTutorialIds); + const requiredCompleted = + prologueMilitiaCampRequiredObjectiveIds.filter((objectiveId) => + completed.has(militiaCampTutorialIdByObjectiveId[objectiveId]) + ); + const commandComplete = completed.has( + prologueMilitiaCampCampaignTutorialIds.complete + ); + const completedCount = requiredCompleted.length + Number(commandComplete); + const nextObjective = commandComplete + ? undefined + : requiredCompleted.length < + prologueMilitiaCampRequiredObjectiveIds.length + ? prologueMilitiaCampObjectiveDefinitions.find( + (definition) => + definition.id !== 'receive-command' && + !completed.has( + militiaCampTutorialIdByObjectiveId[definition.id] + ) + ) + : prologueMilitiaCampObjectiveDefinitions.find( + (definition) => definition.id === 'receive-command' + ); + + const current = nextObjective + ? activeEntry( + `militia-camp-${nextObjective.id}`, + nextObjective.shortLabel, + nextObjective.label, + nextObjective.location + ) + : activeEntry( + 'militia-camp-story-transition', + '출정 이야기를 확인하십시오', + '현재 대화를 마치면 확정한 군령으로 전장에 들어갑니다.' + ); + const nextClue = nextObjective + ? clueEntry( + `militia-camp-clue-${nextObjective.id}`, + nextObjective.location, + `${nextObjective.location}으로 이동해 표시된 대상과 상호작용하십시오.` + ) + : clueEntry( + 'militia-camp-story-transition-clue', + '출정 이야기 계속하기', + '현재 대화를 끝까지 진행하면 전장 안에서 전술 목표가 공개됩니다.' + ); + + return buildSnapshot({ + contextKind: 'militia-camp-exploration', + contextTitle: '탁현 의용군 막사', + current, + recentCompleted: recentMilitiaCampCompletion(state), + nextClue, + progress: knownProgress( + completedCount, + 4, + `첫 출전 준비 ${completedCount} / 4` + ) + }); +} + +function resolveBattlePreparationJournal( + state: CampaignState, + battleId: keyof typeof battleScenarios +): CampaignObjectiveJournalSnapshot { + const scenario = battleScenarios[battleId]; + const recentCompleted = + state.step === 'first-battle' && + state.completedTutorialIds.includes( + prologueMilitiaCampCampaignTutorialIds.complete + ) + ? completedEntry( + 'first-sortie-command', + '첫 출전 군령 확정', + '의용군 막사에서 현재 출전의 준비를 마쳤습니다.' + ) + : defaultRecentCompleted(state); + + return buildSnapshot({ + contextKind: 'battle-preparation', + contextTitle: '현재 전투', + current: activeEntry( + `current-battle-${battleId}`, + `${scenario.title} 진행`, + '출진 준비를 확인하고 현재 전투를 시작하거나 이어가십시오.' + ), + recentCompleted, + nextClue: clueEntry( + `current-battle-clue-${battleId}`, + '현재 출진 준비 확인', + state.selectedSortieUnitIds.length > 0 + ? '선택한 무장과 배치를 다시 확인한 뒤 현재 전장을 여십시오.' + : '출진 준비에서 함께할 무장과 배치를 확인하십시오.' + ), + progress: knownProgress(0, 1, '현재 전투 진행 중') + }); +} + +function resolveFirstCampInterludeJournal( + state: CampaignState +): CampaignObjectiveJournalSnapshot { + const visitComplete = state.completedCampVisits.includes( + firstPursuitScoutVisitId + ); + if (!visitComplete) { + return buildSnapshot({ + contextKind: 'interlude-exploration', + contextTitle: '첫 승리 뒤 군영', + current: activeEntry( + 'first-camp-scout-visit', + '북문 정찰 기록을 확인하십시오', + '군영의 북문 정찰막에서 현재 확인된 두 갈래 길을 살피십시오.', + firstPursuitScoutVisitDefinition.location + ), + recentCompleted: defaultRecentCompleted(state), + nextClue: clueEntry( + 'first-camp-scout-visit-clue', + '정찰막의 간옹 찾기', + '간옹에게 다가가 두 정찰 방향 가운데 하나를 현재 기록에 남기십시오.' + ), + progress: knownProgress(0, 1, '선택 막간 탐색 0 / 1') + }); + } + + const choice = getFirstPursuitScoutVisitChoice( + state.campVisitChoiceIds[firstPursuitScoutVisitId] ?? '' + ); + const { current, nextClue } = genericCampAction(state); + return buildSnapshot({ + contextKind: 'interlude-exploration', + contextTitle: '첫 승리 뒤 군영', + current, + recentCompleted: completedEntry( + 'first-camp-scout-visit-complete', + choice?.label ?? '북문 정찰 기록', + '현재 군영에서 확인한 정찰 방향을 출진 기록에 남겼습니다.' + ), + nextClue, + progress: knownProgress(1, 1, '선택 막간 탐색 1 / 1') + }); +} + +function resolveSecondCampInterludeJournal( + state: CampaignState +): CampaignObjectiveJournalSnapshot { + const completedObjectiveIds = + completedSecondBattleReliefObjectiveIds( + state.completedCampVisits + ); + const visitComplete = state.completedCampVisits.includes( + secondBattleReliefVisitId + ); + const completedSet = new Set(completedObjectiveIds); + const nextObjective = visitComplete + ? undefined + : [ + 'reassure-northern-village', + 'question-ferry-witness', + 'inspect-messenger-trail', + 'set-guangzong-priority' + ] + .map((objectiveId) => getSecondBattleReliefObjective(objectiveId)) + .find( + (objective) => + objective && + !completedSet.has(objective.id) && + objective.prerequisiteObjectiveIds.every( + (prerequisiteId) => completedSet.has(prerequisiteId) + ) + ); + + const generic = genericCampAction(state); + const current = nextObjective + ? activeEntry( + `second-camp-${nextObjective.id}`, + nextObjective.id === 'set-guangzong-priority' + ? '간옹과 다음 출정의 우선 방침을 정하십시오' + : nextObjective.label, + nextObjective.id === 'set-guangzong-priority' + ? '모은 현장 기록을 간옹에게 전하고 지금 출정에 반영할 기준을 하나 정하십시오.' + : nextObjective.detail + ) + : generic.current; + const nextClue = nextObjective + ? clueEntry( + `second-camp-clue-${nextObjective.id}`, + '현재 수습 대상 찾기', + nextObjective.id === 'set-guangzong-priority' + ? '모든 현장 기록을 확인했다면 간옹에게 돌아가 현재 방침을 정하십시오.' + : nextObjective.detail + ) + : generic.nextClue; + + return buildSnapshot({ + contextKind: 'interlude-exploration', + contextTitle: '전투 뒤 현장 수습', + current, + recentCompleted: recentSecondCampCompletion( + state, + completedObjectiveIds, + visitComplete + ), + nextClue, + progress: knownProgress( + visitComplete ? 4 : completedObjectiveIds.length, + 4, + `현장 수습 ${visitComplete ? 4 : completedObjectiveIds.length} / 4` + ) + }); +} + +function resolveThirdCampInterludeJournal( + state: CampaignState +): CampaignObjectiveJournalSnapshot { + const definition = resolveThirdCampExplorationDefinition(state); + if (!definition) { + return resolveGenericCampJournal(state); + } + + const completedActivityIds = + completedThirdCampExplorationActivityIds( + state.completedCampVisits + ); + const completedSet = new Set(completedActivityIds); + const nextActivity = definition.activities.find( + (activity) => !completedSet.has(activity.id) + ); + const generic = genericCampAction(state); + const current = nextActivity + ? activeEntry( + `third-camp-${nextActivity.id}`, + '원하는 출진 준비를 살피거나 군영으로 돌아가십시오', + '이 막간의 세 준비 활동은 모두 선택이며 원하는 순서로 확인할 수 있습니다.', + definition.location + ) + : generic.current; + const nextClue = nextActivity + ? clueEntry( + `third-camp-clue-${nextActivity.id}`, + `${nextActivity.shortLabel} 살펴보기`, + safeThirdCampClues[nextActivity.id] + ) + : generic.nextClue; + + return buildSnapshot({ + contextKind: 'interlude-exploration', + contextTitle: '광종 동림 출진 야영지', + current, + recentCompleted: recentThirdCampCompletion( + state, + definition.activities, + completedActivityIds + ), + nextClue, + progress: knownProgress( + completedActivityIds.length, + definition.activities.length, + `선택 준비 ${completedActivityIds.length} / ${definition.activities.length}` + ) + }); +} + +function resolveGenericCampJournal( + state: CampaignState +): CampaignObjectiveJournalSnapshot { + const { current, nextClue } = genericCampAction(state); + return buildSnapshot({ + contextKind: 'camp', + contextTitle: '군영 진군 기록', + current, + recentCompleted: defaultRecentCompleted(state), + nextClue, + progress: campaignVictoryProgress(state) + }); +} + +function genericCampAction(state: CampaignState) { + const hasSortieDestination = Boolean( + getSortieFlow(state.latestBattleId, state.step).nextBattleId + ); + return { + current: activeEntry( + `camp-current-${state.step}`, + '군영에서 전후 정리와 출진 준비를 확인하십시오', + hasSortieDestination + ? '보고·대화·편성을 원하는 만큼 살핀 뒤 출진 장부에서 현재 행동을 확정하십시오.' + : '현재 군영에 열린 보고와 대화를 확인하십시오.' + ), + nextClue: clueEntry( + `camp-clue-${state.step}`, + hasSortieDestination ? '출진 장부 확인' : '현재 군영 기록 확인', + hasSortieDestination + ? '군영 활동은 선택입니다. 준비가 되면 출진 장부로 이동하십시오.' + : '표시된 현재 군영 활동만 확인하면 됩니다.' + ) + }; +} + +function recentVillageCompletion( + state: CampaignState +): CampaignObjectiveJournalEntry { + const relevantTutorialIds = new Set([ + ...Object.values(villageTutorialIdByObjectiveId), + prologueVillageCampaignTutorialIds.complete + ]); + const recentTutorialId = [...state.completedTutorialIds] + .reverse() + .find((tutorialId) => relevantTutorialIds.has(tutorialId)); + + if (recentTutorialId === prologueVillageCampaignTutorialIds.complete) { + return completedEntry( + 'village-oath-complete', + '도원결의', + '세 사람의 결의를 현재 기록에 남겼습니다.' + ); + } + const objectiveId = prologueVillageRequiredObjectiveIds.find( + (candidate) => + villageTutorialIdByObjectiveId[candidate] === recentTutorialId + ); + const definition = objectiveId + ? prologueVillageObjectiveDefinitions.find( + (candidate) => candidate.id === objectiveId + ) + : undefined; + return definition + ? completedEntry( + `village-complete-${definition.id}`, + definition.shortLabel, + `${definition.location}에서 현재 만남을 마쳤습니다.` + ) + : completedEntry( + 'campaign-started', + '새 캠페인 시작', + '탁현의 첫 이야기를 확인할 준비를 마쳤습니다.' + ); +} + +function recentMilitiaCampCompletion( + state: CampaignState +): CampaignObjectiveJournalEntry { + const relevantTutorialIds = new Set([ + ...Object.values(militiaCampTutorialIdByObjectiveId), + prologueMilitiaCampCampaignTutorialIds.complete + ]); + const recentTutorialId = [...state.completedTutorialIds] + .reverse() + .find((tutorialId) => relevantTutorialIds.has(tutorialId)); + + if ( + recentTutorialId === + prologueMilitiaCampCampaignTutorialIds.complete + ) { + return completedEntry( + 'militia-camp-command-complete', + '첫 출전 군령', + '현재 출전에 적용할 군령을 확정했습니다.' + ); + } + const objectiveId = + prologueMilitiaCampRequiredObjectiveIds.find( + (candidate) => + militiaCampTutorialIdByObjectiveId[candidate] === + recentTutorialId + ); + const definition = objectiveId + ? prologueMilitiaCampObjectiveDefinitions.find( + (candidate) => candidate.id === objectiveId + ) + : undefined; + return definition + ? completedEntry( + `militia-camp-complete-${definition.id}`, + definition.shortLabel, + `${definition.location}에서 현재 점검을 마쳤습니다.` + ) + : completedEntry( + 'village-oath-complete', + '도원결의', + '세 사람의 결의를 마치고 의용군 막사에 들어왔습니다.' + ); +} + +function recentSecondCampCompletion( + state: CampaignState, + completedObjectiveIds: readonly string[], + visitComplete: boolean +): CampaignObjectiveJournalEntry { + if (visitComplete) { + const choice = getSecondBattleReliefChoice( + state.campVisitChoiceIds[secondBattleReliefVisitId] ?? '' + ); + return completedEntry( + 'second-camp-relief-complete', + choice?.label ?? '현장 수습 방침', + '확인한 현장과 현재 출정 방침을 하나의 기록으로 남겼습니다.' + ); + } + + const completedSet = new Set(completedObjectiveIds); + const recentMarker = [...state.completedCampVisits] + .reverse() + .find((marker) => + completedObjectiveIds.some( + (objectiveId) => + marker === + secondBattleReliefObjectiveProgressId( + objectiveId as Parameters< + typeof secondBattleReliefObjectiveProgressId + >[0] + ) + ) + ); + const recentObjective = completedObjectiveIds + .map((objectiveId) => getSecondBattleReliefObjective(objectiveId)) + .find( + (objective) => + objective && + completedSet.has(objective.id) && + recentMarker === + secondBattleReliefObjectiveProgressId(objective.id) + ); + return recentObjective + ? completedEntry( + `second-camp-complete-${recentObjective.id}`, + recentObjective.label, + '현재 현장에서 확인한 내용을 수습 기록에 남겼습니다.' + ) + : defaultRecentCompleted(state); +} + +function recentThirdCampCompletion( + state: CampaignState, + activities: readonly { + id: ThirdCampExplorationActivityId; + shortLabel: string; + completionLine: string; + }[], + completedActivityIds: readonly ThirdCampExplorationActivityId[] +): CampaignObjectiveJournalEntry { + const completedSet = new Set(completedActivityIds); + const recentMarker = [...state.completedCampVisits] + .reverse() + .find((marker) => + completedActivityIds.some( + (activityId) => + marker === + thirdCampExplorationActivityProgressId(activityId) + ) + ); + const recentActivity = activities.find( + (activity) => + completedSet.has(activity.id) && + recentMarker === + thirdCampExplorationActivityProgressId(activity.id) + ); + return recentActivity + ? completedEntry( + `third-camp-complete-${recentActivity.id}`, + recentActivity.shortLabel, + recentActivity.completionLine + ) + : defaultRecentCompleted(state); +} + +function defaultRecentCompleted( + state: CampaignState +): CampaignObjectiveJournalEntry { + const settlement = latestSettlement(state); + if (settlement) { + return settlementCompletedEntry(settlement); + } + if (state.firstBattleReport) { + return completedEntry( + `battle-report-${state.firstBattleReport.battleId}`, + `${state.firstBattleReport.battleTitle} 전투 기록`, + state.firstBattleReport.outcome === 'victory' + ? '최근 전투 결과가 현재 진군 기록에 남았습니다.' + : '직전 전투 시도의 결과를 확인했습니다.' + ); + } + return completedEntry( + 'campaign-started', + '새 캠페인 시작', + '현재 여정의 첫 기록을 열었습니다.' + ); +} + +function latestSettlement( + state: CampaignState +): CampaignBattleSettlement | undefined { + const latest = state.latestBattleId + ? state.battleHistory[state.latestBattleId] + : undefined; + if (latest) { + return latest; + } + return Object.values(state.battleHistory).sort((left, right) => + String(right.completedAt).localeCompare(String(left.completedAt)) + )[0]; +} + +function settlementCompletedEntry( + settlement: CampaignBattleSettlement +): CampaignObjectiveJournalEntry { + return completedEntry( + `battle-settlement-${settlement.battleId}`, + settlement.outcome === 'victory' + ? `${settlement.battleTitle} 전투 마침` + : `${settlement.battleTitle} 전투 기록`, + settlement.outcome === 'victory' + ? '최근 전투 결과가 현재 진군 기록에 남았습니다.' + : '직전 전투 시도의 결과를 확인했습니다.' + ); +} + +function campaignVictoryProgress( + state: CampaignState +): CampaignObjectiveJournalProgress { + const victories = Object.values(state.battleHistory).filter( + (settlement) => settlement.outcome === 'victory' + ).length; + return openProgress( + victories, + `완료한 전투 기록 ${victories}회` + ); +} + +function activeEntry( + id: string, + title: string, + detail: string, + location?: string +): CampaignObjectiveJournalEntry { + return journalEntry(id, 'current', title, detail, location); +} + +function completedEntry( + id: string, + title: string, + detail: string, + location?: string +): CampaignObjectiveJournalEntry { + return journalEntry(id, 'completed', title, detail, location); +} + +function clueEntry( + id: string, + title: string, + detail: string, + location?: string +): CampaignObjectiveJournalEntry { + return journalEntry(id, 'clue', title, detail, location); +} + +function journalEntry( + id: string, + status: CampaignObjectiveJournalStatus, + title: string, + detail: string, + location?: string +): CampaignObjectiveJournalEntry { + const entry = { + id, + status, + statusLabel: campaignObjectiveJournalStatusLabels[status], + title, + detail + }; + return location + ? { ...entry, location } + : entry; +} + +function knownProgress( + completed: number, + total: number, + label: string +): CampaignObjectiveJournalProgress { + return { + completed: Math.max(0, Math.min(completed, total)), + total, + knownTotal: true, + label + }; +} + +function openProgress( + completed: number, + label: string +): CampaignObjectiveJournalProgress { + return { + completed: Math.max(0, completed), + knownTotal: false, + label + }; +} + +function buildSnapshot( + parts: SnapshotParts +): CampaignObjectiveJournalSnapshot { + const lines = [ + `${parts.recentCompleted.statusLabel} ${parts.recentCompleted.title}`, + `${parts.current.statusLabel} ${parts.current.title}`, + `${parts.nextClue.statusLabel} ${parts.nextClue.detail}` + ] as const; + return { + contextKind: parts.contextKind, + current: parts.current, + recentCompleted: parts.recentCompleted, + nextClue: parts.nextClue, + progress: parts.progress, + resumeContext: { + title: `진군 기록 · ${parts.contextTitle}`, + summary: `${parts.recentCompleted.title}. ${parts.current.title}`, + lines + } + }; +} diff --git a/src/game/ui/CampaignObjectiveJournalOverlay.ts b/src/game/ui/CampaignObjectiveJournalOverlay.ts new file mode 100644 index 0000000..4f50213 --- /dev/null +++ b/src/game/ui/CampaignObjectiveJournalOverlay.ts @@ -0,0 +1,942 @@ +import type Phaser from 'phaser'; + +const viewportWidth = 1920; +const viewportHeight = 1080; +const overlayDepth = 5100; +const buttonDepth = 5001; +const panelWidth = 1120; +const panelHeight = 680; +const panelX = (viewportWidth - panelWidth) / 2; +const panelY = (viewportHeight - panelHeight) / 2; +const buttonWidth = 240; +const buttonHeight = 50; +const cardX = panelX + 44; +const cardWidth = panelWidth - 88; +const cardHeight = 140; + +const fontFamily = '"Malgun Gothic", "Noto Sans KR", sans-serif'; + +type PointerEventData = { + stopPropagation: () => void; +}; + +export type CampaignObjectiveJournalStatus = + | 'current' + | 'completed' + | 'clue' + | 'locked' + | 'warning' + | 'neutral' + | (string & {}); + +export interface CampaignObjectiveJournalEntry { + status: CampaignObjectiveJournalStatus; + statusLabel: string; + title: string; + detail: string; + location?: string; +} + +export interface CampaignObjectiveJournalProgress { + label?: string; + title?: string; + detail?: string; + statusLabel?: string; + current?: number; + completed?: number; + total?: number; + [key: string]: unknown; +} + +export interface CampaignObjectiveJournalResumeContext { + title?: string; + summary?: string; + lines?: readonly string[]; + detail?: string; + previousEvent?: string; + currentReason?: string; + [key: string]: unknown; +} + +export interface CampaignObjectiveJournalSnapshot { + contextKind: string; + current: CampaignObjectiveJournalEntry | null; + recentCompleted: CampaignObjectiveJournalEntry | null; + nextClue: CampaignObjectiveJournalEntry | null; + progress?: CampaignObjectiveJournalProgress | string | number | null; + resumeContext?: + | CampaignObjectiveJournalResumeContext + | string + | null; +} + +export interface CampaignObjectiveJournalOverlayOptions { + snapshotProvider: () => CampaignObjectiveJournalSnapshot; + canOpen?: boolean | (() => boolean); + buttonX?: number; + buttonY?: number; +} + +export interface CampaignObjectiveJournalBounds { + x: number; + y: number; + width: number; + height: number; + right: number; + bottom: number; +} + +export type CampaignObjectiveJournalCardKind = + | 'current' + | 'completed' + | 'nextClue'; + +export interface CampaignObjectiveJournalCardDebugState { + kind: CampaignObjectiveJournalCardKind; + label: string; + title: string; + body: string; + bounds: CampaignObjectiveJournalBounds; + titleBounds: CampaignObjectiveJournalBounds; + bodyBounds: CampaignObjectiveJournalBounds; +} + +export interface CampaignObjectiveJournalDebugState { + visible: boolean; + shortcut: 'J'; + objectCount: number; + buttonBounds: CampaignObjectiveJournalBounds; + panelBounds: CampaignObjectiveJournalBounds | null; + closeBounds: CampaignObjectiveJournalBounds | null; + cards: CampaignObjectiveJournalCardDebugState[]; + visibleText: string[]; +} + +type CardPresentation = { + kind: CampaignObjectiveJournalCardKind; + label: string; + entry: CampaignObjectiveJournalEntry; + fill: number; + stroke: number; + labelColor: string; +}; + +type CardView = { + kind: CampaignObjectiveJournalCardKind; + label: string; + title: string; + body: string; + background: Phaser.GameObjects.Rectangle; + titleText: Phaser.GameObjects.Text; + bodyText: Phaser.GameObjects.Text; +}; + +const fallbackEntries: Record< + CampaignObjectiveJournalCardKind, + CampaignObjectiveJournalEntry +> = { + current: { + status: 'neutral', + statusLabel: '확인 중', + title: '현재 목표를 확인하는 중입니다', + detail: '장면을 이어 진행하면 즉시 할 일이 갱신됩니다.' + }, + completed: { + status: 'neutral', + statusLabel: '기록 없음', + title: '아직 마친 기록이 없습니다', + detail: '첫 목표를 완료하면 가장 최근 기록이 이곳에 남습니다.' + }, + nextClue: { + status: 'locked', + statusLabel: '미공개', + title: '다음 실마리는 아직 드러나지 않았습니다', + detail: '현재 목표를 진행하면 공개된 정보만 바탕으로 갱신됩니다.' + } +}; + +/** + * 1920×1080 데스크톱 장면에서 공통으로 사용하는 캠페인 목표 기록 오버레이입니다. + * + * 키 입력은 의도적으로 등록하지 않습니다. 소유 장면이 J/Esc 입력을 받아 + * toggle/open/close를 호출해야 장면별 키 충돌과 모달 우선순위를 제어할 수 있습니다. + */ +export class CampaignObjectiveJournalOverlay { + private readonly scene: Phaser.Scene; + private readonly options: CampaignObjectiveJournalOverlayOptions; + private readonly buttonX: number; + private readonly buttonY: number; + private readonly buttonRoot: Phaser.GameObjects.Container; + private readonly buttonBackground: Phaser.GameObjects.Rectangle; + private readonly buttonLabel: Phaser.GameObjects.Text; + private overlayRoot?: Phaser.GameObjects.Container; + private panelBackground?: Phaser.GameObjects.Rectangle; + private closeBackground?: Phaser.GameObjects.Rectangle; + private overlayTexts: Phaser.GameObjects.Text[] = []; + private cardViews: CardView[] = []; + private destroyed = false; + + private readonly handleSceneEnd = () => { + this.destroy(); + }; + + constructor( + scene: Phaser.Scene, + options: CampaignObjectiveJournalOverlayOptions + ) { + this.scene = scene; + this.options = options; + this.buttonX = options.buttonX ?? 1325; + this.buttonY = options.buttonY ?? 44; + + this.buttonRoot = scene.add + .container(0, 0) + .setDepth(buttonDepth) + .setScrollFactor(0); + + const buttonShadow = scene.add + .rectangle( + this.buttonX + 4, + this.buttonY + 5, + buttonWidth, + buttonHeight, + 0x05080d, + 0.62 + ) + .setOrigin(0); + this.buttonRoot.add(buttonShadow); + + this.buttonBackground = scene.add + .rectangle( + this.buttonX, + this.buttonY, + buttonWidth, + buttonHeight, + 0x162331, + 0.97 + ) + .setOrigin(0) + .setStrokeStyle(2, 0xd8b15f, 0.92) + .setInteractive({ useHandCursor: true }); + this.buttonRoot.add(this.buttonBackground); + + const buttonAccent = scene.add + .rectangle(this.buttonX, this.buttonY, 7, buttonHeight, 0xd8b15f, 1) + .setOrigin(0); + this.buttonRoot.add(buttonAccent); + + this.buttonLabel = scene.add + .text( + this.buttonX + buttonWidth / 2, + this.buttonY + buttonHeight / 2, + 'J 진군 기록', + { + fontFamily, + fontSize: '21px', + color: '#f4e7c7', + fontStyle: 'bold', + align: 'center' + } + ) + .setOrigin(0.5); + this.buttonRoot.add(this.buttonLabel); + + this.buttonBackground.on('pointerover', () => { + const enabled = this.canOpen(); + this.buttonBackground.setFillStyle( + enabled ? 0x24384b : 0x1a2028, + enabled ? 1 : 0.84 + ); + this.buttonLabel.setColor(enabled ? '#fff2cf' : '#8f969f'); + }); + this.buttonBackground.on('pointerout', () => { + this.refreshButtonPresentation(); + }); + this.buttonBackground.on( + 'pointerdown', + ( + _pointer: Phaser.Input.Pointer, + _localX: number, + _localY: number, + event: PointerEventData + ) => { + event.stopPropagation(); + if (this.canOpen()) { + this.toggle(); + } + } + ); + + this.refreshButtonPresentation(); + scene.events.once('shutdown', this.handleSceneEnd); + scene.events.once('destroy', this.handleSceneEnd); + } + + toggle(): boolean { + if (this.isOpen()) { + this.close(); + return false; + } + + return this.open(); + } + + open(): boolean { + if (this.destroyed || this.isOpen() || !this.canOpen()) { + return this.isOpen(); + } + + const snapshot = this.options.snapshotProvider(); + this.createOverlay(snapshot); + return true; + } + + close(): boolean { + if (!this.overlayRoot) { + return false; + } + + this.overlayRoot.destroy(true); + this.overlayRoot = undefined; + this.panelBackground = undefined; + this.closeBackground = undefined; + this.overlayTexts = []; + this.cardViews = []; + return true; + } + + isOpen(): boolean { + return Boolean( + this.overlayRoot?.active && + this.overlayRoot.visible && + !this.destroyed + ); + } + + getDebugState(): CampaignObjectiveJournalDebugState { + const visible = this.isOpen(); + const buttonBounds = this.buttonBackground.active + ? snapshotBounds(this.buttonBackground.getBounds()) + : boundsFromValues( + this.buttonX, + this.buttonY, + buttonWidth, + buttonHeight + ); + const visibleText = [ + this.buttonLabel, + ...this.overlayTexts + ] + .filter((text) => text.active && text.visible) + .map((text) => text.text); + + return { + visible, + shortcut: 'J', + objectCount: visible + ? 1 + this.cardViews.length + : 0, + buttonBounds, + panelBounds: + visible && this.panelBackground + ? snapshotBounds(this.panelBackground.getBounds()) + : null, + closeBounds: + visible && this.closeBackground + ? snapshotBounds(this.closeBackground.getBounds()) + : null, + cards: visible + ? this.cardViews.map((card) => ({ + kind: card.kind, + label: card.label, + title: card.title, + body: card.body, + bounds: snapshotBounds(card.background.getBounds()), + titleBounds: snapshotBounds(card.titleText.getBounds()), + bodyBounds: snapshotBounds(card.bodyText.getBounds()) + })) + : [], + visibleText + }; + } + + destroy() { + if (this.destroyed) { + return; + } + + this.destroyed = true; + this.scene.events.off('shutdown', this.handleSceneEnd); + this.scene.events.off('destroy', this.handleSceneEnd); + this.close(); + this.buttonRoot.destroy(true); + } + + private canOpen() { + const predicate = this.options.canOpen; + return typeof predicate === 'function' + ? predicate() + : predicate ?? true; + } + + private refreshButtonPresentation() { + if (!this.buttonBackground.active || !this.buttonLabel.active) { + return; + } + + const enabled = this.canOpen(); + this.buttonBackground.setFillStyle( + enabled ? 0x162331 : 0x1a2028, + enabled ? 0.97 : 0.78 + ); + this.buttonLabel.setColor(enabled ? '#f4e7c7' : '#8f969f'); + this.buttonRoot.setAlpha(enabled ? 1 : 0.78); + } + + private createOverlay(snapshot: CampaignObjectiveJournalSnapshot) { + const root = this.scene.add + .container(0, 0) + .setDepth(overlayDepth) + .setScrollFactor(0); + this.overlayRoot = root; + + const blocker = this.scene.add + .rectangle( + 0, + 0, + viewportWidth, + viewportHeight, + 0x03070c, + 0.76 + ) + .setOrigin(0) + .setInteractive(); + blocker.on( + 'pointerdown', + ( + _pointer: Phaser.Input.Pointer, + _localX: number, + _localY: number, + event: PointerEventData + ) => event.stopPropagation() + ); + blocker.on( + 'wheel', + ( + _pointer: Phaser.Input.Pointer, + _deltaX: number, + _deltaY: number, + _deltaZ: number, + event: PointerEventData + ) => event.stopPropagation() + ); + root.add(blocker); + + const panelShadow = this.scene.add + .rectangle( + panelX + 12, + panelY + 16, + panelWidth, + panelHeight, + 0x000000, + 0.48 + ) + .setOrigin(0); + root.add(panelShadow); + + const panel = this.scene.add + .rectangle( + panelX, + panelY, + panelWidth, + panelHeight, + 0x111923, + 0.995 + ) + .setOrigin(0) + .setStrokeStyle(3, 0xd8b15f, 0.92) + .setInteractive(); + panel.on( + 'pointerdown', + ( + _pointer: Phaser.Input.Pointer, + _localX: number, + _localY: number, + event: PointerEventData + ) => event.stopPropagation() + ); + root.add(panel); + this.panelBackground = panel; + + const topAccent = this.scene.add + .rectangle(panelX, panelY, panelWidth, 8, 0xd8b15f, 0.96) + .setOrigin(0); + root.add(topAccent); + + this.addOverlayText( + root, + panelX + 44, + panelY + 28, + '진군 기록', + { + fontSize: '30px', + color: '#f4e7c7', + fontStyle: 'bold' + } + ); + + this.addOverlayText( + root, + panelX + 230, + panelY + 38, + `진행 맥락 · ${formatContextKind(snapshot.contextKind)}`, + { + fontSize: '18px', + color: '#aebccc' + } + ); + + const close = this.scene.add + .rectangle( + panelX + panelWidth - 140, + panelY + 24, + 96, + 44, + 0x273442, + 0.98 + ) + .setOrigin(0) + .setStrokeStyle(2, 0x8192a5, 0.82) + .setInteractive({ useHandCursor: true }); + close.on('pointerover', () => close.setFillStyle(0x3a4a5b, 1)); + close.on('pointerout', () => close.setFillStyle(0x273442, 0.98)); + close.on( + 'pointerdown', + ( + _pointer: Phaser.Input.Pointer, + _localX: number, + _localY: number, + event: PointerEventData + ) => { + event.stopPropagation(); + this.close(); + } + ); + root.add(close); + this.closeBackground = close; + + const closeLabel = this.addOverlayText( + root, + panelX + panelWidth - 92, + panelY + 46, + '닫기', + { + fontSize: '18px', + color: '#edf2f6', + fontStyle: 'bold', + align: 'center' + } + ).setOrigin(0.5); + + const progressText = formatProgress(snapshot.progress); + this.addOverlayText( + root, + panelX + 44, + panelY + 82, + progressText, + { + fontSize: '20px', + color: '#e4c77f', + fontStyle: 'bold' + } + ); + + const resumeText = formatResumeContext(snapshot.resumeContext); + if (resumeText) { + this.addOverlayText( + root, + panelX + 44, + panelY + 114, + `재개 맥락 · ${compactText(resumeText, 112)}`, + { + fontSize: '18px', + color: '#bcc7d2', + wordWrap: { width: panelWidth - 88, useAdvancedWrap: true }, + maxLines: 2 + } + ); + } + + const divider = this.scene.add + .rectangle( + panelX + 44, + panelY + 158, + panelWidth - 88, + 2, + 0x536276, + 0.7 + ) + .setOrigin(0); + root.add(divider); + + const cards: CardPresentation[] = [ + { + kind: 'current', + label: '◆ 지금 할 일', + entry: snapshot.current ?? fallbackEntries.current, + fill: 0x1b2936, + stroke: 0xd8b15f, + labelColor: '#f0cf82' + }, + { + kind: 'completed', + label: '✓ 마친 일', + entry: + snapshot.recentCompleted ?? fallbackEntries.completed, + fill: 0x182a28, + stroke: 0x70a978, + labelColor: '#9dd0a3' + }, + { + kind: 'nextClue', + label: '? 다음 실마리', + entry: snapshot.nextClue ?? fallbackEntries.nextClue, + fill: 0x192532, + stroke: 0x6f92be, + labelColor: '#a9c6e9' + } + ]; + + cards.forEach((card, index) => { + this.createCard(root, card, panelY + 174 + index * 154); + }); + + this.addOverlayText( + root, + panelX + 44, + panelY + panelHeight - 30, + '공개된 진행 정보만 기록됩니다 · 장면에서 J 또는 Esc로 닫기', + { + fontSize: '18px', + color: '#8f9cab' + } + ).setOrigin(0, 1); + + // The label is intentionally tracked even though it is non-interactive: + // browser QA can assert that the visible close affordance matches its hit box. + void closeLabel; + } + + private createCard( + root: Phaser.GameObjects.Container, + card: CardPresentation, + y: number + ) { + const background = this.scene.add + .rectangle( + cardX, + y, + cardWidth, + cardHeight, + card.fill, + 0.98 + ) + .setOrigin(0) + .setStrokeStyle(2, card.stroke, 0.9); + root.add(background); + + const accent = this.scene.add + .rectangle(cardX, y, 7, cardHeight, card.stroke, 1) + .setOrigin(0); + root.add(accent); + + this.addOverlayText( + root, + cardX + 24, + y + 14, + card.label, + { + fontSize: '20px', + color: card.labelColor, + fontStyle: 'bold' + } + ); + + const locationSuffix = cleanText(card.entry.location); + const statusLabel = + cleanText(card.entry.statusLabel) || + fallbackEntries[card.kind].statusLabel; + this.addOverlayText( + root, + cardX + cardWidth - 304, + y + 14, + compactText( + locationSuffix + ? `${statusLabel} · ${locationSuffix}` + : statusLabel, + 30 + ), + { + fontSize: '18px', + color: statusColor(card.entry.status, card.labelColor), + fontStyle: 'bold', + fixedWidth: 278, + align: 'right' + } + ); + + const title = compactText( + cleanText(card.entry.title) || + fallbackEntries[card.kind].title, + 52 + ); + const body = compactText( + cleanText(card.entry.detail) || + fallbackEntries[card.kind].detail, + 112 + ); + + const titleText = this.addOverlayText( + root, + cardX + 24, + y + 48, + title, + { + fontSize: '24px', + color: '#f1eadb', + fontStyle: 'bold', + fixedWidth: cardWidth - 48, + maxLines: 1 + } + ); + + const bodyText = this.addOverlayText( + root, + cardX + 24, + y + 84, + body, + { + fontSize: '18px', + color: '#c4ced7', + fixedWidth: cardWidth - 48, + wordWrap: { + width: cardWidth - 48, + useAdvancedWrap: true + }, + maxLines: 2, + lineSpacing: 4 + } + ); + + this.cardViews.push({ + kind: card.kind, + label: card.label, + title, + body, + background, + titleText, + bodyText + }); + } + + private addOverlayText( + root: Phaser.GameObjects.Container, + x: number, + y: number, + value: string, + style: Phaser.Types.GameObjects.Text.TextStyle + ) { + const text = this.scene.add.text(x, y, value, { + fontFamily, + ...style + }); + root.add(text); + this.overlayTexts.push(text); + return text; + } +} + +function formatContextKind(contextKind: string) { + const normalized = cleanText(contextKind); + const labels: Record = { + battle: '전장', + camp: '군영', + story: '이야기', + cityStay: '성 체류', + 'city-stay': '성 체류', + 'not-started': '여정 시작 전', + 'village-exploration': '의용군 마을', + 'militia-camp-exploration': '의용군 막사', + 'battle-preparation': '출진 준비', + aftermath: '승리 후 이야기', + 'interlude-exploration': '전투 사이 탐색', + prologueVillage: '의용군 마을', + 'prologue-village': '의용군 마을', + prologueMilitiaCamp: '의용군 막사', + 'prologue-militia-camp': '의용군 막사', + campVisit: '전투 사이 탐색', + 'camp-visit': '전투 사이 탐색', + title: '저장 기록', + ending: '대단원' + }; + + return labels[normalized] ?? (normalized || '캠페인'); +} + +function formatProgress( + progress: + | CampaignObjectiveJournalProgress + | string + | number + | null + | undefined +) { + if (typeof progress === 'string') { + return `전장 진행 · ${compactText(progress, 72)}`; + } + if (typeof progress === 'number' && Number.isFinite(progress)) { + return `전장 진행 · ${progress}`; + } + if (!progress || typeof progress !== 'object') { + return '전장 진행 · 기록 확인 중'; + } + + const label = + firstText( + progress.label, + progress.title, + progress.statusLabel + ) ?? '전장 진행'; + const current = firstFiniteNumber( + progress.current, + progress.completed, + progress.completedBattles, + progress.battle + ); + const total = firstFiniteNumber( + progress.total, + progress.max, + progress.totalBattles + ); + const detail = firstText(progress.detail); + + if (current !== undefined && total !== undefined) { + return `${compactText(label, 44)} · ${current} / ${total}`; + } + if (current !== undefined) { + return `${compactText(label, 54)} · ${current}`; + } + if (detail) { + return `${compactText(label, 34)} · ${compactText(detail, 52)}`; + } + return compactText(label, 72); +} + +function formatResumeContext( + context: + | CampaignObjectiveJournalResumeContext + | string + | null + | undefined +) { + if (typeof context === 'string') { + return cleanText(context); + } + if (!context || typeof context !== 'object') { + return ''; + } + + const parts = [ + firstText(context.title), + firstText(context.summary), + firstText(context.detail), + firstText(context.previousEvent) + ? `지난 기록 ${firstText(context.previousEvent)}` + : undefined, + firstText(context.currentReason) + ? `현재 ${firstText(context.currentReason)}` + : undefined + ].filter((part): part is string => Boolean(part)); + + return Array.from(new Set(parts)).join(' · '); +} + +function statusColor( + status: CampaignObjectiveJournalStatus, + fallbackColor: string +) { + switch (status) { + case 'completed': + return '#a8d6ad'; + case 'warning': + return '#f0b27f'; + case 'locked': + return '#a7b0bb'; + case 'current': + return '#f0cf82'; + case 'clue': + return '#adc9e8'; + default: + return fallbackColor; + } +} + +function firstText(...values: unknown[]) { + for (const value of values) { + const text = cleanText(value); + if (text) { + return text; + } + } + return undefined; +} + +function firstFiniteNumber(...values: unknown[]) { + for (const value of values) { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + } + return undefined; +} + +function cleanText(value: unknown) { + return typeof value === 'string' + ? value.replace(/\s+/g, ' ').trim() + : ''; +} + +function compactText(value: string, limit: number) { + const text = cleanText(value); + if (text.length <= limit) { + return text; + } + return `${text.slice(0, Math.max(1, limit - 1)).trimEnd()}…`; +} + +function snapshotBounds( + bounds: Phaser.Geom.Rectangle +): CampaignObjectiveJournalBounds { + return boundsFromValues( + bounds.x, + bounds.y, + bounds.width, + bounds.height + ); +} + +function boundsFromValues( + x: number, + y: number, + width: number, + height: number +): CampaignObjectiveJournalBounds { + return { + x, + y, + width, + height, + right: x + width, + bottom: y + height + }; +}