feat: guide early stays and remember player choices
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
564
scripts/verify-xuzhou-stay-narrative-memory.mjs
Normal file
564
scripts/verify-xuzhou-stay-narrative-memory.mjs
Normal 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);
|
||||||
|
}
|
||||||
353
src/game/data/xuzhouStayNarrativeMemory.ts
Normal file
353
src/game/data/xuzhouStayNarrativeMemory.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user