diff --git a/package.json b/package.json index 8e03db6..20c1d3c 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "verify:city-equipment-preparation": "node scripts/verify-city-equipment-preparation.mjs", "verify:city-stays:browser": "node scripts/verify-city-stay-browser.mjs", "verify:xuzhou-equipment-link:browser": "node scripts/verify-xuzhou-equipment-link-browser.mjs", + "verify:xuzhou-stay-narrative": "node scripts/verify-xuzhou-stay-narrative-memory.mjs", "verify:xuzhou-stay-payoff:browser": "node scripts/verify-xuzhou-stay-battle-payoff-browser.mjs", "verify:prologue-village": "node scripts/verify-prologue-village-data.mjs", "verify:first-battle-camp-followup": "node scripts/verify-first-battle-camp-followup.mjs", diff --git a/scripts/verify-first-pursuit-camp-exploration-browser.mjs b/scripts/verify-first-pursuit-camp-exploration-browser.mjs index ec8703d..e902c28 100644 --- a/scripts/verify-first-pursuit-camp-exploration-browser.mjs +++ b/scripts/verify-first-pursuit-camp-exploration-browser.mjs @@ -91,8 +91,70 @@ try { ); await page.evaluate(async () => { - await window.__HEROS_DEBUG__.goToCampVisitExploration(); + await window.__HEROS_DEBUG__.goToCamp(); }); + await page.waitForFunction( + ({ expectedVisitId }) => { + const debug = window.__HEROS_DEBUG__; + const camp = debug?.camp?.(); + const reward = + camp?.victoryRewardAcknowledgement; + const guided = reward?.actions?.find( + (action) => action.id === 'guided' + ); + return ( + debug?.activeScenes?.().includes('CampScene') && + camp?.campaign?.step === 'first-camp' && + reward?.visible === true && + reward.actions + .map((action) => action.id) + .join(',') === + 'equipment,supplies,guided,sortie,close' && + guided?.primary === true && + guided?.label === '북문 정찰막 둘러보기' && + guided?.interactive === true && + camp?.sortieCommand?.guidedKind === 'visit' && + camp.sortieCommand.targetId === expectedVisitId && + camp.sortieCommand.label === '정찰막 둘러보기' && + camp.sortieCommand.bypass?.label === '바로 출진' && + camp.sortieCommand.bypass?.interactive === true + ); + }, + { expectedVisitId: visitId }, + { timeout: 90000 } + ); + const guidedCamp = await page.evaluate(() => + window.__HEROS_DEBUG__?.camp?.() + ); + const guidedReward = + guidedCamp.victoryRewardAcknowledgement; + const guidedAction = guidedReward.actions.find( + (action) => action.id === 'guided' + ); + assert.equal( + guidedReward.actions.filter((action) => action.primary) + .length, + 1 + ); + assert.equal( + guidedReward.actions.find( + (action) => action.id === 'sortie' + )?.label, + '바로 출진' + ); + assertBoundsInsideViewport( + guidedAction.bounds, + 'first-camp guided reward action' + ); + await captureStableScreenshot( + page, + 'dist/verification-first-pursuit-camp-guided-reward.png' + ); + await clickNamedSceneBounds( + page, + 'CampScene', + guidedAction.bounds + ); await waitForExplorationReady(page); await page.waitForTimeout(380); @@ -269,7 +331,7 @@ try { ); }, { key: sceneKey, actorId: 'jian-yong' }, - { timeout: 12000 } + { timeout: 30000 } ); exploration = await readExploration(page); assert( @@ -502,7 +564,7 @@ try { ); }, { key: sceneKey, exitId: 'camp-exit' }, - { timeout: 12000 } + { timeout: 30000 } ); await page.keyboard.press('e'); await waitForCampReturn(page); @@ -517,6 +579,16 @@ try { camp.firstPursuitScoutMemory.activeForNextSortie, true ); + assert.notEqual( + camp.sortieCommand.guidedKind, + 'visit', + 'Completing the guided scout visit must advance the primary camp action.' + ); + assert.equal( + camp.sortieCommand.bypass, + null, + 'The direct-sortie bypass must collapse once the seed has no pending guided step.' + ); assertRelevantProgressEqual( savesAfterChoice, await readCampaignSaves(page), @@ -540,7 +612,7 @@ try { console.log( `Verified the first-pursuit camp exploration at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` + `DPR ${desktopBrowserDeviceScaleFactor}: dedicated Jian Yong SD art, fixed NPC positions, keyboard and pointer movement, ` + - 'distance-gated interaction, face portraits, two real choices, one-time resonance/item rewards, duplicate protection, ' + + 'first-victory guided reward routing, distance-gated interaction, face portraits, two real choices, one-time resonance/item rewards, duplicate protection, ' + 'CampScene return, and current/slot-1 save persistence.' ); } finally { @@ -801,6 +873,37 @@ async function clickSceneBounds(page, bounds) { ); } +async function clickNamedSceneBounds(page, requestedSceneKey, bounds) { + const point = await page.evaluate( + ({ key, requestedBounds }) => { + const scene = window.__HEROS_DEBUG__?.scene(key); + const canvas = document.querySelector('canvas'); + const canvasBounds = 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: requestedSceneKey, requestedBounds: bounds } + ); + assert( + point && Number.isFinite(point.x) && Number.isFinite(point.y), + `Unable to map ${requestedSceneKey} bounds ${JSON.stringify(bounds)}.` + ); + await page.mouse.click(point.x, point.y); +} + async function readCampaignSaves(page) { return page.evaluate(() => { const parse = (key) => { diff --git a/scripts/verify-interaction-ux.mjs b/scripts/verify-interaction-ux.mjs index 367a9e5..335b38a 100644 --- a/scripts/verify-interaction-ux.mjs +++ b/scripts/verify-interaction-ux.mjs @@ -49,6 +49,8 @@ try { 'tactical reactions exclude undeployed or defeated officers, the Wolong narrative objectives gate victory, ' + 'long camp timeline titles and victory conditions stay in separate fixed-width columns, ' + 'movement commands stay anchored to the destination, the final ally prompt waits for the command, ' + + 'the all-acted prompt keeps Enter-to-end, early manual turn end discloses unacted allies and overflow threats, ' + + 'safe Enter/Esc cancellation requires explicit keyboard focus before abandoning actions, ' + 'the persistent turn-end action reopens it, and the right-click menu follows the pointer.' ); } finally { @@ -440,6 +442,15 @@ async function verifyBattlePointerFlow(page) { battle?.actedUnitIds?.includes(unitId) ); }, lastAllySetup.unitId); + const automaticTurnPrompt = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt); + assert( + automaticTurnPrompt?.title === '모든 아군의 행동이 종료되었습니다.' && + automaticTurnPrompt.focusedAction === 'primary' && + automaticTurnPrompt.cancelAction === 'secondary' && + automaticTurnPrompt.buttons?.find((button) => button.id === 'primary')?.meaning === 'end-turn' && + automaticTurnPrompt.buttons?.find((button) => button.id === 'secondary')?.meaning === 'review-battlefield', + `The all-acted automatic prompt must retain Enter-to-end and Esc-to-review semantics: ${JSON.stringify(automaticTurnPrompt)}` + ); await clickTurnPromptSecondary(page); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false); @@ -457,8 +468,43 @@ async function verifyBattlePointerFlow(page) { window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === true && window.__HEROS_DEBUG__?.battle()?.turnPromptMode === 'turn-end' )); - await clickTurnPromptSecondary(page); - await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false); + await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + window.__HEROS_INTERACTION_UX__ ??= {}; + window.__HEROS_INTERACTION_UX__.originalAutomaticRunEnemyTurn = scene.runEnemyTurn; + window.__HEROS_INTERACTION_UX__.automaticTurnEnemyRunCount = 0; + scene.runEnemyTurn = async () => { + window.__HEROS_INTERACTION_UX__.automaticTurnEnemyRunCount += 1; + }; + }); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.battle()?.activeFaction === 'enemy' && + window.__HEROS_INTERACTION_UX__?.automaticTurnEnemyRunCount === 1 + )); + const automaticEnterResult = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const interaction = window.__HEROS_INTERACTION_UX__; + scene.runEnemyTurn = interaction.originalAutomaticRunEnemyTurn; + delete interaction.originalAutomaticRunEnemyTurn; + scene.activeFaction = 'ally'; + scene.phase = 'idle'; + scene.suppressNextLeftClick = false; + scene.refreshEnemyIntentForecast(); + scene.refreshUnitLegibilityStyles(); + scene.updateTurnText(); + scene.renderSituationPanel('자동 턴 종료 Enter 검증 완료'); + return { + enemyRunCount: interaction.automaticTurnEnemyRunCount, + battle: window.__HEROS_DEBUG__?.battle() + }; + }); + assert( + automaticEnterResult.enemyRunCount === 1 && + automaticEnterResult.battle?.activeFaction === 'ally' && + automaticEnterResult.battle.turnPromptVisible === false, + `Enter on the all-acted automatic prompt must still end the allied turn: ${JSON.stringify(automaticEnterResult)}` + ); const mapMenuPointer = await findRightmostEmptyBattleTile(page); await page.mouse.click(mapMenuPointer.x, mapMenuPointer.y, { button: 'right' }); @@ -567,6 +613,233 @@ async function verifyBattlePointerFlow(page) { } })}` ); + await verifyEarlyTurnEndSafety(page); +} + +async function verifyEarlyTurnEndSafety(page) { + const setup = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const units = scene?.debugBattleUnits?.() ?? []; + const allies = units.filter((unit) => unit.faction === 'ally' && unit.hp > 0); + const enemies = units.filter((unit) => unit.faction === 'enemy' && unit.hp > 0); + if (!scene || allies.length < 1 || enemies.length < 3) { + return null; + } + + scene.hideBattleEventBanner(); + scene.hideTurnEndPrompt(); + scene.hideSaveSlotPanel(); + scene.hideMapMenu(); + scene.hideCommandMenu(); + scene.clearMarkers(); + scene.activeFaction = 'ally'; + scene.phase = 'idle'; + scene.selectedUnit = undefined; + scene.pendingMove = undefined; + scene.targetingAction = undefined; + scene.selectedUsable = undefined; + scene.actedUnitIds.clear(); + scene.clearEnemyIntentForecast(); + + const threatTargets = Array.from({ length: 3 }, (_, index) => { + const ally = allies[index]; + return ally ?? { + ...allies[0], + id: `qa-overflow-threat-target-${index + 1}`, + name: `추가 위협 대상 ${index + 1}` + }; + }); + threatTargets.forEach((ally, index) => { + const enemy = enemies[index]; + const plan = scene.buildEnemyActionPlan(enemy); + scene.enemyIntentForecasts.set(enemy.id, { + ...plan, + kind: 'attack', + target: ally, + destination: { x: enemy.x, y: enemy.y }, + preview: { + ...(plan.preview ?? {}), + damage: Math.max(1, plan.preview?.damage ?? 1), + hitRate: plan.preview?.hitRate ?? 100, + criticalRate: plan.preview?.criticalRate ?? 0 + } + }); + }); + scene.renderSituationPanel('조기 턴 종료 안전성 검증'); + + window.__HEROS_INTERACTION_UX__ ??= {}; + window.__HEROS_INTERACTION_UX__.originalRunEnemyTurn = scene.runEnemyTurn; + window.__HEROS_INTERACTION_UX__.earlyTurnEnemyRunCount = 0; + scene.runEnemyTurn = async () => { + window.__HEROS_INTERACTION_UX__.earlyTurnEnemyRunCount += 1; + }; + + return { + allyIds: allies.map((unit) => unit.id), + allyNames: allies.map((unit) => unit.name), + forcedThreatTargetCount: new Set( + Array.from(scene.enemyIntentForecasts.values()).map((plan) => plan.target?.id).filter(Boolean) + ).size + }; + }); + assert( + setup?.allyIds.length >= 1 && setup.forcedThreatTargetCount === 3, + `Expected unacted allies with three distinct forced threats: ${JSON.stringify(setup)}` + ); + + const initialPrompt = await openManualTurnEndPrompt(page); + assertEarlyTurnPrompt(initialPrompt, setup, 'initial'); + await page.screenshot({ path: 'dist/verification-interaction-ux-early-turn-end.png', fullPage: true }); + + await page.keyboard.press('Enter'); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false); + const afterSafeEnter = await page.evaluate(() => ({ + battle: window.__HEROS_DEBUG__?.battle(), + enemyRunCount: window.__HEROS_INTERACTION_UX__?.earlyTurnEnemyRunCount ?? -1 + })); + assert( + afterSafeEnter.battle?.activeFaction === 'ally' && + afterSafeEnter.battle.actedUnitIds?.length === 0 && + afterSafeEnter.enemyRunCount === 0, + `Enter on the safe default must only close the early turn-end prompt: ${JSON.stringify(afterSafeEnter)}` + ); + + await openManualTurnEndPrompt(page); + await page.keyboard.press('Escape'); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false); + const afterEscape = await page.evaluate(() => ({ + battle: window.__HEROS_DEBUG__?.battle(), + enemyRunCount: window.__HEROS_INTERACTION_UX__?.earlyTurnEnemyRunCount ?? -1 + })); + assert( + afterEscape.battle?.activeFaction === 'ally' && afterEscape.enemyRunCount === 0, + `Escape must always cancel early turn end: ${JSON.stringify(afterEscape)}` + ); + + await openManualTurnEndPrompt(page); + await page.keyboard.press('ArrowRight'); + const afterRight = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt); + assert( + afterRight?.focusedAction === 'secondary' && + afterRight.buttons?.find((button) => button.id === 'secondary')?.dangerous === true, + `ArrowRight must explicitly focus the dangerous early turn-end action: ${JSON.stringify(afterRight)}` + ); + await page.keyboard.press('ArrowLeft'); + const afterLeft = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt); + assert( + afterLeft?.focusedAction === 'primary', + `ArrowLeft must restore the safe early turn-end action: ${JSON.stringify(afterLeft)}` + ); + await page.keyboard.press('Tab'); + const afterTab = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt); + assert( + afterTab?.focusedAction === 'secondary', + `Tab must explicitly focus the dangerous early turn-end action: ${JSON.stringify(afterTab)}` + ); + // Let Phaser observe the keyup and render the new focus before the next synthetic key press. + // Real keyboard input naturally has this spacing; without it Chromium can rarely coalesce the sequence. + await page.waitForTimeout(80); + await page.keyboard.press('Enter'); + try { + await page.waitForFunction(() => ( + window.__HEROS_DEBUG__?.battle()?.activeFaction === 'enemy' && + window.__HEROS_INTERACTION_UX__?.earlyTurnEnemyRunCount === 1 + ), undefined, { timeout: 5000 }); + } catch (error) { + const stalledState = await page.evaluate(() => { + const battle = window.__HEROS_DEBUG__?.battle(); + return { + activeElement: document.activeElement?.tagName ?? null, + documentHasFocus: document.hasFocus(), + activeFaction: battle?.activeFaction ?? null, + phase: battle?.phase ?? null, + battleOutcome: battle?.battleOutcome ?? null, + turnPromptVisible: battle?.turnPromptVisible ?? null, + turnPrompt: battle?.turnPrompt ?? null, + battleEventVisible: battle?.battleEvent?.visible ?? null, + saveSlotPanelMode: battle?.saveSlotPanelMode ?? null, + tacticalCommandCutInVisible: battle?.tacticalCommandCutIn?.visible ?? null, + enemyRunCount: window.__HEROS_INTERACTION_UX__?.earlyTurnEnemyRunCount ?? -1 + }; + }); + throw new Error( + `The focused dangerous early turn-end action stalled after Enter: ${JSON.stringify(stalledState)}`, + { cause: error } + ); + } + + const confirmed = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const interaction = window.__HEROS_INTERACTION_UX__; + if (scene && interaction?.originalRunEnemyTurn) { + scene.runEnemyTurn = interaction.originalRunEnemyTurn; + delete interaction.originalRunEnemyTurn; + scene.activeFaction = 'ally'; + scene.phase = 'idle'; + scene.suppressNextLeftClick = false; + scene.hideTurnEndPrompt(); + scene.refreshEnemyIntentForecast(); + scene.refreshUnitLegibilityStyles(); + scene.updateTurnText(); + scene.renderSituationPanel('조기 턴 종료 안전성 검증 완료'); + } + return { + enemyRunCount: interaction?.earlyTurnEnemyRunCount ?? -1, + battle: window.__HEROS_DEBUG__?.battle() + }; + }); + assert( + confirmed.enemyRunCount === 1 && + confirmed.battle?.activeFaction === 'ally' && + confirmed.battle.turnPromptVisible === false, + `The explicitly selected dangerous action did not enter and cleanly restore the enemy turn: ${JSON.stringify(confirmed)}` + ); +} + +async function openManualTurnEndPrompt(page) { + const mapMenuPointer = await findRightmostEmptyBattleTile(page); + await page.mouse.click(mapMenuPointer.x, mapMenuPointer.y, { button: 'right' }); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.mapMenuVisible === true); + const mapMenu = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + return scene?.mapMenuLayout ? { ...scene.mapMenuLayout, actions: [...scene.mapMenuLayout.actions] } : null; + }); + const endTurnActionIndex = mapMenu?.actions.indexOf('endTurn') ?? -1; + assert(mapMenu && endTurnActionIndex >= 0, `Expected an enabled manual turn-end action: ${JSON.stringify(mapMenu)}`); + await clickSceneBounds(page, 'BattleScene', { + x: mapMenu.left, + y: mapMenu.top + mapMenu.padding + endTurnActionIndex * mapMenu.buttonHeight, + width: mapMenu.width, + height: mapMenu.buttonHeight + }); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === true); + return page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt); +} + +function assertEarlyTurnPrompt(prompt, setup, stage) { + const primary = prompt?.buttons?.find((button) => button.id === 'primary'); + const secondary = prompt?.buttons?.find((button) => button.id === 'secondary'); + assert( + prompt?.title.includes(`미행동 ${setup.allyIds.length}명`) && + setup.allyNames.every((name) => prompt.body.includes(name)) && + prompt.body.includes('외 1명') && + prompt.focusedAction === 'primary' && + prompt.cancelAction === 'primary' && + primary?.label === '계속 지휘' && + primary.meaning === 'continue-command' && + primary.dangerous === false && + primary.focused === true && + primary.escapeCancels === true && + secondary?.label === '미행동 포기 후 종료' && + secondary.meaning === 'abandon-unacted-and-end' && + secondary.dangerous === true && + secondary.focused === false && + secondary.escapeCancels === false, + `Early turn-end prompt did not disclose unacted allies, overflow threats, or safe focus (${stage}): ${JSON.stringify({ + setup, + prompt + })}` + ); } async function verifyBattleEventOverlayInputBlock(page) { diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index d6abd54..d7b6658 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -33,6 +33,46 @@ async function moveLegacyUi(page, x, y, options) { await page.mouse.move(x * legacyUiScale, y * legacyUiScale, options); } +async function clickSceneBounds(page, sceneKey, bounds) { + assert( + bounds && + Number.isFinite(bounds.x) && + Number.isFinite(bounds.y) && + Number.isFinite(bounds.width) && + Number.isFinite(bounds.height), + `Expected finite ${sceneKey} click bounds: ${JSON.stringify(bounds)}` + ); + const point = await page.evaluate( + ({ requestedSceneKey, requestedBounds }) => { + const scene = + window.__HEROS_DEBUG__?.scene(requestedSceneKey); + const canvas = document.querySelector('canvas'); + const canvasBounds = 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 + }; + }, + { requestedSceneKey: sceneKey, requestedBounds: bounds } + ); + assert( + point && Number.isFinite(point.x) && Number.isFinite(point.y), + `Expected a mapped ${sceneKey} click point: ${JSON.stringify({ bounds, point })}` + ); + await page.mouse.click(point.x, point.y); +} + async function clickBattleDeploymentStart(page) { const point = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); @@ -2751,8 +2791,20 @@ try { firstArrivalReward.cards.find((card) => card.id === 'unlocks')?.new === true && firstArrivalReward.cards.find((card) => card.id === 'unlocks')?.actionId === 'sortie' && firstArrivalReward.cards.every((card) => boundsWithinFhdViewport(card.bounds)) && - firstArrivalReward.actions.map((action) => action.id).join(',') === 'equipment,supplies,sortie,close' && + firstArrivalReward.actions.map((action) => action.id).join(',') === 'equipment,supplies,guided,sortie,close' && + firstArrivalReward.actions.filter((action) => action.primary).length === 1 && + firstArrivalReward.actions.find((action) => action.id === 'guided')?.label === '북문 정찰막 둘러보기' && + firstArrivalReward.actions.find((action) => action.id === 'guided')?.primary === true && + firstArrivalReward.actions.find((action) => action.id === 'sortie')?.label === '바로 출진' && + firstArrivalReward.actions.find((action) => action.id === 'sortie')?.primary === false && firstArrivalReward.actions.every((action) => action.interactive && boundsWithinFhdViewport(action.bounds)) && + firstCampProbe.state?.sortieCommand?.label === '정찰막 둘러보기' && + firstCampProbe.state?.sortieCommand?.guidedKind === 'visit' && + firstCampProbe.state?.sortieCommand?.targetId === firstPursuitScoutVisitId && + firstCampProbe.state?.sortieCommand?.interactive === true && + firstCampProbe.state?.sortieCommand?.bypass?.label === '바로 출진' && + firstCampProbe.state?.sortieCommand?.bypass?.interactive === true && + boundsWithinFhdViewport(firstCampProbe.state?.sortieCommand?.bypass?.bounds) && firstCampProbe.state?.sortieRoster ?.filter((unit) => ['guan-yu', 'zhang-fei', 'jian-yong'].includes(unit.id)).length === 3 && firstCampProbe.state?.sortieRoster @@ -2767,7 +2819,14 @@ try { firstCampProbe.state?.campTabs?.find((tab) => tab.id === 'visit')?.visualState === 'active' && firstCampProbe.state?.campTabs?.find((tab) => tab.id === 'visit')?.newBadgeVisible === true && firstCampProbe.state?.campTabs?.filter((tab) => ['supplies', 'equipment'].includes(tab.id)).every((tab) => tab.newBadgeVisible === true), - `Expected first camp arrival to expose bounded reward cards, deep links, and NEW badges: ${JSON.stringify(firstArrivalReward)}` + `Expected first camp arrival to expose bounded reward cards, deep links, and NEW badges: ${JSON.stringify({ + reward: firstArrivalReward, + sortieCommand: firstCampProbe.state?.sortieCommand, + activeTab: firstCampProbe.state?.activeTab, + selectedVisitId: firstCampProbe.state?.selectedVisitId, + campTabs: firstCampProbe.state?.campTabs, + sortieRoster: firstCampProbe.state?.sortieRoster + })}` ); const firstArrivalEquipmentPoint = await readCampVictoryRewardCardControlPoint(page, 'equipment'); await page.mouse.click(firstArrivalEquipmentPoint.x, firstArrivalEquipmentPoint.y); @@ -2895,25 +2954,46 @@ try { `Expected exactly one unfinished scout visit after the first victory, without an early battle payoff: ${JSON.stringify(firstCampProbe.state?.firstPursuitScoutMemory)}` ); - const firstCampVisitTabPoint = await readCampTabControlPoint(page, 'visit'); - await page.mouse.click(firstCampVisitTabPoint.x, firstCampVisitTabPoint.y); + const firstCampBypassBounds = firstCampProbe.state?.sortieCommand?.bypass?.bounds; + assert( + firstCampBypassBounds && boundsWithinFhdViewport(firstCampBypassBounds), + `Expected a persistent first-camp direct-sortie bypass after closing the reward panel: ${JSON.stringify(firstCampProbe.state?.sortieCommand)}` + ); + await clickSceneBounds(page, 'CampScene', firstCampBypassBounds); await page.waitForFunction( - (visitId) => { - const camp = window.__HEROS_DEBUG__?.camp?.(); - return camp?.activeTab === 'visit' && camp?.selectedVisitId === visitId; - }, - firstPursuitScoutVisitId, + () => window.__HEROS_DEBUG__?.camp?.()?.sortieVisible === true, + undefined, { timeout: 30000 } ); - const firstPursuitScoutExplorationPoint = await readCampVisitExplorationControlPoint( - page, - firstPursuitScoutVisitId + await page.screenshot({ + path: `${screenshotDir}/rc-first-camp-direct-sortie-bypass.png`, + fullPage: true + }); + await page.keyboard.press('Escape'); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp?.()?.sortieVisible === false, + undefined, + { timeout: 30000 } ); - await assertBaselineBrowserViewport(page, 'first-camp scout visit exploration CTA'); - await page.screenshot({ path: `${screenshotDir}/rc-first-camp-scout-visit-cta.png`, fullPage: true }); - await assertCanvasPainted(page, 'first-camp scout visit exploration CTA'); - - await page.mouse.click(firstPursuitScoutExplorationPoint.x, firstPursuitScoutExplorationPoint.y); + firstCampProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + return { + state: window.__HEROS_DEBUG__?.camp(), + summary: scene?.reportSummary?.(), + texts: (scene?.contentObjects ?? []) + .filter((object) => object?.type === 'Text') + .map((object) => object.text) + }; + }); + const firstCampGuidedScoutBounds = firstCampProbe.state?.sortieCommand?.bounds; + assert( + firstCampGuidedScoutBounds && boundsWithinFhdViewport(firstCampGuidedScoutBounds), + `Expected the first-camp primary command to expose a bounded scout route: ${JSON.stringify(firstCampProbe.state?.sortieCommand)}` + ); + await assertBaselineBrowserViewport(page, 'first-camp guided scout action'); + await page.screenshot({ path: `${screenshotDir}/rc-first-camp-guided-scout-action.png`, fullPage: true }); + await assertCanvasPainted(page, 'first-camp guided scout action'); + await clickSceneBounds(page, 'CampScene', firstCampGuidedScoutBounds); await page.waitForFunction( (visitId) => { const debug = window.__HEROS_DEBUG__; @@ -2936,7 +3016,7 @@ try { firstPursuitExplorationEntry.viewport.height === 1080 && firstPursuitExplorationEntry.requiredTexturesReady === true && firstPursuitExplorationEntry.actors?.find((actor) => actor.id === 'jian-yong')?.moved === false, - `Expected the scout CTA to open the ready FHD camp exploration with Jian Yong fixed in place: ${JSON.stringify(firstPursuitExplorationEntry)}` + `Expected the guided scout action to open the ready FHD camp exploration with Jian Yong fixed in place: ${JSON.stringify(firstPursuitExplorationEntry)}` ); const firstPursuitJianYongInteraction = await page.evaluate(() => @@ -3077,7 +3157,14 @@ try { completedFirstPursuitScoutVisit.decisionTextCount === 1 && completedFirstPursuitScoutVisit.choiceControlCount === 0 && completedFirstPursuitScoutVisit.preparationSummaryVisible === true && - completedFirstPursuitScoutVisit.visitCompletionTextVisible === true, + completedFirstPursuitScoutVisit.visitCompletionTextVisible === true && + completedFirstPursuitScoutVisit.state.sortieCommand?.guidedKind === 'dialogue' && + completedFirstPursuitScoutVisit.state.sortieCommand?.targetId === + completedFirstPursuitScoutVisit.state.firstVictoryFollowup?.targetDialogueId && + completedFirstPursuitScoutVisit.state.sortieCommand?.label?.includes('핵심 회고') && + completedFirstPursuitScoutVisit.state.sortieCommand?.interactive === true && + completedFirstPursuitScoutVisit.state.sortieCommand?.bypass?.label === '바로 출진' && + completedFirstPursuitScoutVisit.state.sortieCommand?.bypass?.interactive === true, `Expected the chosen scout visit to become one completed, visible preparation memory: ${JSON.stringify(completedFirstPursuitScoutVisit)}` ); assert( @@ -3123,7 +3210,14 @@ try { `Expected camp tab hover styling to reset on pointerout: ${JSON.stringify(firstCampTabResetState?.campTabs)}` ); - await page.mouse.click(firstCampDialogueTabPoint.x, firstCampDialogueTabPoint.y); + const firstCampGuidedDialogueBounds = firstCampTabResetState?.sortieCommand?.bounds; + assert( + firstCampGuidedDialogueBounds && + firstCampTabResetState?.sortieCommand?.guidedKind === 'dialogue' && + boundsWithinFhdViewport(firstCampGuidedDialogueBounds), + `Expected the primary camp command to guide the player into the pending reflection: ${JSON.stringify(firstCampTabResetState?.sortieCommand)}` + ); + await clickSceneBounds(page, 'CampScene', firstCampGuidedDialogueBounds); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp()?.activeTab === 'dialogue', undefined, @@ -3278,6 +3372,10 @@ try { completedFirstVictoryDialogueProbe.state?.campTabs?.find( (tab) => tab.id === 'dialogue' )?.newBadgeVisible === true && + completedFirstVictoryDialogueProbe.state?.sortieCommand?.label === '출진 준비' && + completedFirstVictoryDialogueProbe.state?.sortieCommand?.guidedKind === 'sortie' && + completedFirstVictoryDialogueProbe.state?.sortieCommand?.targetId === null && + completedFirstVictoryDialogueProbe.state?.sortieCommand?.bypass === null && completedFirstVictoryDialogueProbe.remainingRowMarkerCount === 0 && firstVictoryDialoguePersisted(completedFirstVictoryDialogueSave.current) && firstVictoryDialoguePersisted(completedFirstVictoryDialogueSave.slot1), diff --git a/scripts/verify-static-data.mjs b/scripts/verify-static-data.mjs index 7b14dd3..69f95e0 100644 --- a/scripts/verify-static-data.mjs +++ b/scripts/verify-static-data.mjs @@ -24,6 +24,7 @@ const checks = [ 'scripts/verify-city-stay-data.mjs', 'scripts/verify-city-equipment-preparation.mjs', 'scripts/verify-xuzhou-stay-battle-payoff.mjs', + 'scripts/verify-xuzhou-stay-narrative-memory.mjs', 'scripts/verify-prologue-village-data.mjs', 'scripts/verify-first-battle-camp-followup.mjs', 'scripts/verify-first-battle-camaraderie-memory.mjs', diff --git a/scripts/verify-xuzhou-stay-battle-payoff-browser.mjs b/scripts/verify-xuzhou-stay-battle-payoff-browser.mjs index f76f140..ba1149f 100644 --- a/scripts/verify-xuzhou-stay-battle-payoff-browser.mjs +++ b/scripts/verify-xuzhou-stay-battle-payoff-browser.mjs @@ -69,7 +69,7 @@ try { `100% zoom, DPR ${desktopBrowserDeviceScaleFactor}: two exact deployment intent previews, ` + 'aid +4 and encourage +1-turn single-use branches, Mi Zhu absence, no-information baseline, ' + 'Mi Zhu defeat expiry, guard counterplay credit, equipment-safe header/chip layout, ' + - 'battle-save restoration, and campaign result/history persistence.' + 'battle-save restoration, campaign result/history persistence, and choice-aware Mi Zhu story/aftermath pages.' ); } finally { await browser?.close(); @@ -431,6 +431,236 @@ async function verifyAidBranch(page, renderer) { )}` ); await capture(page, screenshotPath(renderer, 'aid-result')); + await verifyNarrativeMemoryPages(page, renderer); +} + +async function verifyNarrativeMemoryPages(page, renderer) { + const intro = await openXuzhouNarrativePage(page, 'story'); + assert.equal(intro.currentPageId, 'eighth-xuzhou-stay-memory-intro'); + assert.equal(intro.currentSpeaker, '미축'); + assert.equal(intro.portraitKey, 'mi-zhu'); + assert( + intro.portraitTextureKey?.startsWith('portrait-mi-zhu'), + `${renderer}: the Xuzhou memory page must render Mi Zhu's face portrait: ${JSON.stringify(intro)}` + ); + assert( + intro.currentText.includes('서주에서 함께 살핀 장부') && + intro.currentText.includes('창고 당번을 함께 나누기로 한 뜻'), + `${renderer}: the pre-battle story must react to the exact shared-storehouse choice: ${JSON.stringify(intro)}` + ); + assert.deepEqual( + intro.pageIds.slice(2, 5), + [ + 'eighth-xiaopei-road', + 'eighth-xuzhou-stay-memory-intro', + 'eighth-sortie-choice' + ], + `${renderer}: the memory page must sit immediately after the Xiaopei road briefing.` + ); + assert.equal(intro.xuzhouStayNarrativeMemory?.stage, 'story'); + assert.equal( + intro.xuzhouStayNarrativeMemory?.choiceId, + aidChoiceId + ); + assert.equal( + intro.xuzhouStayNarrativeMemory?.currentPageApplied, + true + ); + assertBoundsInside( + intro.bodyBounds, + intro.dialogPanelBounds, + `${renderer}: Xuzhou intro dialogue body` + ); + await capture( + page, + screenshotPath(renderer, 'narrative-intro-aid') + ); + + const aftermath = await openXuzhouNarrativePage( + page, + 'aftermath' + ); + assert.equal( + aftermath.currentPageId, + 'eighth-xuzhou-stay-memory-aftermath' + ); + assert.equal(aftermath.currentSpeaker, '미축'); + assert.equal(aftermath.portraitKey, 'mi-zhu'); + assert( + aftermath.portraitTextureKey?.startsWith( + 'portrait-mi-zhu' + ), + `${renderer}: the Xuzhou aftermath must retain Mi Zhu's face portrait: ${JSON.stringify(aftermath)}` + ); + assert( + aftermath.currentText.includes('움직임 1곳을 파훼') && + aftermath.currentText.includes('응급 회복을 4만큼 더 보탰습니다'), + `${renderer}: the aftermath must narrate the historical counterplay and consumed aid bonus: ${JSON.stringify(aftermath)}` + ); + assert.deepEqual( + aftermath.pageIds.slice(0, 3), + [ + 'eighth-victory-supply-road', + 'eighth-xuzhou-stay-memory-aftermath', + 'eighth-lu-bu-arrival' + ], + `${renderer}: the historical result page must sit before Lu Bu's arrival.` + ); + assert.equal( + aftermath.xuzhouStayNarrativeMemory?.stage, + 'aftermath' + ); + assert.equal( + aftermath.xuzhouStayNarrativeMemory + ?.informationCounterplays, + 1 + ); + assert.equal( + aftermath.xuzhouStayNarrativeMemory?.supportOutcome, + 'used' + ); + assert.equal( + aftermath.xuzhouStayNarrativeMemory?.miZhuStatus, + 'deployed' + ); + assert.equal( + aftermath.xuzhouStayNarrativeMemory?.currentPageApplied, + true + ); + assertBoundsInside( + aftermath.bodyBounds, + aftermath.dialogPanelBounds, + `${renderer}: Xuzhou aftermath dialogue body` + ); + await capture( + page, + screenshotPath(renderer, 'narrative-aftermath-aid') + ); + + await page.evaluate(() => { + const game = window.__HEROS_GAME__; + game?.scene.stop('StoryScene'); + game?.scene.stop('BattleScene'); + game?.scene.start('TitleScene'); + }); + await page.waitForFunction(() => + window.__HEROS_DEBUG__?.activeScenes?.().includes( + 'TitleScene' + ) + ); +} + +async function openXuzhouNarrativePage(page, stage) { + await page.evaluate( + async ({ requestedStage, battleId }) => { + const [scenarioModule, lazySceneModule] = + await Promise.all([ + import('/heros_web/src/game/data/scenario.ts'), + import('/heros_web/src/game/scenes/lazyScenes.ts') + ]); + const game = window.__HEROS_GAME__; + if (!game) { + throw new Error('Game instance is unavailable.'); + } + game.scene.stop('BattleScene'); + game.scene.stop('StoryScene'); + await lazySceneModule.startLazySceneFromGame( + game, + 'StoryScene', + { + pages: structuredClone( + requestedStage === 'story' + ? scenarioModule.eighthBattleIntroPages + : scenarioModule.eighthBattleVictoryPages + ), + nextScene: 'TitleScene', + presentationBattleId: battleId, + presentationStage: requestedStage + } + ); + }, + { + requestedStage: stage, + battleId: targetBattleId + } + ); + await page.waitForFunction( + () => { + const story = + window.__HEROS_DEBUG__?.scene( + 'StoryScene' + )?.getDebugState?.(); + return ( + window.__HEROS_DEBUG__?.activeScenes?.().includes( + 'StoryScene' + ) && + story?.ready === true + ); + }, + undefined, + { timeout: 90000 } + ); + + for (let index = 0; index < 8; index += 1) { + await page.waitForFunction( + () => { + const scene = + window.__HEROS_DEBUG__?.scene('StoryScene'); + return ( + typeof scene?.advance === 'function' && + scene.transitioning === false + ); + }, + undefined, + { timeout: 30000 } + ); + const current = await readStoryWithPageIds(page); + if ( + current.xuzhouStayNarrativeMemory + ?.currentPageApplied === true + ) { + return current; + } + assert.equal( + current.isLastPage, + false, + `The ${stage} story ended before its Xuzhou memory page: ${JSON.stringify(current)}` + ); + const previousPageIndex = current.pageIndex; + await page.evaluate(() => + window.__HEROS_DEBUG__?.scene( + 'StoryScene' + )?.advance?.() + ); + await page.waitForFunction( + (before) => { + const story = + window.__HEROS_DEBUG__?.scene( + 'StoryScene' + )?.getDebugState?.(); + return ( + story?.ready === true && + story.pageIndex > before + ); + }, + previousPageIndex, + { timeout: 90000 } + ); + } + throw new Error( + `Could not reach the ${stage} Xuzhou memory page.` + ); +} + +async function readStoryWithPageIds(page) { + return page.evaluate(() => { + const scene = + window.__HEROS_DEBUG__?.scene('StoryScene'); + return { + ...scene?.getDebugState?.(), + pageIds: (scene?.pages ?? []).map((page) => page.id) + }; + }); } async function verifyEncourageBranch(page, renderer) { diff --git a/scripts/verify-xuzhou-stay-narrative-memory.mjs b/scripts/verify-xuzhou-stay-narrative-memory.mjs new file mode 100644 index 0000000..8602a79 --- /dev/null +++ b/scripts/verify-xuzhou-stay-narrative-memory.mjs @@ -0,0 +1,564 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'vite'; + +const server = await createServer({ + logLevel: 'error', + server: { middlewareMode: true, hmr: false }, + appType: 'custom' +}); + +try { + const narrative = await server.ssrLoadModule( + '/src/game/data/xuzhouStayNarrativeMemory.ts' + ); + const payoff = await server.ssrLoadModule( + '/src/game/data/xuzhouStayBattlePayoff.ts' + ); + const scenario = await server.ssrLoadModule( + '/src/game/data/scenario.ts' + ); + + verifyIntroChoices(narrative, payoff, scenario); + verifyInformationOnlyIntro(narrative, payoff, scenario); + verifyHistoricalInformationAndSupport( + narrative, + payoff, + scenario + ); + verifyUnusedNoDeploymentAndRetreat( + narrative, + payoff, + scenario + ); + verifyIsolationAndMalformedInputs( + narrative, + payoff, + scenario + ); + verifyInputImmutability(narrative, payoff, scenario); + + console.log( + 'Xuzhou stay narrative memory verification passed (two intro choices, information-only memory, historical counterplays, support used/unused, Mi Zhu no-deployment/retreat, malformed and other-battle isolation, old-save fallback, historical independence, exact portrait/page placement, and input immutability).' + ); +} finally { + await server.close(); +} + +function verifyIntroChoices(narrative, payoff, scenario) { + const aid = resolveIntro( + narrative, + scenario, + campaignMemory(payoff, { + informationCompleted: true, + choiceId: payoff.xuzhouStayShareStorehouseDutyChoiceId + }) + ); + assert.equal( + aid.memory.source, + 'campaign' + ); + assert.equal(aid.memory.stage, 'story'); + assert.equal( + aid.memory.choiceId, + payoff.xuzhouStayShareStorehouseDutyChoiceId + ); + assert.equal(aid.memory.informationCompleted, true); + assert.deepEqual(aid.memory.appliedPageIds, [ + narrative.xuzhouStayNarrativeIntroPageId + ]); + const aidPage = insertedPage( + aid, + narrative.xuzhouStayNarrativeIntroPageId + ); + assert.equal(aidPage.portrait, 'mi-zhu'); + assert.equal(aidPage.speaker, '미축'); + assert.match(aidPage.text, /장부/); + assert.match(aidPage.text, /창고 당번/); + assertInsertedBetween( + aid.pages, + 'eighth-xiaopei-road', + narrative.xuzhouStayNarrativeIntroPageId, + 'eighth-sortie-choice' + ); + + const encourage = resolveIntro( + narrative, + scenario, + campaignMemory(payoff, { + informationCompleted: true, + choiceId: payoff.xuzhouStayTrustMiZhuLedgerChoiceId + }) + ); + assert.equal( + encourage.memory.choiceId, + payoff.xuzhouStayTrustMiZhuLedgerChoiceId + ); + const encouragePage = insertedPage( + encourage, + narrative.xuzhouStayNarrativeIntroPageId + ); + assert.match(encouragePage.text, /장부를 제게 맡겨/); + assert.match(encouragePage.text, /독려/); +} + +function verifyInformationOnlyIntro( + narrative, + payoff, + scenario +) { + const result = resolveIntro( + narrative, + scenario, + campaignMemory(payoff, { + informationCompleted: true + }) + ); + assert.equal(result.memory.choiceId, null); + assert.equal(result.memory.informationCompleted, true); + const page = insertedPage( + result, + narrative.xuzhouStayNarrativeIntroPageId + ); + assert.match(page.text, /장부/); + assert.match(page.text, /출진 장수/); +} + +function verifyHistoricalInformationAndSupport( + narrative, + payoff, + scenario +) { + const aidReport = historicalReport( + payoff, + payoffSnapshot(payoff, { + informationCompleted: true, + choiceId: payoff.xuzhouStayShareStorehouseDutyChoiceId, + informationCounterplays: 2, + miZhuDeployed: true, + supportAttempts: 1, + supportUses: 1, + bonusHealing: 4 + }), + 14 + ); + const aid = resolveAftermath( + narrative, + scenario, + aidReport, + campaignMemory(payoff, { + informationCompleted: false, + choiceId: payoff.xuzhouStayTrustMiZhuLedgerChoiceId + }) + ); + assert.equal(aid.memory.source, 'historical-payoff'); + assert.equal(aid.memory.informationCounterplays, 2); + assert.equal(aid.memory.supportOutcome, 'used'); + assert.equal(aid.memory.miZhuStatus, 'deployed'); + assert.equal(aid.memory.bonusHealing, 4); + const aidPage = insertedPage( + aid, + narrative.xuzhouStayNarrativeAftermathPageId + ); + assert.equal(aidPage.portrait, 'mi-zhu'); + assert.match(aidPage.text, /2곳/); + assert.match(aidPage.text, /응급 회복을 4만큼/); + assert.doesNotMatch(aidPage.text, /격려의 기세/); + assertInsertedBetween( + aid.pages, + 'eighth-victory-supply-road', + narrative.xuzhouStayNarrativeAftermathPageId, + 'eighth-lu-bu-arrival' + ); + + const changedCampaign = campaignMemory(payoff, { + informationCompleted: true, + choiceId: payoff.xuzhouStayShareStorehouseDutyChoiceId + }); + const replayed = resolveAftermath( + narrative, + scenario, + aidReport, + changedCampaign + ); + assert.deepEqual( + replayed, + aid, + 'Historical aftermath text must not change with current campaign memory.' + ); + + const encourage = resolveAftermath( + narrative, + scenario, + historicalReport( + payoff, + payoffSnapshot(payoff, { + choiceId: payoff.xuzhouStayTrustMiZhuLedgerChoiceId, + miZhuDeployed: true, + supportAttempts: 1, + supportUses: 1, + bonusBuffTurns: 1 + }), + 9 + ) + ); + assert.equal(encourage.memory.supportOutcome, 'used'); + assert.equal(encourage.memory.bonusBuffTurns, 1); + assert.match( + insertedPage( + encourage, + narrative.xuzhouStayNarrativeAftermathPageId + ).text, + /격려의 기세를 1턴/ + ); + + const informationOnly = resolveAftermath( + narrative, + scenario, + historicalReport( + payoff, + payoffSnapshot(payoff, { + informationCompleted: true, + informationCounterplays: 1 + }) + ) + ); + assert.equal(informationOnly.memory.choiceId, null); + assert.equal(informationOnly.memory.supportOutcome, null); + assert.match( + insertedPage( + informationOnly, + narrative.xuzhouStayNarrativeAftermathPageId + ).text, + /1곳/ + ); +} + +function verifyUnusedNoDeploymentAndRetreat( + narrative, + payoff, + scenario +) { + const unused = resolveAftermath( + narrative, + scenario, + historicalReport( + payoff, + payoffSnapshot(payoff, { + choiceId: payoff.xuzhouStayShareStorehouseDutyChoiceId, + miZhuDeployed: true + }), + 11 + ) + ); + assert.equal(unused.memory.miZhuStatus, 'deployed'); + assert.equal(unused.memory.supportOutcome, 'unused'); + assert.match( + insertedPage( + unused, + narrative.xuzhouStayNarrativeAftermathPageId + ).text, + /쓸 기회는 없었지만/ + ); + + const notDeployed = resolveAftermath( + narrative, + scenario, + historicalReport( + payoff, + payoffSnapshot(payoff, { + choiceId: payoff.xuzhouStayTrustMiZhuLedgerChoiceId, + miZhuDeployed: false + }), + null + ) + ); + assert.equal( + notDeployed.memory.miZhuStatus, + 'not-deployed' + ); + assert.equal( + notDeployed.memory.supportOutcome, + 'unavailable' + ); + assert.match( + insertedPage( + notDeployed, + narrative.xuzhouStayNarrativeAftermathPageId + ).text, + /출전에 함께하지 못해/ + ); + + const retreated = resolveAftermath( + narrative, + scenario, + historicalReport( + payoff, + payoffSnapshot(payoff, { + choiceId: payoff.xuzhouStayTrustMiZhuLedgerChoiceId, + miZhuDeployed: true + }), + 0 + ) + ); + assert.equal(retreated.memory.miZhuStatus, 'retreated'); + assert.equal(retreated.memory.supportOutcome, 'unused'); + assert.match( + insertedPage( + retreated, + narrative.xuzhouStayNarrativeAftermathPageId + ).text, + /전장에서 물러나는 바람에/ + ); +} + +function verifyIsolationAndMalformedInputs( + narrative, + payoff, + scenario +) { + const noIntroMemory = resolveIntro( + narrative, + scenario, + campaignMemory(payoff, {}) + ); + assert.equal(noIntroMemory.memory, undefined); + assert.equal( + noIntroMemory.pages.length, + scenario.eighthBattleIntroPages.length + ); + + const noHistoricalMemory = resolveAftermath( + narrative, + scenario, + { + battleId: payoff.xuzhouStayBattlePayoffTargetBattleId, + outcome: 'victory', + units: [] + } + ); + assert.equal(noHistoricalMemory.memory, undefined); + assert.equal( + noHistoricalMemory.pages.length, + scenario.eighthBattleVictoryPages.length + ); + + const malformed = resolveAftermath( + narrative, + scenario, + { + battleId: payoff.xuzhouStayBattlePayoffTargetBattleId, + outcome: 'victory', + units: [{ unitId: 'mi-zhu', hp: 10 }], + xuzhouStayBattlePayoff: { + ...payoffSnapshot(payoff, { + informationCompleted: true + }), + recordId: 'forged-record' + } + } + ); + assert.equal(malformed.memory, undefined); + + const otherBattle = narrative.resolveXuzhouStayNarrativeMemory( + scenario.eighthBattleIntroPages, + { + battleId: 'ninth-battle-xuzhou-gate-night-raid', + stage: 'story', + campaign: campaignMemory(payoff, { + informationCompleted: true + }) + } + ); + assert.equal(otherBattle.memory, undefined); + assert.equal( + otherBattle.pages.some((page) => + page.id.startsWith('eighth-xuzhou-stay-memory') + ), + false + ); + + const defeat = resolveAftermath( + narrative, + scenario, + { + ...historicalReport( + payoff, + payoffSnapshot(payoff, { + informationCompleted: true + }) + ), + outcome: 'defeat' + } + ); + assert.equal(defeat.memory, undefined); +} + +function verifyInputImmutability(narrative, payoff, scenario) { + const pages = structuredClone( + scenario.eighthBattleVictoryPages + ); + const campaign = campaignMemory(payoff, { + informationCompleted: true, + choiceId: payoff.xuzhouStayTrustMiZhuLedgerChoiceId + }); + const report = historicalReport( + payoff, + payoffSnapshot(payoff, { + informationCompleted: true, + choiceId: payoff.xuzhouStayTrustMiZhuLedgerChoiceId, + informationCounterplays: 2, + miZhuDeployed: true, + supportAttempts: 1, + supportUses: 1, + bonusBuffTurns: 1 + }), + 8 + ); + const beforePages = structuredClone(pages); + const beforeCampaign = structuredClone(campaign); + const beforeReport = structuredClone(report); + + const result = narrative.resolveXuzhouStayNarrativeMemory( + pages, + { + battleId: payoff.xuzhouStayBattlePayoffTargetBattleId, + stage: 'aftermath', + campaign, + historicalReport: report + } + ); + assert.deepEqual(pages, beforePages); + assert.deepEqual(campaign, beforeCampaign); + assert.deepEqual(report, beforeReport); + assert.notEqual(result.pages, pages); + assert.notEqual(result.pages[0], pages[0]); + + result.pages[0].text = 'mutated output'; + assert.deepEqual(pages, beforePages); +} + +function resolveIntro(narrative, scenario, campaign) { + return narrative.resolveXuzhouStayNarrativeMemory( + scenario.eighthBattleIntroPages, + { + battleId: 'eighth-battle-xiaopei-supply-road', + stage: 'story', + campaign + } + ); +} + +function resolveAftermath( + narrative, + scenario, + historicalReport, + campaign +) { + return narrative.resolveXuzhouStayNarrativeMemory( + scenario.eighthBattleVictoryPages, + { + battleId: 'eighth-battle-xiaopei-supply-road', + stage: 'aftermath', + campaign, + historicalReport + } + ); +} + +function campaignMemory( + payoff, + { informationCompleted = false, choiceId } = {} +) { + return { + battleHistory: { + [payoff.xuzhouStayBattlePayoffSourceBattleId]: { + battleId: payoff.xuzhouStayBattlePayoffSourceBattleId, + outcome: 'victory' + } + }, + completedCampVisits: informationCompleted + ? [payoff.xuzhouStayInformationVisitId] + : [], + completedCampDialogues: choiceId + ? [payoff.xuzhouStayResonanceDialogueId] + : [], + campDialogueChoiceIds: choiceId + ? { + [payoff.xuzhouStayResonanceDialogueId]: choiceId + } + : {} + }; +} + +function payoffSnapshot( + payoff, + { + informationCompleted = false, + choiceId, + informationCounterplays = 0, + miZhuDeployed = false, + supportAttempts = 0, + supportUses = 0, + bonusHealing = 0, + bonusBuffTurns = 0 + } = {} +) { + return { + version: payoff.xuzhouStayBattlePayoffVersion, + recordId: payoff.xuzhouStayBattlePayoffRecordId, + cityStayId: payoff.xuzhouStayId, + sourceBattleId: + payoff.xuzhouStayBattlePayoffSourceBattleId, + targetBattleId: + payoff.xuzhouStayBattlePayoffTargetBattleId, + informationVisitId: payoff.xuzhouStayInformationVisitId, + dialogueId: payoff.xuzhouStayResonanceDialogueId, + informationCompleted, + ...(choiceId ? { choiceId } : {}), + informationCounterplays, + miZhuDeployed, + supportAttempts, + supportUses, + bonusHealing, + bonusBuffTurns + }; +} + +function historicalReport(payoff, snapshot, miZhuHp = null) { + return { + battleId: payoff.xuzhouStayBattlePayoffTargetBattleId, + outcome: 'victory', + units: + miZhuHp === null + ? [] + : [ + { + unitId: payoff.xuzhouStayMiZhuUnitId, + hp: miZhuHp + } + ], + xuzhouStayBattlePayoff: snapshot + }; +} + +function insertedPage(result, pageId) { + assert(result.memory, `Missing narrative memory for ${pageId}.`); + const page = result.pages.find( + (candidate) => candidate.id === pageId + ); + assert(page, `Missing inserted page: ${pageId}`); + return page; +} + +function assertInsertedBetween( + pages, + previousId, + insertedId, + nextId +) { + const ids = pages.map((page) => page.id); + const insertedIndex = ids.indexOf(insertedId); + assert(insertedIndex > 0, `Missing inserted page: ${insertedId}`); + assert.equal(ids[insertedIndex - 1], previousId); + assert.equal(ids[insertedIndex + 1], nextId); +} diff --git a/src/game/data/xuzhouStayNarrativeMemory.ts b/src/game/data/xuzhouStayNarrativeMemory.ts new file mode 100644 index 0000000..861c03e --- /dev/null +++ b/src/game/data/xuzhouStayNarrativeMemory.ts @@ -0,0 +1,353 @@ +import type { StoryPage } from './scenario'; +import { + normalizeXuzhouStayBattlePayoffSnapshot, + resolveXuzhouStayBattlePayoff, + xuzhouStayBattlePayoffTargetBattleId, + xuzhouStayMiZhuUnitId, + xuzhouStayShareStorehouseDutyChoiceId, + xuzhouStayTrustMiZhuLedgerChoiceId, + type XuzhouStayBattlePayoffCampaignState, + type XuzhouStayBattlePayoffChoiceId, + type XuzhouStayBattlePayoffSnapshot +} from './xuzhouStayBattlePayoff'; + +export const xuzhouStayNarrativeIntroPageId = + 'eighth-xuzhou-stay-memory-intro' as const; +export const xuzhouStayNarrativeAftermathPageId = + 'eighth-xuzhou-stay-memory-aftermath' as const; + +const xuzhouStayNarrativeIntroAnchorPageId = + 'eighth-xiaopei-road'; +const xuzhouStayNarrativeAftermathAnchorPageId = + 'eighth-victory-supply-road'; + +export type XuzhouStayNarrativeStage = 'story' | 'aftermath'; +export type XuzhouStayNarrativeMiZhuStatus = + | 'deployed' + | 'not-deployed' + | 'retreated'; +export type XuzhouStayNarrativeSupportOutcome = + | 'used' + | 'unused' + | 'unavailable'; + +export type XuzhouStayNarrativeHistoricalUnit = { + unitId?: unknown; + hp?: unknown; +}; + +export type XuzhouStayNarrativeHistoricalReport = { + battleId?: unknown; + outcome?: unknown; + units?: readonly XuzhouStayNarrativeHistoricalUnit[]; + xuzhouStayBattlePayoff?: unknown; +}; + +export type XuzhouStayNarrativeMemory = { + source: 'campaign' | 'historical-payoff'; + stage: XuzhouStayNarrativeStage; + battleId: typeof xuzhouStayBattlePayoffTargetBattleId; + informationCompleted: boolean; + choiceId: XuzhouStayBattlePayoffChoiceId | null; + informationCounterplays: number | null; + miZhuStatus: XuzhouStayNarrativeMiZhuStatus | null; + supportOutcome: XuzhouStayNarrativeSupportOutcome | null; + bonusHealing: number; + bonusBuffTurns: number; + appliedPageIds: string[]; +}; + +export type XuzhouStayNarrativeResolution = { + pages: StoryPage[]; + memory?: XuzhouStayNarrativeMemory; +}; + +export type XuzhouStayNarrativeResolutionOptions = { + battleId?: unknown; + stage: XuzhouStayNarrativeStage; + campaign?: XuzhouStayBattlePayoffCampaignState | null; + historicalReport?: XuzhouStayNarrativeHistoricalReport | null; +}; + +export function resolveXuzhouStayNarrativeMemory( + sourcePages: readonly StoryPage[], + options: XuzhouStayNarrativeResolutionOptions +): XuzhouStayNarrativeResolution { + const pages = sourcePages.map((page) => ({ ...page })); + if ( + options.battleId !== + xuzhouStayBattlePayoffTargetBattleId + ) { + return { pages }; + } + + return options.stage === 'story' + ? resolveIntroNarrative(pages, options.campaign) + : resolveAftermathNarrative( + pages, + options.historicalReport + ); +} + +function resolveIntroNarrative( + pages: StoryPage[], + campaign?: XuzhouStayBattlePayoffCampaignState | null +): XuzhouStayNarrativeResolution { + const payoff = resolveXuzhouStayBattlePayoff( + campaign, + xuzhouStayBattlePayoffTargetBattleId + ); + if (!payoff) { + return { pages }; + } + + const page = introPage( + payoff.informationCompleted, + payoff.choiceId + ); + if ( + !insertAfter( + pages, + xuzhouStayNarrativeIntroAnchorPageId, + page + ) + ) { + return { pages }; + } + + return { + pages, + memory: { + source: 'campaign', + stage: 'story', + battleId: xuzhouStayBattlePayoffTargetBattleId, + informationCompleted: payoff.informationCompleted, + choiceId: payoff.choiceId ?? null, + informationCounterplays: null, + miZhuStatus: null, + supportOutcome: null, + bonusHealing: 0, + bonusBuffTurns: 0, + appliedPageIds: [xuzhouStayNarrativeIntroPageId] + } + }; +} + +function resolveAftermathNarrative( + pages: StoryPage[], + report?: XuzhouStayNarrativeHistoricalReport | null +): XuzhouStayNarrativeResolution { + if ( + report?.battleId !== + xuzhouStayBattlePayoffTargetBattleId || + report.outcome !== 'victory' + ) { + return { pages }; + } + + const payoff = normalizeXuzhouStayBattlePayoffSnapshot( + report.xuzhouStayBattlePayoff, + { + expectedBattleId: + xuzhouStayBattlePayoffTargetBattleId + } + ); + if (!payoff) { + return { pages }; + } + + const miZhuStatus = historicalMiZhuStatus(payoff, report); + const supportOutcome = historicalSupportOutcome( + payoff, + miZhuStatus + ); + const page = aftermathPage( + payoff, + miZhuStatus, + supportOutcome + ); + if ( + !insertAfter( + pages, + xuzhouStayNarrativeAftermathAnchorPageId, + page + ) + ) { + return { pages }; + } + + return { + pages, + memory: { + source: 'historical-payoff', + stage: 'aftermath', + battleId: xuzhouStayBattlePayoffTargetBattleId, + informationCompleted: payoff.informationCompleted, + choiceId: payoff.choiceId ?? null, + informationCounterplays: + payoff.informationCounterplays, + miZhuStatus: payoff.choiceId ? miZhuStatus : null, + supportOutcome: payoff.choiceId + ? supportOutcome + : null, + bonusHealing: payoff.bonusHealing, + bonusBuffTurns: payoff.bonusBuffTurns, + appliedPageIds: [ + xuzhouStayNarrativeAftermathPageId + ] + } + }; +} + +function introPage( + informationCompleted: boolean, + choiceId?: XuzhouStayBattlePayoffChoiceId +): StoryPage { + const informationLine = informationCompleted + ? '서주에서 함께 살핀 장부 덕분에 적이 노릴 길목을 미리 짚을 수 있습니다.' + : ''; + const choiceLine = + choiceId === xuzhouStayShareStorehouseDutyChoiceId + ? '창고 당번을 함께 나누기로 한 뜻대로, 저도 전열에서 다친 이들을 먼저 돌보겠습니다.' + : choiceId === + xuzhouStayTrustMiZhuLedgerChoiceId + ? '장부를 제게 맡겨 주신 뜻대로, 전열이 흔들릴 때 병사들을 독려하겠습니다.' + : '장부에서 확인한 움직임을 출진 장수들에게 빠짐없이 전하겠습니다.'; + + return { + id: xuzhouStayNarrativeIntroPageId, + bgm: 'battle-prep', + chapter: '서주에서 세운 방침', + background: 'story-xuzhou', + speaker: '미축', + portrait: 'mi-zhu', + text: [informationLine, choiceLine] + .filter(Boolean) + .join(' ') + }; +} + +function aftermathPage( + payoff: XuzhouStayBattlePayoffSnapshot, + miZhuStatus: XuzhouStayNarrativeMiZhuStatus, + supportOutcome: XuzhouStayNarrativeSupportOutcome +): StoryPage { + const informationLine = payoff.informationCompleted + ? payoff.informationCounterplays > 0 + ? `서주 장부에서 짚은 움직임 ${payoff.informationCounterplays}곳을 파훼해 보급로의 빈틈을 막았습니다.` + : '서주 장부를 전장에 가져왔지만, 짚어 둔 움직임을 직접 파훼할 기회는 없었습니다.' + : ''; + const supportLine = payoff.choiceId + ? supportResultLine(payoff, miZhuStatus, supportOutcome) + : ''; + + return { + id: xuzhouStayNarrativeAftermathPageId, + bgm: 'militia-theme', + chapter: '되살아난 서주의 약속', + background: 'story-xuzhou', + speaker: '미축', + portrait: 'mi-zhu', + text: [informationLine, supportLine] + .filter(Boolean) + .join(' ') + }; +} + +function supportResultLine( + payoff: XuzhouStayBattlePayoffSnapshot, + miZhuStatus: XuzhouStayNarrativeMiZhuStatus, + supportOutcome: XuzhouStayNarrativeSupportOutcome +) { + if (miZhuStatus === 'not-deployed') { + return '저는 이번 출전에 함께하지 못해, 서주에서 정한 방침을 전장에 보태지 못했습니다.'; + } + if ( + miZhuStatus === 'retreated' && + supportOutcome !== 'used' + ) { + return '제가 전장에서 물러나는 바람에, 서주에서 정한 방침을 끝까지 펼치지 못했습니다.'; + } + if ( + payoff.choiceId === + xuzhouStayShareStorehouseDutyChoiceId + ) { + if (supportOutcome === 'used') { + const retreatLine = + miZhuStatus === 'retreated' + ? ' 비록 뒤에는 물러났지만,' + : ''; + return `공동 구휼의 약속으로 응급 회복을 ${payoff.bonusHealing}만큼 더 보탰습니다.${retreatLine} 백성과 병사가 함께 창고를 지킨 뜻은 남았습니다.`; + } + return '공동 구휼의 방침을 쓸 기회는 없었지만, 다음 보급에도 백성과 병사의 몫을 함께 살피겠습니다.'; + } + if ( + payoff.choiceId === + xuzhouStayTrustMiZhuLedgerChoiceId + ) { + if (supportOutcome === 'used') { + const retreatLine = + miZhuStatus === 'retreated' + ? ' 비록 뒤에는 물러났지만,' + : ''; + return `맡겨 주신 장부로 격려의 기세를 ${payoff.bonusBuffTurns}턴 더 이어 냈습니다.${retreatLine} 믿고 맡긴 뜻에 보답할 수 있었습니다.`; + } + return '장부 위임의 방침을 쓸 기회는 없었지만, 다음에는 전열의 흔들림을 더 일찍 살피겠습니다.'; + } + return ''; +} + +function historicalMiZhuStatus( + payoff: XuzhouStayBattlePayoffSnapshot, + report: XuzhouStayNarrativeHistoricalReport +): XuzhouStayNarrativeMiZhuStatus { + if (!payoff.miZhuDeployed) { + return 'not-deployed'; + } + const unit = Array.isArray(report.units) + ? report.units.find( + (candidate) => + candidate?.unitId === xuzhouStayMiZhuUnitId + ) + : undefined; + return unit && normalizedHp(unit.hp) <= 0 + ? 'retreated' + : 'deployed'; +} + +function historicalSupportOutcome( + payoff: XuzhouStayBattlePayoffSnapshot, + miZhuStatus: XuzhouStayNarrativeMiZhuStatus +): XuzhouStayNarrativeSupportOutcome { + if (miZhuStatus === 'not-deployed') { + return 'unavailable'; + } + return payoff.supportUses > 0 ? 'used' : 'unused'; +} + +function normalizedHp(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) + ? Math.max(0, value) + : 1; +} + +function insertAfter( + pages: StoryPage[], + anchorPageId: string, + page: StoryPage +) { + if (pages.some((candidate) => candidate.id === page.id)) { + return false; + } + const anchorIndex = pages.findIndex( + (candidate) => candidate.id === anchorPageId + ); + if ( + anchorIndex < 0 || + anchorIndex >= pages.length - 1 + ) { + return false; + } + pages.splice(anchorIndex + 1, 0, page); + return true; +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 73c8cbd..fba7c3c 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1673,6 +1673,33 @@ type TurnEndPromptOptions = { secondaryLabel?: string; primaryAction?: () => void; secondaryAction?: () => void; + initialFocus?: TurnPromptActionId; + cancelActionId?: TurnPromptActionId; + primaryMeaning?: TurnPromptActionMeaning; + secondaryMeaning?: TurnPromptActionMeaning; + primaryDangerous?: boolean; + secondaryDangerous?: boolean; +}; + +type TurnPromptActionId = 'primary' | 'secondary'; + +type TurnPromptActionMeaning = + | 'end-turn' + | 'review-battlefield' + | 'continue-command' + | 'abandon-unacted-and-end' + | 'overwrite-save' + | 'load-save' + | 'cancel'; + +type TurnPromptButtonView = { + id: TurnPromptActionId; + label: string; + meaning: TurnPromptActionMeaning; + dangerous: boolean; + background: Phaser.GameObjects.Rectangle; + text: Phaser.GameObjects.Text; + hint: Phaser.GameObjects.Text; }; type DeploymentSlot = { @@ -3857,6 +3884,11 @@ export class BattleScene extends Phaser.Scene { private turnPromptMode?: TurnPromptMode; private turnPromptPrimaryAction?: () => void; private turnPromptSecondaryAction?: () => void; + private turnPromptTitle?: string; + private turnPromptBody?: string; + private turnPromptFocusedAction?: TurnPromptActionId; + private turnPromptCancelActionId?: TurnPromptActionId; + private turnPromptButtonViews: TurnPromptButtonView[] = []; private mapResultPopupObjects: Phaser.GameObjects.Text[] = []; private mapResultPopupPeakCount = 0; private mapResultPopupLast?: { @@ -4051,6 +4083,11 @@ export class BattleScene extends Phaser.Scene { this.turnPromptMode = undefined; this.turnPromptPrimaryAction = undefined; this.turnPromptSecondaryAction = undefined; + this.turnPromptTitle = undefined; + this.turnPromptBody = undefined; + this.turnPromptFocusedAction = undefined; + this.turnPromptCancelActionId = undefined; + this.turnPromptButtonViews = []; this.turnText = undefined; this.battleTitleText = undefined; this.objectiveTrackerText = undefined; @@ -4315,7 +4352,7 @@ export class BattleScene extends Phaser.Scene { return; } if (this.turnPromptObjects.length > 0) { - this.activateTurnPromptPrimaryAction(); + this.activateTurnPromptFocusedAction(); return; } @@ -4351,7 +4388,7 @@ export class BattleScene extends Phaser.Scene { } if (this.turnPromptObjects.length > 0) { - this.activateTurnPromptSecondaryAction(); + this.activateTurnPromptCancelAction(); return; } @@ -4403,6 +4440,9 @@ export class BattleScene extends Phaser.Scene { if (this.battleEventObjects.length > 0) { return; } + if (this.handleTurnPromptNavigationKey(event)) { + return; + } if (this.handleSaveSlotPanelKey(event)) { return; } @@ -10444,6 +10484,13 @@ export class BattleScene extends Phaser.Scene { if (!this.enemyIntentForecastEnabled()) { return '아군 턴을 종료하시겠습니까?'; } + return `${this.enemyIntentTurnEndSummary()}\n이대로 턴을 종료할까요?`; + } + + private enemyIntentTurnEndSummary() { + if (!this.enemyIntentForecastEnabled()) { + return '적 예상 정보 없음'; + } const groups = new Map(); const plans = this.enemyIntentForecasts.size > 0 ? Array.from(this.enemyIntentForecasts.values()) @@ -10460,13 +10507,16 @@ export class BattleScene extends Phaser.Scene { } groups.set(plan.target.id, group); }); - const summaries = Array.from(groups.values()) - .sort((left, right) => right.immediate - left.immediate || right.approach - left.approach) - .slice(0, 2) - .map((group) => `${group.target.name} 공${group.immediate}·추${group.approach}`); + const sortedGroups = Array.from(groups.values()) + .sort((left, right) => right.immediate - left.immediate || right.approach - left.approach); + const visibleGroups = sortedGroups.slice(0, 2); + const omittedCount = Math.max(0, sortedGroups.length - visibleGroups.length); + const summaries = visibleGroups.map( + (group) => `${group.target.name} 공${group.immediate}·추${group.approach}` + ); return summaries.length > 0 - ? `적 예상 · ${summaries.join(' · ')}\n이대로 턴을 종료할까요?` - : '적 예상 · 즉시 공격 없음\n이대로 턴을 종료할까요?'; + ? `적 예상 · ${summaries.join(' · ')}${omittedCount > 0 ? ` · 외 ${omittedCount}명` : ''}` + : '적 예상 · 즉시 공격 없음'; } private enemyIntentTargetCounts(unitId: string) { @@ -20495,12 +20545,7 @@ export class BattleScene extends Phaser.Scene { } if (action === 'endTurn') { - this.showTurnEndPrompt(undefined, { - title: '턴 종료 확인', - body: this.enemyIntentTurnEndBody(), - primaryLabel: '턴 종료', - secondaryLabel: '계속 지휘' - }); + this.showManualTurnEndPrompt(); return; } if (action === 'threat') { @@ -20544,6 +20589,38 @@ export class BattleScene extends Phaser.Scene { } } + private showManualTurnEndPrompt() { + const unactedAllies = this.actionableAllies(); + if (unactedAllies.length === 0) { + this.showTurnEndPrompt(undefined, { + title: '턴 종료 확인', + body: this.enemyIntentTurnEndBody(), + primaryLabel: '턴 종료', + secondaryLabel: '계속 지휘', + primaryMeaning: 'end-turn', + secondaryMeaning: 'continue-command' + }); + return; + } + + const visibleNames = unactedAllies.slice(0, 4).map((unit) => unit.name); + const omittedNames = Math.max(0, unactedAllies.length - visibleNames.length); + const unactedNames = `${visibleNames.join(' · ')}${omittedNames > 0 ? ` 외 ${omittedNames}명` : ''}`; + this.showTurnEndPrompt(undefined, { + title: `미행동 ${unactedAllies.length}명 · 턴 종료 확인`, + body: `미행동 장수 · ${unactedNames}\n${this.enemyIntentTurnEndSummary()}\n행동을 포기하고 턴을 종료할까요?`, + primaryLabel: '계속 지휘', + secondaryLabel: '미행동 포기 후 종료', + primaryAction: () => this.hideTurnEndPrompt(), + secondaryAction: () => this.endAllyTurn(), + initialFocus: 'primary', + cancelActionId: 'primary', + primaryMeaning: 'continue-command', + secondaryMeaning: 'abandon-unacted-and-end', + secondaryDangerous: true + }); + } + private hideMapMenu() { this.mapMenuObjects.forEach((object) => object.destroy()); this.mapMenuObjects = []; @@ -27760,6 +27837,32 @@ export class BattleScene extends Phaser.Scene { private showTurnEndPrompt(message?: string, options: TurnEndPromptOptions = {}) { this.hideTurnEndPrompt(); this.turnPromptMode = options.mode ?? 'turn-end'; + const promptTitle = options.title ?? '모든 아군의 행동이 종료되었습니다.'; + const promptBody = options.body ?? this.enemyIntentTurnEndBody(); + const primaryLabel = options.primaryLabel ?? '턴 종료'; + const secondaryLabel = options.secondaryLabel ?? '전장 확인'; + const primaryAction = options.primaryAction ?? (() => this.endAllyTurn()); + const secondaryAction = options.secondaryAction ?? (() => this.hideTurnEndPrompt()); + const primaryMeaning = options.primaryMeaning ?? ( + this.turnPromptMode === 'save-overwrite-confirm' + ? 'overwrite-save' + : this.turnPromptMode === 'load-confirm' + ? 'load-save' + : 'end-turn' + ); + const secondaryMeaning = options.secondaryMeaning ?? ( + this.turnPromptMode === 'turn-end' ? 'review-battlefield' : 'cancel' + ); + this.turnPromptTitle = promptTitle; + this.turnPromptBody = promptBody; + this.turnPromptPrimaryAction = primaryAction; + this.turnPromptSecondaryAction = secondaryAction; + this.turnPromptFocusedAction = options.initialFocus ?? 'primary'; + this.turnPromptCancelActionId = options.cancelActionId ?? 'secondary'; + const expandedPrompt = promptBody.split('\n').length >= 3; + const promptHeight = expandedPrompt ? 202 : 170; + const buttonY = promptHeight - 46; + const hintY = promptHeight - 19; const shade = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x03070b, 0.46); shade.setOrigin(0); @@ -27777,7 +27880,7 @@ export class BattleScene extends Phaser.Scene { this.turnPromptObjects.push(shade); const width = this.battleUiLength(388); - const height = this.battleUiLength(162); + const height = this.battleUiLength(promptHeight); const left = this.layout.mapX + this.layout.mapWidth / 2 - width / 2; const top = this.layout.mapY + this.layout.mapHeight / 2 - height / 2; const panel = this.add.rectangle(left, top, width, height, 0x101821, 0.96); @@ -27786,7 +27889,7 @@ export class BattleScene extends Phaser.Scene { panel.setStrokeStyle(this.battleUiLength(2), palette.gold, 0.9); this.turnPromptObjects.push(panel); - const title = this.add.text(left + width / 2, top + this.battleUiLength(26), options.title ?? '모든 아군의 행동이 종료되었습니다.', { + const title = this.add.text(left + width / 2, top + this.battleUiLength(26), promptTitle, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: this.battleUiFontSize(20), color: '#f2e3bf', @@ -27796,79 +27899,136 @@ export class BattleScene extends Phaser.Scene { title.setDepth(41); this.turnPromptObjects.push(title); - const body = this.add.text(left + width / 2, top + this.battleUiLength(58), options.body ?? this.enemyIntentTurnEndBody(), { + const body = this.add.text(left + width / 2, top + this.battleUiLength(52), promptBody, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: this.battleUiFontSize(17), color: '#d4dce6', align: 'center', wordWrap: { width: width - this.battleUiLength(44), useAdvancedWrap: true } }); - body.setOrigin(0.5); + body.setOrigin(0.5, 0); body.setDepth(41); this.turnPromptObjects.push(body); - const primaryLabel = options.primaryLabel ?? '턴 종료'; - const secondaryLabel = options.secondaryLabel ?? '전장 확인'; - const primaryAction = options.primaryAction ?? (() => this.endAllyTurn()); - const secondaryAction = options.secondaryAction ?? (() => this.hideTurnEndPrompt()); - this.turnPromptPrimaryAction = primaryAction; - this.turnPromptSecondaryAction = secondaryAction; const buttonWidth = this.battleUiLength(136); const buttons = [ - { label: primaryLabel, keyHint: 'Enter', x: left + this.battleUiLength(119), primary: true }, - { label: secondaryLabel, keyHint: 'Esc', x: left + this.battleUiLength(269), primary: false } + { + id: 'primary' as const, + label: primaryLabel, + meaning: primaryMeaning, + dangerous: options.primaryDangerous ?? false, + x: left + this.battleUiLength(119) + }, + { + id: 'secondary' as const, + label: secondaryLabel, + meaning: secondaryMeaning, + dangerous: options.secondaryDangerous ?? false, + x: left + this.battleUiLength(269) + } ]; - buttons.forEach(({ label, keyHint, x, primary }) => { - const bg = this.add.rectangle(x, top + this.battleUiLength(116), buttonWidth, this.battleUiLength(36), 0x1a2630, 0.95); + buttons.forEach(({ id, label, meaning, dangerous, x }) => { + const bg = this.add.rectangle(x, top + this.battleUiLength(buttonY), buttonWidth, this.battleUiLength(36), 0x1a2630, 0.95); bg.setDepth(41); - bg.setStrokeStyle(this.battleUiLength(1), primary ? palette.gold : palette.blue, 0.72); bg.setInteractive({ useHandCursor: true }); - bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98)); - bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.95)); - bg.on('pointerdown', () => (primary ? this.activateTurnPromptPrimaryAction(true) : this.activateTurnPromptSecondaryAction(true))); + bg.on('pointerover', () => this.focusTurnPromptAction(id)); + bg.on('pointerdown', () => this.activateTurnPromptAction(id, true)); this.turnPromptObjects.push(bg); - const text = this.add.text(x, top + this.battleUiLength(116), label, { + const text = this.add.text(x, top + this.battleUiLength(buttonY), label, { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', - fontSize: this.battleUiFontSize(16), + fontSize: this.battleUiFontSize(label.length > 9 ? 13 : 16), color: '#f2e3bf', fontStyle: '700' }); text.setOrigin(0.5); text.setDepth(42); text.setInteractive({ useHandCursor: true }); - text.on('pointerdown', () => (primary ? this.activateTurnPromptPrimaryAction(true) : this.activateTurnPromptSecondaryAction(true))); + text.on('pointerover', () => this.focusTurnPromptAction(id)); + text.on('pointerdown', () => this.activateTurnPromptAction(id, true)); this.turnPromptObjects.push(text); - const hint = this.add.text(x, top + this.battleUiLength(143), keyHint, { + const hint = this.add.text(x, top + this.battleUiLength(hintY), '', { fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', fontSize: this.battleUiFontSize(10), - color: primary ? '#cdbb8a' : '#91a7bd', + color: '#91a7bd', fontStyle: '700' }); hint.setOrigin(0.5); hint.setDepth(42); this.turnPromptObjects.push(hint); + this.turnPromptButtonViews.push({ id, label, meaning, dangerous, background: bg, text, hint }); }); + this.renderTurnPromptFocus(); void message; } - private activateTurnPromptPrimaryAction(fromPointer = false) { - if (!this.turnPromptPrimaryAction) { - return; + private handleTurnPromptNavigationKey(event: KeyboardEvent) { + if (this.turnPromptObjects.length === 0) { + return false; + } + const requestedFocus = + event.code === 'ArrowLeft' + ? 'primary' + : event.code === 'ArrowRight' + ? 'secondary' + : event.code === 'Tab' + ? this.turnPromptFocusedAction === 'primary' && !event.shiftKey + ? 'secondary' + : 'primary' + : undefined; + if (!requestedFocus) { + return false; } - if (fromPointer) { - this.suppressNextLeftClick = true; - } + event.preventDefault(); + this.focusTurnPromptAction(requestedFocus); soundDirector.playSelect(); - this.turnPromptPrimaryAction(); + return true; } - private activateTurnPromptSecondaryAction(fromPointer = false) { - const action = this.turnPromptSecondaryAction ?? (() => this.hideTurnEndPrompt()); + private focusTurnPromptAction(actionId: TurnPromptActionId) { + if (this.turnPromptObjects.length === 0 || this.turnPromptFocusedAction === actionId) { + return; + } + this.turnPromptFocusedAction = actionId; + this.renderTurnPromptFocus(); + } + + private renderTurnPromptFocus() { + this.turnPromptButtonViews.forEach((view) => { + const focused = this.turnPromptFocusedAction === view.id; + const cancel = this.turnPromptCancelActionId === view.id; + const tone = view.dangerous ? 0xd46a54 : view.id === 'primary' ? palette.gold : palette.blue; + view.background.setFillStyle( + focused ? (view.dangerous ? 0x4a2320 : 0x283947) : 0x1a2630, + focused ? 0.99 : 0.95 + ); + view.background.setStrokeStyle(this.battleUiLength(focused ? 2 : 1), tone, focused ? 1 : 0.72); + view.text.setColor(focused ? '#fff8df' : '#d4dce6'); + view.hint + .setText(focused ? (cancel ? 'Enter · Esc' : 'Enter') : cancel ? 'Esc' : '←/→ · Tab') + .setColor(view.dangerous ? '#ffb6a6' : cancel ? '#cdbb8a' : '#91a7bd'); + }); + } + + private activateTurnPromptFocusedAction() { + this.activateTurnPromptAction(this.turnPromptFocusedAction ?? 'primary'); + } + + private activateTurnPromptCancelAction() { + this.activateTurnPromptAction(this.turnPromptCancelActionId ?? 'secondary'); + } + + private activateTurnPromptAction(actionId: TurnPromptActionId, fromPointer = false) { + const action = actionId === 'primary' + ? this.turnPromptPrimaryAction + : this.turnPromptSecondaryAction; + if (!action) { + return; + } if (fromPointer) { this.suppressNextLeftClick = true; } @@ -27876,12 +28036,25 @@ export class BattleScene extends Phaser.Scene { action(); } + private activateTurnPromptPrimaryAction(fromPointer = false) { + this.activateTurnPromptAction('primary', fromPointer); + } + + private activateTurnPromptSecondaryAction(fromPointer = false) { + this.activateTurnPromptAction('secondary', fromPointer); + } + private hideTurnEndPrompt() { this.turnPromptObjects.forEach((object) => object.destroy()); this.turnPromptObjects = []; this.turnPromptMode = undefined; this.turnPromptPrimaryAction = undefined; this.turnPromptSecondaryAction = undefined; + this.turnPromptTitle = undefined; + this.turnPromptBody = undefined; + this.turnPromptFocusedAction = undefined; + this.turnPromptCancelActionId = undefined; + this.turnPromptButtonViews = []; } private pushBattleLog(message: string) { @@ -31821,6 +31994,23 @@ export class BattleScene extends Phaser.Scene { : null, turnPromptVisible: this.turnPromptObjects.length > 0, turnPromptMode: this.turnPromptMode ?? null, + turnPrompt: this.turnPromptObjects.length > 0 + ? { + mode: this.turnPromptMode ?? null, + title: this.turnPromptTitle ?? '', + body: this.turnPromptBody ?? '', + focusedAction: this.turnPromptFocusedAction ?? null, + cancelAction: this.turnPromptCancelActionId ?? null, + buttons: this.turnPromptButtonViews.map((view) => ({ + id: view.id, + label: view.label, + meaning: view.meaning, + dangerous: view.dangerous, + focused: this.turnPromptFocusedAction === view.id, + escapeCancels: this.turnPromptCancelActionId === view.id + })) + } + : null, mapResultFeedback: { activeCount: this.mapResultPopupObjects.filter((object) => object.active).length, peakCount: this.mapResultPopupPeakCount, diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 1fb9c8c..5f1dabd 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -344,7 +344,7 @@ type CampTabButtonView = { type VictoryRewardCardId = CampaignVictoryRewardCategoryId; -type VictoryRewardActionId = 'equipment' | 'supplies' | 'sortie' | 'close'; +type VictoryRewardActionId = 'equipment' | 'supplies' | 'guided' | 'sortie' | 'close'; type VictoryRewardIcon = | { kind: 'mark'; mark: string } @@ -368,6 +368,7 @@ type VictoryRewardActionView = { id: VictoryRewardActionId; label: string; button: Phaser.GameObjects.Rectangle; + primary: boolean; }; type CampActionButtonView = { @@ -377,6 +378,23 @@ type CampActionButtonView = { hovered: boolean; }; +type FirstCampGuidedAction = + | { + kind: 'visit'; + label: '북문 정찰막 둘러보기'; + targetId: typeof firstPursuitScoutVisitId; + } + | { + kind: 'dialogue'; + label: string; + targetId: string; + } + | { + kind: 'sortie'; + label: '출진 준비'; + targetId: null; + }; + type EquipmentSwapRequest = { unitId: string; slot: EquipmentSlot; @@ -11517,6 +11535,9 @@ export class CampScene extends Phaser.Scene { private reportFormationHistoryUndoState?: ReportFormationHistoryUndoState; private tabButtons: CampTabButtonView[] = []; private sortieCommandButton?: Phaser.GameObjects.Rectangle; + private sortieCommandLabel?: Phaser.GameObjects.Text; + private sortieBypassButton?: Phaser.GameObjects.Rectangle; + private sortieBypassLabel?: Phaser.GameObjects.Text; private sortiePrimaryActionButton?: CampActionButtonView; private sortieNextActionGuideBackground?: Phaser.GameObjects.Rectangle; private sortieNextActionGuideText?: Phaser.GameObjects.Text; @@ -11672,6 +11693,10 @@ export class CampScene extends Phaser.Scene { this.reportFormationHistoryView = undefined; this.reportFormationHistoryUndoState = undefined; this.tabButtons = []; + this.sortieCommandButton = undefined; + this.sortieCommandLabel = undefined; + this.sortieBypassButton = undefined; + this.sortieBypassLabel = undefined; this.sortiePrimaryActionButton = undefined; this.sortieNextActionGuideBackground = undefined; this.sortieNextActionGuideText = undefined; @@ -13025,6 +13050,85 @@ export class CampScene extends Phaser.Scene { ); } + private firstCampGuidedAction(): FirstCampGuidedAction | undefined { + if ( + !this.isFirstSortiePrepSlice() || + this.currentCampBattleId() !== campBattleIds.first + ) { + return undefined; + } + if (!this.completedCampVisits().includes(firstPursuitScoutVisitId)) { + return { + kind: 'visit', + label: '북문 정찰막 둘러보기', + targetId: firstPursuitScoutVisitId + }; + } + const followup = this.firstBattleCampFollowup(); + if ( + followup && + !this.completedCampDialogues().includes(followup.targetDialogueId) + ) { + return { + kind: 'dialogue', + label: `${followup.mentorName}와 핵심 회고`, + targetId: followup.targetDialogueId + }; + } + return { + kind: 'sortie', + label: '출진 준비', + targetId: null + }; + } + + private runFirstCampGuidedAction() { + const action = this.firstCampGuidedAction(); + if (!action || action.kind === 'sortie') { + this.showRequestedSortiePrep(); + return; + } + if (action.kind === 'visit') { + this.activeTab = 'visit'; + this.selectedVisitId = action.targetId; + this.render(); + this.startCampNavigation('CampVisitExplorationScene', { visitId: action.targetId }); + return; + } + this.activeTab = 'dialogue'; + this.selectedDialogueId = action.targetId; + this.render(); + } + + private campPrimaryActionLabel(flow = this.currentSortieFlow()) { + if (this.isFinalEpilogueFlow(flow) && this.campaign?.step === flow.campaignStep) { + return '엔딩 보기'; + } + const guidedAction = this.firstCampGuidedAction(); + return guidedAction?.kind === 'visit' + ? '정찰막 둘러보기' + : guidedAction?.label ?? '출진 준비'; + } + + private refreshCampPrimaryActionPresentation() { + const label = this.campPrimaryActionLabel(); + const guidedAction = this.firstCampGuidedAction(); + const bypassVisible = Boolean(guidedAction && guidedAction.kind !== 'sortie'); + this.sortieCommandLabel + ?.setText(label) + .setFontSize( + bypassVisible + ? Array.from(label).length > 7 ? 12 : 14 + : Array.from(label).length > 8 ? 13 : 16 + ); + [this.sortieBypassButton, this.sortieBypassLabel].forEach((object) => { + object?.setVisible(bypassVisible); + if (object?.input) { + object.input.enabled = bypassVisible; + } + }); + } + private hasPendingFirstBattleCamaraderieMemory() { const dialogue = this.firstBattleCamaraderieDialogue(); return Boolean( @@ -13102,16 +13206,36 @@ export class CampScene extends Phaser.Scene { soundDirector.playSelect(); this.showCampSaveSlotPanel(); }); - const flow = this.currentSortieFlow(); - const storyButtonLabel = this.isFinalEpilogueFlow(flow) && this.campaign?.step === flow.campaignStep ? '엔딩 보기' : '출진 준비'; - this.sortieCommandButton = this.addCommandButton(storyButtonLabel, 1152, 38, 142, () => { + const guidedAction = this.firstCampGuidedAction(); + const splitSortieCommand = Boolean(guidedAction && guidedAction.kind !== 'sortie'); + const sortieCommand = this.addCommandButton( + this.campPrimaryActionLabel(), + splitSortieCommand ? 1132 : 1152, + 38, + splitSortieCommand ? 116 : 142, + () => { soundDirector.playSelect(); + const flow = this.currentSortieFlow(); if (this.isFinalEpilogueFlow(flow)) { this.startVictoryStory(); return; } - this.showSortiePrep(); - }, true); + this.runFirstCampGuidedAction(); + }, + true + ); + this.sortieCommandButton = sortieCommand.background; + this.sortieCommandLabel = sortieCommand.label; + if (splitSortieCommand) { + const sortieBypass = this.addCommandButton('바로 출진', 1230, 38, 72, () => { + soundDirector.playSelect(); + this.showRequestedSortiePrep(); + }); + sortieBypass.label.setFontSize(13); + this.sortieBypassButton = sortieBypass.background; + this.sortieBypassLabel = sortieBypass.label; + } + this.refreshCampPrimaryActionPresentation(); } private showCampSaveSlotPanel() { @@ -13472,6 +13596,9 @@ export class CampScene extends Phaser.Scene { const panelY = 90; const panelWidth = 884; const panelHeight = 546; + const firstCampGuidedAction = report.battleId === campBattleIds.first + ? this.firstCampGuidedAction() + : undefined; const shade = this.trackVictoryReward( this.add.rectangle(0, 0, campLegacyCanvasWidth, campLegacyCanvasHeight, 0x020406, 0.78) @@ -13683,15 +13810,24 @@ export class CampScene extends Phaser.Scene { ); noteText.setDepth(depth + 2); - const actionDefinitions: Array<{ id: VictoryRewardActionId; label: string; primary?: boolean }> = [ - { id: 'equipment', label: '장비 확인' }, - { id: 'supplies', label: '보급 확인' }, - { id: 'sortie', label: '출진 준비', primary: true }, - { id: 'close', label: '닫기' } - ]; - const buttonWidth = 158; + const actionDefinitions: Array<{ id: VictoryRewardActionId; label: string; primary?: boolean }> = + firstCampGuidedAction + ? [ + { id: 'equipment', label: '장비 확인' }, + { id: 'supplies', label: '보급 확인' }, + { id: 'guided', label: firstCampGuidedAction.label, primary: true }, + { id: 'sortie', label: '바로 출진' }, + { id: 'close', label: '닫기' } + ] + : [ + { id: 'equipment', label: '장비 확인' }, + { id: 'supplies', label: '보급 확인' }, + { id: 'sortie', label: '출진 준비', primary: true }, + { id: 'close', label: '닫기' } + ]; + const buttonWidth = firstCampGuidedAction ? 156 : 158; const buttonHeight = 48; - const actionGap = 14; + const actionGap = firstCampGuidedAction ? 8 : 14; const totalActionWidth = actionDefinitions.length * buttonWidth + (actionDefinitions.length - 1) * actionGap; const actionStartX = panelX + Math.floor((panelWidth - totalActionWidth) / 2); actionDefinitions.forEach((definition, index) => { @@ -13708,7 +13844,16 @@ export class CampScene extends Phaser.Scene { button.setInteractive({ useHandCursor: true }); const label = this.trackVictoryReward( - this.add.text(buttonX + buttonWidth / 2, buttonY + buttonHeight / 2, definition.label, this.textStyle(14, definition.primary ? '#fff2b8' : '#f2e3bf', true)) + this.add.text( + buttonX + buttonWidth / 2, + buttonY + buttonHeight / 2, + definition.label, + this.textStyle( + definition.id === 'guided' && Array.from(definition.label).length > 8 ? 12 : 14, + definition.primary ? '#fff2b8' : '#f2e3bf', + true + ) + ) ); label.setOrigin(0.5); label.setDepth(depth + 3); @@ -13723,7 +13868,12 @@ export class CampScene extends Phaser.Scene { target.on('pointerout', () => button.setFillStyle(restingFill, 0.99)); target.on('pointerdown', run); }); - this.victoryRewardActions.push({ id: definition.id, label: definition.label, button }); + this.victoryRewardActions.push({ + id: definition.id, + label: definition.label, + button, + primary: Boolean(definition.primary) + }); }); soundDirector.playRewardRevealCue(); } @@ -13759,6 +13909,10 @@ export class CampScene extends Phaser.Scene { this.render(); return; } + if (action === 'guided') { + this.runFirstCampGuidedAction(); + return; + } if (action === 'sortie' || (action === 'close' && this.openSortiePrepOnCreate)) { this.showRequestedSortiePrep(); } @@ -14110,7 +14264,7 @@ export class CampScene extends Phaser.Scene { target.on('pointerout', () => setHovered(false)); target.on('pointerdown', action); }); - return bg; + return { background: bg, label: text }; } private render() { @@ -14124,6 +14278,7 @@ export class CampScene extends Phaser.Scene { this.acknowledgePendingVictoryRewardCategories(['supplies']); } this.visitedTabs.add(this.activeTab); + this.refreshCampPrimaryActionPresentation(); this.updateTabButtons(); this.renderUnitColumn(); this.renderReportPanel(); @@ -26042,6 +26197,7 @@ export class CampScene extends Phaser.Scene { : false; const cityEquipmentSortieStatus = this.cityEquipmentSortieStatus(); const firstBattleCampFollowup = this.firstBattleCampFollowup(); + const firstCampGuidedAction = this.firstCampGuidedAction(); const firstBattleCamaraderieMemory = this.firstBattleCamaraderieMemory(); const firstBattleCamaraderieDialogue = this.firstBattleCamaraderieDialogue(); const thirdBattlePriorityReturn = this.thirdBattlePriorityReturnDialogue(); @@ -26090,6 +26246,7 @@ export class CampScene extends Phaser.Scene { actions: this.victoryRewardActions.map((action) => ({ id: action.id, label: action.label, + primary: action.primary, bounds: this.sortieObjectBoundsDebug(action.button), interactive: Boolean(action.button.input?.enabled) })) @@ -26148,8 +26305,28 @@ export class CampScene extends Phaser.Scene { lineWidth: button.bg.lineWidth })), sortieCommand: { + label: this.sortieCommandLabel?.text ?? null, + guidedKind: firstCampGuidedAction?.kind ?? null, + targetId: firstCampGuidedAction?.targetId ?? null, bounds: this.sortieObjectBoundsDebug(this.sortieCommandButton), - interactive: Boolean(this.sortieCommandButton?.input?.enabled) + interactive: Boolean( + this.sortieCommandButton?.input?.enabled && + this.sortieCommandLabel?.input?.enabled + ), + bypass: + this.sortieBypassButton?.visible && + this.sortieBypassLabel?.visible + ? { + label: this.sortieBypassLabel.text, + bounds: this.sortieObjectBoundsDebug( + this.sortieBypassButton + ), + interactive: Boolean( + this.sortieBypassButton.input?.enabled && + this.sortieBypassLabel.input?.enabled + ) + } + : null }, firstVictoryFollowup: firstBattleCampFollowup ? { diff --git a/src/game/scenes/StoryScene.ts b/src/game/scenes/StoryScene.ts index ab4ef60..6929266 100644 --- a/src/game/scenes/StoryScene.ts +++ b/src/game/scenes/StoryScene.ts @@ -37,6 +37,10 @@ import { resolveFirstBattleVictoryNarrative, type FirstBattleVictoryNarrativeMemory } from '../data/firstBattleNarrativeMemory'; +import { + resolveXuzhouStayNarrativeMemory, + type XuzhouStayNarrativeMemory +} from '../data/xuzhouStayNarrativeMemory'; import { ensureUnitAnimations, loadUnitBaseSheets, @@ -53,7 +57,11 @@ import { } from '../data/storyCutscenes'; import { prologueOpeningPages } from '../data/prologueVillage'; import { type PortraitKey, type StoryPage } from '../data/scenario'; -import { campaignVictoryRewardPresentation, getFirstBattleReport } from '../state/campaignState'; +import { + campaignVictoryRewardPresentation, + getCampaignState, + getFirstBattleReport +} from '../state/campaignState'; import { releaseTextureSource } from '../render/loaderLifecycle'; import { isVisualMotionReduced } from '../settings/visualMotion'; import { palette } from '../ui/palette'; @@ -222,6 +230,7 @@ export class StoryScene extends Phaser.Scene { private ownedStoryTextureKeys = new Set(); private rewardDisplay: StoryRewardDisplayModel = this.emptyRewardDisplay(); private firstBattleNarrativeMemory?: FirstBattleVictoryNarrativeMemory; + private xuzhouStayNarrativeMemory?: XuzhouStayNarrativeMemory; private visualMotionReduced = false; private storyEnvironmentProfile?: StoryEnvironmentProfile; private storyEnvironmentBackgroundKey?: string; @@ -248,8 +257,22 @@ export class StoryScene extends Phaser.Scene { ) ? resolveFirstBattleVictoryNarrative(sourcePages, getFirstBattleReport()) : undefined; - this.pages = firstBattleAftermath?.pages ?? sourcePages; + const campaign = getCampaignState(); + const xuzhouStayNarrative = resolveXuzhouStayNarrativeMemory( + firstBattleAftermath?.pages ?? sourcePages, + { + battleId: this.presentationBattleId, + stage: this.presentationStage, + campaign, + historicalReport: this.presentationBattleId + ? campaign.battleHistory[this.presentationBattleId] + : undefined + } + ); + this.pages = xuzhouStayNarrative.pages; this.firstBattleNarrativeMemory = firstBattleAftermath?.memory; + this.xuzhouStayNarrativeMemory = + xuzhouStayNarrative.memory; this.selectedStoryBackgroundKeys = storyBackgroundKeysForPages(this.pages); this.presentationWash = undefined; this.pageIndex = 0; @@ -349,6 +372,21 @@ export class StoryScene extends Phaser.Scene { ) } : null, + xuzhouStayNarrativeMemory: + this.xuzhouStayNarrativeMemory + ? { + ...this.xuzhouStayNarrativeMemory, + appliedPageIds: [ + ...this.xuzhouStayNarrativeMemory.appliedPageIds + ], + currentPageApplied: Boolean( + page && + this.xuzhouStayNarrativeMemory.appliedPageIds.includes( + page.id + ) + ) + } + : null, environment: this.storyEnvironmentDebugState(), presentation: this.storyPresentationDebugState(), assetStreaming: {