feat: guide early stays and remember player choices

This commit is contained in:
2026-07-27 19:41:10 +09:00
parent eec4896002
commit c22f77b4e1
11 changed files with 2121 additions and 93 deletions

View File

@@ -37,6 +37,7 @@
"verify:city-equipment-preparation": "node scripts/verify-city-equipment-preparation.mjs", "verify:city-equipment-preparation": "node scripts/verify-city-equipment-preparation.mjs",
"verify:city-stays:browser": "node scripts/verify-city-stay-browser.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-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:xuzhou-stay-payoff:browser": "node scripts/verify-xuzhou-stay-battle-payoff-browser.mjs",
"verify:prologue-village": "node scripts/verify-prologue-village-data.mjs", "verify:prologue-village": "node scripts/verify-prologue-village-data.mjs",
"verify:first-battle-camp-followup": "node scripts/verify-first-battle-camp-followup.mjs", "verify:first-battle-camp-followup": "node scripts/verify-first-battle-camp-followup.mjs",

View File

@@ -91,8 +91,70 @@ try {
); );
await page.evaluate(async () => { 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 waitForExplorationReady(page);
await page.waitForTimeout(380); await page.waitForTimeout(380);
@@ -269,7 +331,7 @@ try {
); );
}, },
{ key: sceneKey, actorId: 'jian-yong' }, { key: sceneKey, actorId: 'jian-yong' },
{ timeout: 12000 } { timeout: 30000 }
); );
exploration = await readExploration(page); exploration = await readExploration(page);
assert( assert(
@@ -502,7 +564,7 @@ try {
); );
}, },
{ key: sceneKey, exitId: 'camp-exit' }, { key: sceneKey, exitId: 'camp-exit' },
{ timeout: 12000 } { timeout: 30000 }
); );
await page.keyboard.press('e'); await page.keyboard.press('e');
await waitForCampReturn(page); await waitForCampReturn(page);
@@ -517,6 +579,16 @@ try {
camp.firstPursuitScoutMemory.activeForNextSortie, camp.firstPursuitScoutMemory.activeForNextSortie,
true 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( assertRelevantProgressEqual(
savesAfterChoice, savesAfterChoice,
await readCampaignSaves(page), await readCampaignSaves(page),
@@ -540,7 +612,7 @@ try {
console.log( console.log(
`Verified the first-pursuit camp exploration at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` + `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, ` + `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.' 'CampScene return, and current/slot-1 save persistence.'
); );
} finally { } 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) { async function readCampaignSaves(page) {
return page.evaluate(() => { return page.evaluate(() => {
const parse = (key) => { const parse = (key) => {

View File

@@ -49,6 +49,8 @@ try {
'tactical reactions exclude undeployed or defeated officers, the Wolong narrative objectives gate victory, ' + '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, ' + '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, ' + '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.' 'the persistent turn-end action reopens it, and the right-click menu follows the pointer.'
); );
} finally { } finally {
@@ -440,6 +442,15 @@ async function verifyBattlePointerFlow(page) {
battle?.actedUnitIds?.includes(unitId) battle?.actedUnitIds?.includes(unitId)
); );
}, lastAllySetup.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 clickTurnPromptSecondary(page);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false); 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()?.turnPromptVisible === true &&
window.__HEROS_DEBUG__?.battle()?.turnPromptMode === 'turn-end' window.__HEROS_DEBUG__?.battle()?.turnPromptMode === 'turn-end'
)); ));
await clickTurnPromptSecondary(page); await page.evaluate(() => {
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false); 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); const mapMenuPointer = await findRightmostEmptyBattleTile(page);
await page.mouse.click(mapMenuPointer.x, mapMenuPointer.y, { button: 'right' }); 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) { async function verifyBattleEventOverlayInputBlock(page) {

View File

@@ -33,6 +33,46 @@ async function moveLegacyUi(page, x, y, options) {
await page.mouse.move(x * legacyUiScale, y * legacyUiScale, 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) { async function clickBattleDeploymentStart(page) {
const point = await page.evaluate(() => { const point = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); 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')?.new === true &&
firstArrivalReward.cards.find((card) => card.id === 'unlocks')?.actionId === 'sortie' && firstArrivalReward.cards.find((card) => card.id === 'unlocks')?.actionId === 'sortie' &&
firstArrivalReward.cards.every((card) => boundsWithinFhdViewport(card.bounds)) && 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)) && 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 firstCampProbe.state?.sortieRoster
?.filter((unit) => ['guan-yu', 'zhang-fei', 'jian-yong'].includes(unit.id)).length === 3 && ?.filter((unit) => ['guan-yu', 'zhang-fei', 'jian-yong'].includes(unit.id)).length === 3 &&
firstCampProbe.state?.sortieRoster 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')?.visualState === 'active' &&
firstCampProbe.state?.campTabs?.find((tab) => tab.id === 'visit')?.newBadgeVisible === true && 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), 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'); const firstArrivalEquipmentPoint = await readCampVictoryRewardCardControlPoint(page, 'equipment');
await page.mouse.click(firstArrivalEquipmentPoint.x, firstArrivalEquipmentPoint.y); 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)}` `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'); const firstCampBypassBounds = firstCampProbe.state?.sortieCommand?.bypass?.bounds;
await page.mouse.click(firstCampVisitTabPoint.x, firstCampVisitTabPoint.y); 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( await page.waitForFunction(
(visitId) => { () => window.__HEROS_DEBUG__?.camp?.()?.sortieVisible === true,
const camp = window.__HEROS_DEBUG__?.camp?.(); undefined,
return camp?.activeTab === 'visit' && camp?.selectedVisitId === visitId;
},
firstPursuitScoutVisitId,
{ timeout: 30000 } { timeout: 30000 }
); );
const firstPursuitScoutExplorationPoint = await readCampVisitExplorationControlPoint( await page.screenshot({
page, path: `${screenshotDir}/rc-first-camp-direct-sortie-bypass.png`,
firstPursuitScoutVisitId 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'); firstCampProbe = await page.evaluate(() => {
await page.screenshot({ path: `${screenshotDir}/rc-first-camp-scout-visit-cta.png`, fullPage: true }); const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
await assertCanvasPainted(page, 'first-camp scout visit exploration CTA'); return {
state: window.__HEROS_DEBUG__?.camp(),
await page.mouse.click(firstPursuitScoutExplorationPoint.x, firstPursuitScoutExplorationPoint.y); 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( await page.waitForFunction(
(visitId) => { (visitId) => {
const debug = window.__HEROS_DEBUG__; const debug = window.__HEROS_DEBUG__;
@@ -2936,7 +3016,7 @@ try {
firstPursuitExplorationEntry.viewport.height === 1080 && firstPursuitExplorationEntry.viewport.height === 1080 &&
firstPursuitExplorationEntry.requiredTexturesReady === true && firstPursuitExplorationEntry.requiredTexturesReady === true &&
firstPursuitExplorationEntry.actors?.find((actor) => actor.id === 'jian-yong')?.moved === false, 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(() => const firstPursuitJianYongInteraction = await page.evaluate(() =>
@@ -3077,7 +3157,14 @@ try {
completedFirstPursuitScoutVisit.decisionTextCount === 1 && completedFirstPursuitScoutVisit.decisionTextCount === 1 &&
completedFirstPursuitScoutVisit.choiceControlCount === 0 && completedFirstPursuitScoutVisit.choiceControlCount === 0 &&
completedFirstPursuitScoutVisit.preparationSummaryVisible === true && 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)}` `Expected the chosen scout visit to become one completed, visible preparation memory: ${JSON.stringify(completedFirstPursuitScoutVisit)}`
); );
assert( assert(
@@ -3123,7 +3210,14 @@ try {
`Expected camp tab hover styling to reset on pointerout: ${JSON.stringify(firstCampTabResetState?.campTabs)}` `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( await page.waitForFunction(
() => window.__HEROS_DEBUG__?.camp()?.activeTab === 'dialogue', () => window.__HEROS_DEBUG__?.camp()?.activeTab === 'dialogue',
undefined, undefined,
@@ -3278,6 +3372,10 @@ try {
completedFirstVictoryDialogueProbe.state?.campTabs?.find( completedFirstVictoryDialogueProbe.state?.campTabs?.find(
(tab) => tab.id === 'dialogue' (tab) => tab.id === 'dialogue'
)?.newBadgeVisible === true && )?.newBadgeVisible === true &&
completedFirstVictoryDialogueProbe.state?.sortieCommand?.label === '출진 준비' &&
completedFirstVictoryDialogueProbe.state?.sortieCommand?.guidedKind === 'sortie' &&
completedFirstVictoryDialogueProbe.state?.sortieCommand?.targetId === null &&
completedFirstVictoryDialogueProbe.state?.sortieCommand?.bypass === null &&
completedFirstVictoryDialogueProbe.remainingRowMarkerCount === 0 && completedFirstVictoryDialogueProbe.remainingRowMarkerCount === 0 &&
firstVictoryDialoguePersisted(completedFirstVictoryDialogueSave.current) && firstVictoryDialoguePersisted(completedFirstVictoryDialogueSave.current) &&
firstVictoryDialoguePersisted(completedFirstVictoryDialogueSave.slot1), firstVictoryDialoguePersisted(completedFirstVictoryDialogueSave.slot1),

View File

@@ -24,6 +24,7 @@ const checks = [
'scripts/verify-city-stay-data.mjs', 'scripts/verify-city-stay-data.mjs',
'scripts/verify-city-equipment-preparation.mjs', 'scripts/verify-city-equipment-preparation.mjs',
'scripts/verify-xuzhou-stay-battle-payoff.mjs', 'scripts/verify-xuzhou-stay-battle-payoff.mjs',
'scripts/verify-xuzhou-stay-narrative-memory.mjs',
'scripts/verify-prologue-village-data.mjs', 'scripts/verify-prologue-village-data.mjs',
'scripts/verify-first-battle-camp-followup.mjs', 'scripts/verify-first-battle-camp-followup.mjs',
'scripts/verify-first-battle-camaraderie-memory.mjs', 'scripts/verify-first-battle-camaraderie-memory.mjs',

View File

@@ -69,7 +69,7 @@ try {
`100% zoom, DPR ${desktopBrowserDeviceScaleFactor}: two exact deployment intent previews, ` + `100% zoom, DPR ${desktopBrowserDeviceScaleFactor}: two exact deployment intent previews, ` +
'aid +4 and encourage +1-turn single-use branches, Mi Zhu absence, no-information baseline, ' + '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, ' + '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 { } finally {
await browser?.close(); await browser?.close();
@@ -431,6 +431,236 @@ async function verifyAidBranch(page, renderer) {
)}` )}`
); );
await capture(page, screenshotPath(renderer, 'aid-result')); 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) { async function verifyEncourageBranch(page, renderer) {

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -1673,6 +1673,33 @@ type TurnEndPromptOptions = {
secondaryLabel?: string; secondaryLabel?: string;
primaryAction?: () => void; primaryAction?: () => void;
secondaryAction?: () => 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 = { type DeploymentSlot = {
@@ -3857,6 +3884,11 @@ export class BattleScene extends Phaser.Scene {
private turnPromptMode?: TurnPromptMode; private turnPromptMode?: TurnPromptMode;
private turnPromptPrimaryAction?: () => void; private turnPromptPrimaryAction?: () => void;
private turnPromptSecondaryAction?: () => 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 mapResultPopupObjects: Phaser.GameObjects.Text[] = [];
private mapResultPopupPeakCount = 0; private mapResultPopupPeakCount = 0;
private mapResultPopupLast?: { private mapResultPopupLast?: {
@@ -4051,6 +4083,11 @@ export class BattleScene extends Phaser.Scene {
this.turnPromptMode = undefined; this.turnPromptMode = undefined;
this.turnPromptPrimaryAction = undefined; this.turnPromptPrimaryAction = undefined;
this.turnPromptSecondaryAction = undefined; this.turnPromptSecondaryAction = undefined;
this.turnPromptTitle = undefined;
this.turnPromptBody = undefined;
this.turnPromptFocusedAction = undefined;
this.turnPromptCancelActionId = undefined;
this.turnPromptButtonViews = [];
this.turnText = undefined; this.turnText = undefined;
this.battleTitleText = undefined; this.battleTitleText = undefined;
this.objectiveTrackerText = undefined; this.objectiveTrackerText = undefined;
@@ -4315,7 +4352,7 @@ export class BattleScene extends Phaser.Scene {
return; return;
} }
if (this.turnPromptObjects.length > 0) { if (this.turnPromptObjects.length > 0) {
this.activateTurnPromptPrimaryAction(); this.activateTurnPromptFocusedAction();
return; return;
} }
@@ -4351,7 +4388,7 @@ export class BattleScene extends Phaser.Scene {
} }
if (this.turnPromptObjects.length > 0) { if (this.turnPromptObjects.length > 0) {
this.activateTurnPromptSecondaryAction(); this.activateTurnPromptCancelAction();
return; return;
} }
@@ -4403,6 +4440,9 @@ export class BattleScene extends Phaser.Scene {
if (this.battleEventObjects.length > 0) { if (this.battleEventObjects.length > 0) {
return; return;
} }
if (this.handleTurnPromptNavigationKey(event)) {
return;
}
if (this.handleSaveSlotPanelKey(event)) { if (this.handleSaveSlotPanelKey(event)) {
return; return;
} }
@@ -10444,6 +10484,13 @@ export class BattleScene extends Phaser.Scene {
if (!this.enemyIntentForecastEnabled()) { if (!this.enemyIntentForecastEnabled()) {
return '아군 턴을 종료하시겠습니까?'; return '아군 턴을 종료하시겠습니까?';
} }
return `${this.enemyIntentTurnEndSummary()}\n이대로 턴을 종료할까요?`;
}
private enemyIntentTurnEndSummary() {
if (!this.enemyIntentForecastEnabled()) {
return '적 예상 정보 없음';
}
const groups = new Map<string, { target: UnitData; immediate: number; approach: number }>(); const groups = new Map<string, { target: UnitData; immediate: number; approach: number }>();
const plans = this.enemyIntentForecasts.size > 0 const plans = this.enemyIntentForecasts.size > 0
? Array.from(this.enemyIntentForecasts.values()) ? Array.from(this.enemyIntentForecasts.values())
@@ -10460,13 +10507,16 @@ export class BattleScene extends Phaser.Scene {
} }
groups.set(plan.target.id, group); groups.set(plan.target.id, group);
}); });
const summaries = Array.from(groups.values()) const sortedGroups = Array.from(groups.values())
.sort((left, right) => right.immediate - left.immediate || right.approach - left.approach) .sort((left, right) => right.immediate - left.immediate || right.approach - left.approach);
.slice(0, 2) const visibleGroups = sortedGroups.slice(0, 2);
.map((group) => `${group.target.name}${group.immediate}·추${group.approach}`); 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 return summaries.length > 0
? `적 예상 · ${summaries.join(' · ')}\n이대로 턴을 종료할까요?` ? `적 예상 · ${summaries.join(' · ')}${omittedCount > 0 ? ` · 외 ${omittedCount}` : ''}`
: '적 예상 · 즉시 공격 없음\n이대로 턴을 종료할까요?'; : '적 예상 · 즉시 공격 없음';
} }
private enemyIntentTargetCounts(unitId: string) { private enemyIntentTargetCounts(unitId: string) {
@@ -20495,12 +20545,7 @@ export class BattleScene extends Phaser.Scene {
} }
if (action === 'endTurn') { if (action === 'endTurn') {
this.showTurnEndPrompt(undefined, { this.showManualTurnEndPrompt();
title: '턴 종료 확인',
body: this.enemyIntentTurnEndBody(),
primaryLabel: '턴 종료',
secondaryLabel: '계속 지휘'
});
return; return;
} }
if (action === 'threat') { 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() { private hideMapMenu() {
this.mapMenuObjects.forEach((object) => object.destroy()); this.mapMenuObjects.forEach((object) => object.destroy());
this.mapMenuObjects = []; this.mapMenuObjects = [];
@@ -27760,6 +27837,32 @@ export class BattleScene extends Phaser.Scene {
private showTurnEndPrompt(message?: string, options: TurnEndPromptOptions = {}) { private showTurnEndPrompt(message?: string, options: TurnEndPromptOptions = {}) {
this.hideTurnEndPrompt(); this.hideTurnEndPrompt();
this.turnPromptMode = options.mode ?? 'turn-end'; 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); const shade = this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x03070b, 0.46);
shade.setOrigin(0); shade.setOrigin(0);
@@ -27777,7 +27880,7 @@ export class BattleScene extends Phaser.Scene {
this.turnPromptObjects.push(shade); this.turnPromptObjects.push(shade);
const width = this.battleUiLength(388); 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 left = this.layout.mapX + this.layout.mapWidth / 2 - width / 2;
const top = this.layout.mapY + this.layout.mapHeight / 2 - height / 2; const top = this.layout.mapY + this.layout.mapHeight / 2 - height / 2;
const panel = this.add.rectangle(left, top, width, height, 0x101821, 0.96); 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); panel.setStrokeStyle(this.battleUiLength(2), palette.gold, 0.9);
this.turnPromptObjects.push(panel); 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', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(20), fontSize: this.battleUiFontSize(20),
color: '#f2e3bf', color: '#f2e3bf',
@@ -27796,79 +27899,136 @@ export class BattleScene extends Phaser.Scene {
title.setDepth(41); title.setDepth(41);
this.turnPromptObjects.push(title); 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', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(17), fontSize: this.battleUiFontSize(17),
color: '#d4dce6', color: '#d4dce6',
align: 'center', align: 'center',
wordWrap: { width: width - this.battleUiLength(44), useAdvancedWrap: true } wordWrap: { width: width - this.battleUiLength(44), useAdvancedWrap: true }
}); });
body.setOrigin(0.5); body.setOrigin(0.5, 0);
body.setDepth(41); body.setDepth(41);
this.turnPromptObjects.push(body); 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 buttonWidth = this.battleUiLength(136);
const buttons = [ 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 }) => { buttons.forEach(({ id, label, meaning, dangerous, x }) => {
const bg = this.add.rectangle(x, top + this.battleUiLength(116), buttonWidth, this.battleUiLength(36), 0x1a2630, 0.95); const bg = this.add.rectangle(x, top + this.battleUiLength(buttonY), buttonWidth, this.battleUiLength(36), 0x1a2630, 0.95);
bg.setDepth(41); bg.setDepth(41);
bg.setStrokeStyle(this.battleUiLength(1), primary ? palette.gold : palette.blue, 0.72);
bg.setInteractive({ useHandCursor: true }); bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => bg.setFillStyle(0x283947, 0.98)); bg.on('pointerover', () => this.focusTurnPromptAction(id));
bg.on('pointerout', () => bg.setFillStyle(0x1a2630, 0.95)); bg.on('pointerdown', () => this.activateTurnPromptAction(id, true));
bg.on('pointerdown', () => (primary ? this.activateTurnPromptPrimaryAction(true) : this.activateTurnPromptSecondaryAction(true)));
this.turnPromptObjects.push(bg); 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', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(16), fontSize: this.battleUiFontSize(label.length > 9 ? 13 : 16),
color: '#f2e3bf', color: '#f2e3bf',
fontStyle: '700' fontStyle: '700'
}); });
text.setOrigin(0.5); text.setOrigin(0.5);
text.setDepth(42); text.setDepth(42);
text.setInteractive({ useHandCursor: true }); 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); 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', fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(10), fontSize: this.battleUiFontSize(10),
color: primary ? '#cdbb8a' : '#91a7bd', color: '#91a7bd',
fontStyle: '700' fontStyle: '700'
}); });
hint.setOrigin(0.5); hint.setOrigin(0.5);
hint.setDepth(42); hint.setDepth(42);
this.turnPromptObjects.push(hint); this.turnPromptObjects.push(hint);
this.turnPromptButtonViews.push({ id, label, meaning, dangerous, background: bg, text, hint });
}); });
this.renderTurnPromptFocus();
void message; void message;
} }
private activateTurnPromptPrimaryAction(fromPointer = false) { private handleTurnPromptNavigationKey(event: KeyboardEvent) {
if (!this.turnPromptPrimaryAction) { if (this.turnPromptObjects.length === 0) {
return; 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) { event.preventDefault();
this.suppressNextLeftClick = true; this.focusTurnPromptAction(requestedFocus);
}
soundDirector.playSelect(); soundDirector.playSelect();
this.turnPromptPrimaryAction(); return true;
} }
private activateTurnPromptSecondaryAction(fromPointer = false) { private focusTurnPromptAction(actionId: TurnPromptActionId) {
const action = this.turnPromptSecondaryAction ?? (() => this.hideTurnEndPrompt()); 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) { if (fromPointer) {
this.suppressNextLeftClick = true; this.suppressNextLeftClick = true;
} }
@@ -27876,12 +28036,25 @@ export class BattleScene extends Phaser.Scene {
action(); action();
} }
private activateTurnPromptPrimaryAction(fromPointer = false) {
this.activateTurnPromptAction('primary', fromPointer);
}
private activateTurnPromptSecondaryAction(fromPointer = false) {
this.activateTurnPromptAction('secondary', fromPointer);
}
private hideTurnEndPrompt() { private hideTurnEndPrompt() {
this.turnPromptObjects.forEach((object) => object.destroy()); this.turnPromptObjects.forEach((object) => object.destroy());
this.turnPromptObjects = []; this.turnPromptObjects = [];
this.turnPromptMode = undefined; this.turnPromptMode = undefined;
this.turnPromptPrimaryAction = undefined; this.turnPromptPrimaryAction = undefined;
this.turnPromptSecondaryAction = undefined; this.turnPromptSecondaryAction = undefined;
this.turnPromptTitle = undefined;
this.turnPromptBody = undefined;
this.turnPromptFocusedAction = undefined;
this.turnPromptCancelActionId = undefined;
this.turnPromptButtonViews = [];
} }
private pushBattleLog(message: string) { private pushBattleLog(message: string) {
@@ -31821,6 +31994,23 @@ export class BattleScene extends Phaser.Scene {
: null, : null,
turnPromptVisible: this.turnPromptObjects.length > 0, turnPromptVisible: this.turnPromptObjects.length > 0,
turnPromptMode: this.turnPromptMode ?? null, 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: { mapResultFeedback: {
activeCount: this.mapResultPopupObjects.filter((object) => object.active).length, activeCount: this.mapResultPopupObjects.filter((object) => object.active).length,
peakCount: this.mapResultPopupPeakCount, peakCount: this.mapResultPopupPeakCount,

View File

@@ -344,7 +344,7 @@ type CampTabButtonView = {
type VictoryRewardCardId = CampaignVictoryRewardCategoryId; type VictoryRewardCardId = CampaignVictoryRewardCategoryId;
type VictoryRewardActionId = 'equipment' | 'supplies' | 'sortie' | 'close'; type VictoryRewardActionId = 'equipment' | 'supplies' | 'guided' | 'sortie' | 'close';
type VictoryRewardIcon = type VictoryRewardIcon =
| { kind: 'mark'; mark: string } | { kind: 'mark'; mark: string }
@@ -368,6 +368,7 @@ type VictoryRewardActionView = {
id: VictoryRewardActionId; id: VictoryRewardActionId;
label: string; label: string;
button: Phaser.GameObjects.Rectangle; button: Phaser.GameObjects.Rectangle;
primary: boolean;
}; };
type CampActionButtonView = { type CampActionButtonView = {
@@ -377,6 +378,23 @@ type CampActionButtonView = {
hovered: boolean; hovered: boolean;
}; };
type FirstCampGuidedAction =
| {
kind: 'visit';
label: '북문 정찰막 둘러보기';
targetId: typeof firstPursuitScoutVisitId;
}
| {
kind: 'dialogue';
label: string;
targetId: string;
}
| {
kind: 'sortie';
label: '출진 준비';
targetId: null;
};
type EquipmentSwapRequest = { type EquipmentSwapRequest = {
unitId: string; unitId: string;
slot: EquipmentSlot; slot: EquipmentSlot;
@@ -11517,6 +11535,9 @@ export class CampScene extends Phaser.Scene {
private reportFormationHistoryUndoState?: ReportFormationHistoryUndoState; private reportFormationHistoryUndoState?: ReportFormationHistoryUndoState;
private tabButtons: CampTabButtonView[] = []; private tabButtons: CampTabButtonView[] = [];
private sortieCommandButton?: Phaser.GameObjects.Rectangle; private sortieCommandButton?: Phaser.GameObjects.Rectangle;
private sortieCommandLabel?: Phaser.GameObjects.Text;
private sortieBypassButton?: Phaser.GameObjects.Rectangle;
private sortieBypassLabel?: Phaser.GameObjects.Text;
private sortiePrimaryActionButton?: CampActionButtonView; private sortiePrimaryActionButton?: CampActionButtonView;
private sortieNextActionGuideBackground?: Phaser.GameObjects.Rectangle; private sortieNextActionGuideBackground?: Phaser.GameObjects.Rectangle;
private sortieNextActionGuideText?: Phaser.GameObjects.Text; private sortieNextActionGuideText?: Phaser.GameObjects.Text;
@@ -11672,6 +11693,10 @@ export class CampScene extends Phaser.Scene {
this.reportFormationHistoryView = undefined; this.reportFormationHistoryView = undefined;
this.reportFormationHistoryUndoState = undefined; this.reportFormationHistoryUndoState = undefined;
this.tabButtons = []; this.tabButtons = [];
this.sortieCommandButton = undefined;
this.sortieCommandLabel = undefined;
this.sortieBypassButton = undefined;
this.sortieBypassLabel = undefined;
this.sortiePrimaryActionButton = undefined; this.sortiePrimaryActionButton = undefined;
this.sortieNextActionGuideBackground = undefined; this.sortieNextActionGuideBackground = undefined;
this.sortieNextActionGuideText = 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() { private hasPendingFirstBattleCamaraderieMemory() {
const dialogue = this.firstBattleCamaraderieDialogue(); const dialogue = this.firstBattleCamaraderieDialogue();
return Boolean( return Boolean(
@@ -13102,16 +13206,36 @@ export class CampScene extends Phaser.Scene {
soundDirector.playSelect(); soundDirector.playSelect();
this.showCampSaveSlotPanel(); this.showCampSaveSlotPanel();
}); });
const flow = this.currentSortieFlow(); const guidedAction = this.firstCampGuidedAction();
const storyButtonLabel = this.isFinalEpilogueFlow(flow) && this.campaign?.step === flow.campaignStep ? '엔딩 보기' : '출진 준비'; const splitSortieCommand = Boolean(guidedAction && guidedAction.kind !== 'sortie');
this.sortieCommandButton = this.addCommandButton(storyButtonLabel, 1152, 38, 142, () => { const sortieCommand = this.addCommandButton(
this.campPrimaryActionLabel(),
splitSortieCommand ? 1132 : 1152,
38,
splitSortieCommand ? 116 : 142,
() => {
soundDirector.playSelect(); soundDirector.playSelect();
const flow = this.currentSortieFlow();
if (this.isFinalEpilogueFlow(flow)) { if (this.isFinalEpilogueFlow(flow)) {
this.startVictoryStory(); this.startVictoryStory();
return; return;
} }
this.showSortiePrep(); this.runFirstCampGuidedAction();
}, true); },
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() { private showCampSaveSlotPanel() {
@@ -13472,6 +13596,9 @@ export class CampScene extends Phaser.Scene {
const panelY = 90; const panelY = 90;
const panelWidth = 884; const panelWidth = 884;
const panelHeight = 546; const panelHeight = 546;
const firstCampGuidedAction = report.battleId === campBattleIds.first
? this.firstCampGuidedAction()
: undefined;
const shade = this.trackVictoryReward( const shade = this.trackVictoryReward(
this.add.rectangle(0, 0, campLegacyCanvasWidth, campLegacyCanvasHeight, 0x020406, 0.78) this.add.rectangle(0, 0, campLegacyCanvasWidth, campLegacyCanvasHeight, 0x020406, 0.78)
@@ -13683,15 +13810,24 @@ export class CampScene extends Phaser.Scene {
); );
noteText.setDepth(depth + 2); noteText.setDepth(depth + 2);
const actionDefinitions: Array<{ id: VictoryRewardActionId; label: string; primary?: boolean }> = [ const actionDefinitions: Array<{ id: VictoryRewardActionId; label: string; primary?: boolean }> =
{ id: 'equipment', label: '장비 확인' }, firstCampGuidedAction
{ id: 'supplies', label: '보급 확인' }, ? [
{ id: 'sortie', label: '출진 준비', primary: true }, { id: 'equipment', label: '장비 확인' },
{ id: 'close', label: '닫기' } { id: 'supplies', label: '보급 확인' },
]; { id: 'guided', label: firstCampGuidedAction.label, primary: true },
const buttonWidth = 158; { 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 buttonHeight = 48;
const actionGap = 14; const actionGap = firstCampGuidedAction ? 8 : 14;
const totalActionWidth = actionDefinitions.length * buttonWidth + (actionDefinitions.length - 1) * actionGap; const totalActionWidth = actionDefinitions.length * buttonWidth + (actionDefinitions.length - 1) * actionGap;
const actionStartX = panelX + Math.floor((panelWidth - totalActionWidth) / 2); const actionStartX = panelX + Math.floor((panelWidth - totalActionWidth) / 2);
actionDefinitions.forEach((definition, index) => { actionDefinitions.forEach((definition, index) => {
@@ -13708,7 +13844,16 @@ export class CampScene extends Phaser.Scene {
button.setInteractive({ useHandCursor: true }); button.setInteractive({ useHandCursor: true });
const label = this.trackVictoryReward( 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.setOrigin(0.5);
label.setDepth(depth + 3); label.setDepth(depth + 3);
@@ -13723,7 +13868,12 @@ export class CampScene extends Phaser.Scene {
target.on('pointerout', () => button.setFillStyle(restingFill, 0.99)); target.on('pointerout', () => button.setFillStyle(restingFill, 0.99));
target.on('pointerdown', run); 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(); soundDirector.playRewardRevealCue();
} }
@@ -13759,6 +13909,10 @@ export class CampScene extends Phaser.Scene {
this.render(); this.render();
return; return;
} }
if (action === 'guided') {
this.runFirstCampGuidedAction();
return;
}
if (action === 'sortie' || (action === 'close' && this.openSortiePrepOnCreate)) { if (action === 'sortie' || (action === 'close' && this.openSortiePrepOnCreate)) {
this.showRequestedSortiePrep(); this.showRequestedSortiePrep();
} }
@@ -14110,7 +14264,7 @@ export class CampScene extends Phaser.Scene {
target.on('pointerout', () => setHovered(false)); target.on('pointerout', () => setHovered(false));
target.on('pointerdown', action); target.on('pointerdown', action);
}); });
return bg; return { background: bg, label: text };
} }
private render() { private render() {
@@ -14124,6 +14278,7 @@ export class CampScene extends Phaser.Scene {
this.acknowledgePendingVictoryRewardCategories(['supplies']); this.acknowledgePendingVictoryRewardCategories(['supplies']);
} }
this.visitedTabs.add(this.activeTab); this.visitedTabs.add(this.activeTab);
this.refreshCampPrimaryActionPresentation();
this.updateTabButtons(); this.updateTabButtons();
this.renderUnitColumn(); this.renderUnitColumn();
this.renderReportPanel(); this.renderReportPanel();
@@ -26042,6 +26197,7 @@ export class CampScene extends Phaser.Scene {
: false; : false;
const cityEquipmentSortieStatus = this.cityEquipmentSortieStatus(); const cityEquipmentSortieStatus = this.cityEquipmentSortieStatus();
const firstBattleCampFollowup = this.firstBattleCampFollowup(); const firstBattleCampFollowup = this.firstBattleCampFollowup();
const firstCampGuidedAction = this.firstCampGuidedAction();
const firstBattleCamaraderieMemory = this.firstBattleCamaraderieMemory(); const firstBattleCamaraderieMemory = this.firstBattleCamaraderieMemory();
const firstBattleCamaraderieDialogue = this.firstBattleCamaraderieDialogue(); const firstBattleCamaraderieDialogue = this.firstBattleCamaraderieDialogue();
const thirdBattlePriorityReturn = this.thirdBattlePriorityReturnDialogue(); const thirdBattlePriorityReturn = this.thirdBattlePriorityReturnDialogue();
@@ -26090,6 +26246,7 @@ export class CampScene extends Phaser.Scene {
actions: this.victoryRewardActions.map((action) => ({ actions: this.victoryRewardActions.map((action) => ({
id: action.id, id: action.id,
label: action.label, label: action.label,
primary: action.primary,
bounds: this.sortieObjectBoundsDebug(action.button), bounds: this.sortieObjectBoundsDebug(action.button),
interactive: Boolean(action.button.input?.enabled) interactive: Boolean(action.button.input?.enabled)
})) }))
@@ -26148,8 +26305,28 @@ export class CampScene extends Phaser.Scene {
lineWidth: button.bg.lineWidth lineWidth: button.bg.lineWidth
})), })),
sortieCommand: { sortieCommand: {
label: this.sortieCommandLabel?.text ?? null,
guidedKind: firstCampGuidedAction?.kind ?? null,
targetId: firstCampGuidedAction?.targetId ?? null,
bounds: this.sortieObjectBoundsDebug(this.sortieCommandButton), 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 firstVictoryFollowup: firstBattleCampFollowup
? { ? {

View File

@@ -37,6 +37,10 @@ import {
resolveFirstBattleVictoryNarrative, resolveFirstBattleVictoryNarrative,
type FirstBattleVictoryNarrativeMemory type FirstBattleVictoryNarrativeMemory
} from '../data/firstBattleNarrativeMemory'; } from '../data/firstBattleNarrativeMemory';
import {
resolveXuzhouStayNarrativeMemory,
type XuzhouStayNarrativeMemory
} from '../data/xuzhouStayNarrativeMemory';
import { import {
ensureUnitAnimations, ensureUnitAnimations,
loadUnitBaseSheets, loadUnitBaseSheets,
@@ -53,7 +57,11 @@ import {
} from '../data/storyCutscenes'; } from '../data/storyCutscenes';
import { prologueOpeningPages } from '../data/prologueVillage'; import { prologueOpeningPages } from '../data/prologueVillage';
import { type PortraitKey, type StoryPage } from '../data/scenario'; 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 { releaseTextureSource } from '../render/loaderLifecycle';
import { isVisualMotionReduced } from '../settings/visualMotion'; import { isVisualMotionReduced } from '../settings/visualMotion';
import { palette } from '../ui/palette'; import { palette } from '../ui/palette';
@@ -222,6 +230,7 @@ export class StoryScene extends Phaser.Scene {
private ownedStoryTextureKeys = new Set<string>(); private ownedStoryTextureKeys = new Set<string>();
private rewardDisplay: StoryRewardDisplayModel = this.emptyRewardDisplay(); private rewardDisplay: StoryRewardDisplayModel = this.emptyRewardDisplay();
private firstBattleNarrativeMemory?: FirstBattleVictoryNarrativeMemory; private firstBattleNarrativeMemory?: FirstBattleVictoryNarrativeMemory;
private xuzhouStayNarrativeMemory?: XuzhouStayNarrativeMemory;
private visualMotionReduced = false; private visualMotionReduced = false;
private storyEnvironmentProfile?: StoryEnvironmentProfile; private storyEnvironmentProfile?: StoryEnvironmentProfile;
private storyEnvironmentBackgroundKey?: string; private storyEnvironmentBackgroundKey?: string;
@@ -248,8 +257,22 @@ export class StoryScene extends Phaser.Scene {
) )
? resolveFirstBattleVictoryNarrative(sourcePages, getFirstBattleReport()) ? resolveFirstBattleVictoryNarrative(sourcePages, getFirstBattleReport())
: undefined; : 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.firstBattleNarrativeMemory = firstBattleAftermath?.memory;
this.xuzhouStayNarrativeMemory =
xuzhouStayNarrative.memory;
this.selectedStoryBackgroundKeys = storyBackgroundKeysForPages(this.pages); this.selectedStoryBackgroundKeys = storyBackgroundKeysForPages(this.pages);
this.presentationWash = undefined; this.presentationWash = undefined;
this.pageIndex = 0; this.pageIndex = 0;
@@ -349,6 +372,21 @@ export class StoryScene extends Phaser.Scene {
) )
} }
: null, : null,
xuzhouStayNarrativeMemory:
this.xuzhouStayNarrativeMemory
? {
...this.xuzhouStayNarrativeMemory,
appliedPageIds: [
...this.xuzhouStayNarrativeMemory.appliedPageIds
],
currentPageApplied: Boolean(
page &&
this.xuzhouStayNarrativeMemory.appliedPageIds.includes(
page.id
)
)
}
: null,
environment: this.storyEnvironmentDebugState(), environment: this.storyEnvironmentDebugState(),
presentation: this.storyPresentationDebugState(), presentation: this.storyPresentationDebugState(),
assetStreaming: { assetStreaming: {