feat: make third-camp preparation choices explicit

This commit is contained in:
2026-07-28 01:29:20 +09:00
parent 1a81ce5689
commit a41672bd0e
11 changed files with 1608 additions and 143 deletions

View File

@@ -28,7 +28,7 @@ const priorityFixtures = [
},
{
id: 'equipment',
label: '장비 정비',
label: '선봉 방호',
effectKind: 'prepared-equipment-guard',
configuredValue: 8
},
@@ -142,6 +142,10 @@ async function verifyRenderer(browser, baseUrl, rendererFixture) {
priority
);
}
await verifyCompanionFormationMismatch(
page,
rendererFixture.renderer
);
assert.deepEqual(
pageErrors,
@@ -360,14 +364,15 @@ async function verifyCampPreparationUi(page, renderer) {
'equipment'
);
assert.equal(exploration.visit.completedActivityCount, 1);
assert.equal(exploration.visit.selectedPriorityId, 'equipment');
assert.equal(exploration.visit.selectedPriorityId, null);
assert.equal(exploration.visit.selectionConfirmed, false);
assert.equal(
exploration.visit.hudActivityTitle,
'자유 준비 1 / 3'
);
assert.equal(
exploration.visit.hudPriorityLocation,
'장비 정비'
'아직 정하지 않음'
);
assertThirdCampWorldFeedback(exploration, ['equipment']);
assert(
@@ -383,6 +388,12 @@ async function verifyCampPreparationUi(page, renderer) {
return scene?.debugInteractWith?.('camp-exit') ?? false;
});
assert.equal(partialExit, true);
await page.waitForFunction(
() =>
window.__HEROS_DEBUG__?.campVisitExploration?.()
?.dialogue?.active === true
);
await advanceExplorationDialogue(page);
await waitForCamp(page);
await page.reload({
waitUntil: 'domcontentloaded',
@@ -404,7 +415,7 @@ async function verifyCampPreparationUi(page, renderer) {
['equipment'],
'A partial activity must survive a Camp round trip and page reload.'
);
assert.equal(exploration.visit.selectedPriorityId, 'equipment');
assert.equal(exploration.visit.selectedPriorityId, null);
assertThirdCampWorldFeedback(exploration, ['equipment']);
exploration = await completeThirdCampNpcActivity(
@@ -416,7 +427,7 @@ async function verifyCampPreparationUi(page, renderer) {
[...exploration.visit.completedActivityIds].sort(),
['equipment', 'information']
);
assert.equal(exploration.visit.selectedPriorityId, 'information');
assert.equal(exploration.visit.selectedPriorityId, null);
assertThirdCampWorldFeedback(
exploration,
['equipment', 'information']
@@ -468,8 +479,7 @@ async function verifyCampPreparationUi(page, renderer) {
const state =
window.__HEROS_DEBUG__?.campVisitExploration?.();
return (
state?.visit?.completedActivityCount === 3 &&
state?.visit?.selectedPriorityId === 'companion'
state?.visit?.completedActivityCount === 3
);
}
);
@@ -485,7 +495,7 @@ async function verifyCampPreparationUi(page, renderer) {
);
assert.equal(
exploration.visit.hudPriorityLocation,
'동료 공명'
'아직 정하지 않음'
);
assertThirdCampWorldFeedback(
exploration,
@@ -544,6 +554,12 @@ async function verifyCampPreparationUi(page, renderer) {
return scene?.debugInteractWith?.('camp-exit') ?? false;
});
assert.equal(completedExit, true);
await page.waitForFunction(
() =>
window.__HEROS_DEBUG__?.campVisitExploration?.()
?.dialogue?.active === true
);
await advanceExplorationDialogue(page);
await waitForCamp(page);
await page.evaluate(() => {
const scene =
@@ -570,7 +586,45 @@ async function verifyCampPreparationUi(page, renderer) {
priority.id
);
}
assert.equal(camp.thirdCampPreparation.selectedPriorityId, null);
assert.equal(camp.thirdCampPreparation.confirmed, false);
assert.equal(
camp.thirdCampPreparation.keyboardFocusPriorityId,
'information'
);
await page.keyboard.press('ArrowDown');
await page.waitForFunction(
() =>
window.__HEROS_DEBUG__?.camp?.()
?.thirdCampPreparation?.keyboardFocusPriorityId ===
'equipment'
);
await page.keyboard.press('Shift+Tab');
await page.waitForFunction(
() =>
window.__HEROS_DEBUG__?.camp?.()
?.thirdCampPreparation?.keyboardFocusPriorityId ===
'information'
);
await page.keyboard.press('3');
await page.waitForFunction(
() => {
const preparation =
window.__HEROS_DEBUG__?.camp?.()?.thirdCampPreparation;
return (
preparation?.selectedPriorityId === 'companion' &&
preparation.confirmed === true &&
preparation.ready === true
);
}
);
camp = await readCamp(page);
assertPreparationSelection(camp, 'companion');
await verifyPreparationModalKeyboardIsolation(page);
camp = await verifyPreparationStepKeyboardIsolation(
page,
'information'
);
assertFiniteBounds(
camp.thirdCampPreparation.clearButtonBounds,
'preparation clear button'
@@ -616,6 +670,368 @@ async function verifyCampPreparationUi(page, renderer) {
null,
'cleared optional preparation'
);
await verifyRetryLockedPreparationCard(page);
}
async function verifyPreparationModalKeyboardIsolation(page) {
await page.evaluate(async () => {
const campaignModule = await import(
'/heros_web/src/game/state/campaignState.ts'
);
const snapshot = campaignModule.getCampaignState();
campaignModule.saveCampaignState(snapshot, 2);
campaignModule.saveCampaignState(snapshot, 1);
const scene =
window.__HEROS_GAME__?.scene.getScene('CampScene');
scene.campaign = campaignModule.getCampaignState();
scene.showSortiePrep();
});
await page.waitForFunction(
() => {
const state = window.__HEROS_DEBUG__?.camp?.();
return (
state?.campaign?.activeSaveSlot === 1 &&
state?.thirdCampPreparation?.browserOpen === true &&
state.thirdCampPreparation.selectedPriorityId ===
'companion'
);
}
);
await openCampSaveSlotPanel(page);
await page.keyboard.press('1');
await waitForCampSaveModalClosed(page);
let camp = await readCamp(page);
assertPreparationSelection(camp, 'companion');
assert.equal(
camp.campaign.activeSaveSlot,
1,
'The 1 key must save to slot 1 instead of selecting the first preparation card.'
);
await selectPreparationCard(page, 'equipment');
await waitForCampSelection(page, 'equipment');
await openCampSaveSlotPanel(page);
await page.keyboard.press('3');
await waitForCampSaveModalClosed(page);
camp = await readCamp(page);
assertPreparationSelection(camp, 'equipment');
assert.equal(
camp.campaign.activeSaveSlot,
3,
'The 3 key must save to slot 3 instead of selecting the third preparation card.'
);
await selectPreparationCard(page, 'information');
await waitForCampSelection(page, 'information');
await openCampSaveSlotPanel(page);
await page.keyboard.press('2');
await page.waitForFunction(
() => {
const scene =
window.__HEROS_GAME__?.scene.getScene('CampScene');
return (
scene?.saveSlotObjects?.length > 0 &&
scene?.saveSlotConfirmObjects?.length > 0 &&
scene?.pendingSaveSlot === 2
);
}
);
camp = await readCamp(page);
assertPreparationSelection(camp, 'information');
await page.keyboard.press('1');
await page.keyboard.press('3');
const guardedConfirm = await readCampSaveModalState(page);
assert.equal(guardedConfirm.slotPanelOpen, true);
assert.equal(guardedConfirm.overwriteConfirmOpen, true);
assert.equal(guardedConfirm.pendingSaveSlot, 2);
assert.equal(
guardedConfirm.selectedPriorityId,
'information',
'Number keys owned by the overwrite confirmation must not leak to hidden preparation cards.'
);
await page.keyboard.press('Enter');
await waitForCampSaveModalClosed(page);
camp = await readCamp(page);
assertPreparationSelection(camp, 'information');
assert.equal(
camp.campaign.activeSaveSlot,
2,
'Enter must confirm the pending overwrite instead of confirming the focused preparation card.'
);
}
async function verifyPreparationStepKeyboardIsolation(
page,
selectedPriorityId
) {
let camp = await readCamp(page);
assertPreparationSelection(camp, selectedPriorityId);
assert.equal(camp.thirdCampPreparation.browserOpen, true);
assert.equal(camp.sortiePrimaryAction?.kind, 'next');
assert.equal(
camp.sortiePrimaryAction?.nextStep,
'formation'
);
assertFiniteBounds(
camp.sortiePrimaryAction?.bounds,
'sortie formation action'
);
await clickSceneBounds(
page,
'CampScene',
camp.sortiePrimaryAction.bounds
);
await page.waitForFunction(
(selectedPriorityId) => {
const state = window.__HEROS_DEBUG__?.camp?.();
return (
state?.sortiePrepStep === 'formation' &&
state?.thirdCampPreparation?.browserOpen === false &&
state.thirdCampPreparation.selectedPriorityId ===
selectedPriorityId
);
},
selectedPriorityId
);
camp = await readCamp(page);
assert(
camp.thirdCampPreparation.priorities.every(
({ cardBounds, actionButtonBounds }) =>
cardBounds === null && actionButtonBounds === null
),
'Leaving the briefing must destroy every preparation-card input target.'
);
await page.keyboard.press('1');
camp = await readCamp(page);
assert.equal(camp.sortiePrepStep, 'formation');
assert.equal(
camp.thirdCampPreparation.selectedPriorityId,
selectedPriorityId,
'A hidden preparation card must not consume number keys during formation.'
);
await page.keyboard.press('Enter');
await page.waitForFunction(
(selectedPriorityId) => {
const state = window.__HEROS_DEBUG__?.camp?.();
return (
state?.sortiePrepStep === 'loadout' &&
state?.thirdCampPreparation?.browserOpen === false &&
state.thirdCampPreparation.selectedPriorityId ===
selectedPriorityId
);
},
selectedPriorityId
);
await page.evaluate(() => {
const scene =
window.__HEROS_GAME__?.scene.getScene('CampScene');
scene.setSortiePrepStep('briefing');
});
await waitForPreparationToggle(page);
camp = await openPreparationBrowser(page);
assertPreparationSelection(camp, selectedPriorityId);
return camp;
}
async function verifyRetryLockedPreparationCard(page) {
await seedThirdVictory(page, {
acknowledgeActivities: false,
selectPriorityId: null
});
await page.evaluate(async (targetBattleId) => {
const campaignModule = await import(
'/heros_web/src/game/state/campaignState.ts'
);
const actionModule = await import(
'/heros_web/src/game/state/thirdCampPreparationActions.ts'
);
const explorationActionModule = await import(
'/heros_web/src/game/state/thirdCampExplorationActions.ts'
);
const { battleScenarios } = await import(
'/heros_web/src/game/data/battles.ts'
);
const scenario = battleScenarios[targetBattleId];
if (!scenario) {
throw new Error(
`Missing retry scenario ${targetBattleId}.`
);
}
const entry =
explorationActionModule.enterThirdCampExploration();
const activity =
explorationActionModule.recordThirdCampExplorationActivity(
'information'
);
const selection =
actionModule.setThirdCampPreparationPriority(
'information'
);
if (!entry.ok || !activity.ok || !selection.ok) {
throw new Error(
`Failed to seed a legacy preparation selection: ${JSON.stringify(
{
entry,
activity,
selection
}
)}`
);
}
campaignModule.setFirstBattleReport({
battleId: scenario.id,
battleTitle: scenario.title,
outcome: 'defeat',
turnNumber: 7,
rewardGold: 0,
defeatedEnemies: 0,
totalEnemies: scenario.units.filter(
(unit) => unit.faction === 'enemy'
).length,
objectives: scenario.objectives.map((objective) => ({
id: objective.id,
label: objective.label,
achieved: false,
status: 'failed',
detail: '미달성',
rewardGold: 0
})),
units: scenario.units,
bonds: scenario.bonds.map((bond) => ({
...bond,
battleExp: 0
})),
itemRewards: [],
campaignRewards: {
supplies: [],
equipment: [],
reputation: [],
recruits: [],
unlocks: [],
note: ''
},
completedCampDialogues: [],
completedCampVisits: [],
createdAt: '2026-07-27T01:00:00.000Z'
});
campaignModule.completeCampaignAftermath(targetBattleId);
const legacyRetry = campaignModule.getCampaignState();
legacyRetry.thirdCampPreparationSelection = {
...legacyRetry.thirdCampPreparationSelection,
confirmed: false
};
campaignModule.setCampaignState(legacyRetry);
const game = window.__HEROS_GAME__;
for (const scene of game?.scene.getScenes(true) ?? []) {
game.scene.stop(scene.scene.key);
}
game.scene.start('CampScene', {
retryBattleId: targetBattleId,
openSortiePrep: true,
skipIntroStory: true
});
}, targetBattleId);
await page.waitForFunction(
(targetBattleId) => {
const state = window.__HEROS_DEBUG__?.camp?.();
return (
window.__HEROS_DEBUG__
?.activeScenes?.()
.includes('CampScene') &&
state?.campaign?.step === 'fourth-battle' &&
state?.sortieVisible === true &&
state?.sortiePrepStep === 'briefing' &&
state?.thirdCampPreparation?.available === true &&
state.thirdCampPreparation.targetBattleId ===
targetBattleId &&
state.thirdCampPreparation.selectedPriorityId ===
'information' &&
state.thirdCampPreparation.confirmed === false &&
state.thirdCampPreparation.ready === false &&
state.thirdCampPreparation.browserOpen === false
);
},
targetBattleId,
{ timeout: 90000 }
);
let camp = await openPreparationBrowser(page);
assert.equal(
camp.thirdCampPreparation.selectedPriorityId,
'information'
);
assert.equal(camp.thirdCampPreparation.confirmed, false);
assert.equal(camp.thirdCampPreparation.ready, false);
assert.equal(
preparationPriority(camp, 'information').selectable,
true
);
const locked = preparationPriority(camp, 'equipment');
assert.equal(locked.selectable, false);
assert.equal(
locked.unavailableReason,
'equipment-change-required'
);
const lockedLabels = await page.evaluate(() => {
const scene =
window.__HEROS_GAME__?.scene.getScene('CampScene');
return (
scene?.children.list
.filter(
(child) =>
child?.active &&
child?.visible &&
child?.text === '초회 한정'
)
.map((child) => child.text) ?? []
);
});
assert.equal(
lockedLabels.length,
2,
'Every still-locked CTA in a legacy retry must read "초회 한정".'
);
await clickSceneBounds(
page,
'CampScene',
locked.actionButtonBounds
);
await page.waitForTimeout(300);
const retryState = await page.evaluate(() => {
const scene =
window.__HEROS_GAME__?.scene.getScene('CampScene');
return {
activeScenes:
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
camp: window.__HEROS_DEBUG__?.camp?.(),
navigationPending: scene?.navigationPending ?? null
};
});
assert.equal(retryState.navigationPending, false);
assert.equal(
retryState.activeScenes.includes(
'CampVisitExplorationScene'
),
false,
'A retry-only locked preparation CTA must not reopen exploration.'
);
assert.equal(
retryState.camp?.thirdCampPreparation?.browserOpen,
true
);
assert.equal(
retryState.camp?.thirdCampPreparation?.selectedPriorityId,
'information'
);
assert.equal(
retryState.camp?.thirdCampPreparation?.confirmed,
false
);
}
async function waitForThirdCampExploration(page) {
@@ -806,7 +1222,8 @@ async function verifyBattlePriority(
assert.deepEqual(seeded.selection, {
sourceBattleId,
targetBattleId,
priorityId: priority.id
priorityId: priority.id,
confirmed: true
});
assert.equal(seeded.memory?.selectionRecordId, selectionRecordId);
assert.equal(seeded.memory?.priorityId, priority.id);
@@ -1048,9 +1465,119 @@ async function verifyBattlePriority(
);
}
async function verifyCompanionFormationMismatch(page, renderer) {
await seedThirdVictory(page, {
acknowledgeActivities: true,
selectPriorityId: 'companion',
selectedSortieUnitIds: ['liu-bei', 'guan-yu']
});
await page.evaluate(async () => {
await window.__HEROS_DEBUG__.goToCamp();
});
await waitForCamp(page);
await page.evaluate(() => {
const scene =
window.__HEROS_GAME__?.scene.getScene('CampScene');
scene?.showSortiePrep?.();
});
let camp = await waitForPreparationToggle(page);
assert.deepEqual(
camp.thirdCampPreparation.compatibility,
{
compatible: false,
requiredUnitIds: ['guan-yu', 'zhang-fei'],
missingUnitIds: ['zhang-fei'],
disabledReason: 'required-units-missing'
}
);
const checklist = camp.sortieChecklist.find(
({ label }) => label === '출진 우선 준비'
);
assert.equal(checklist?.complete, false);
assert.match(checklist?.detail ?? '', /장비.*미편성.*미발동/);
await capture(
page,
`dist/verification-third-camp-preparation-${renderer}-companion-mismatch-camp.png`
);
await page.evaluate(async (battleId) => {
await window.__HEROS_DEBUG__.goToBattle(battleId);
}, targetBattleId);
await page.waitForFunction(
({ targetBattleId, selectionRecordId }) => {
const state = window.__HEROS_DEBUG__?.battle?.();
const preparation = state?.thirdCampPreparation;
return (
state?.scene === 'BattleScene' &&
state.battleId === targetBattleId &&
state.phase === 'deployment' &&
preparation?.selectionRecordId === selectionRecordId &&
preparation.compatibility?.compatible === false
);
},
{ targetBattleId, selectionRecordId },
{ timeout: 90000 }
);
let battle = await readBattle(page);
assert.equal(battle.thirdCampPreparation.effectActive, false);
assert.equal(
battle.thirdCampPreparation.presentation.deployment.status,
'incompatible'
);
assert.equal(
battle.thirdCampPreparation.presentation.turn.status,
'incompatible'
);
assert.match(
battle.thirdCampPreparation.presentation.deployment.text,
/편성되지 않아.*미적용/
);
await page.evaluate(() => {
const scene =
window.__HEROS_GAME__?.scene.getScene('BattleScene');
scene.phase = 'idle';
scene.renderTacticalInitiativeChip();
});
battle = await readBattle(page);
assert.equal(battle.thirdCampPreparation.chip.visible, true);
assert.match(
battle.thirdCampPreparation.chip.text,
/미편성.*미발동/
);
await capture(
page,
`dist/verification-third-camp-preparation-${renderer}-companion-mismatch-battle.png`
);
await page.evaluate(() => {
window.__HEROS_DEBUG__.forceBattleOutcome('victory');
});
await page.waitForFunction(
() =>
window.__HEROS_DEBUG__?.battle?.()
?.thirdCampPreparation?.presentation?.result?.status ===
'incompatible',
undefined,
{ timeout: 90000 }
);
battle = await readBattle(page);
assert.match(
battle.thirdCampPreparation.resultFeedbackText,
/편성되지 않아.*미적용/
);
}
async function seedThirdVictory(
page,
{ acknowledgeActivities, selectPriorityId }
{
acknowledgeActivities,
selectPriorityId,
selectedSortieUnitIds = [
'liu-bei',
'guan-yu',
'zhang-fei'
]
}
) {
return page.evaluate(
async ({
@@ -1059,7 +1586,8 @@ async function seedThirdVictory(
priorityDialogueId,
priorityDialogueBondId,
acknowledgeActivities,
selectPriorityId
selectPriorityId,
selectedSortieUnitIds
}) => {
const game = window.__HEROS_GAME__;
for (const scene of game?.scene.getScenes(true) ?? []) {
@@ -1160,9 +1688,7 @@ async function seedThirdVictory(
])
];
campaign.selectedSortieUnitIds = [
'liu-bei',
'guan-yu',
'zhang-fei'
...selectedSortieUnitIds
];
campaignModule.setCampaignState(campaign);
campaignModule.completeCampaignAftermath(sourceBattleId);
@@ -1262,7 +1788,8 @@ async function seedThirdVictory(
priorityDialogueId,
priorityDialogueBondId,
acknowledgeActivities,
selectPriorityId
selectPriorityId,
selectedSortieUnitIds
}
);
}
@@ -1606,6 +2133,72 @@ async function waitForPreparationToggle(page) {
return readCamp(page);
}
async function openCampSaveSlotPanel(page) {
const before = await readCampSaveModalState(page);
assert.equal(
before.slotPanelOpen,
false,
'The save-slot panel must begin closed.'
);
await page.evaluate(() => {
const scene =
window.__HEROS_GAME__?.scene.getScene('CampScene');
if (typeof scene?.showCampSaveSlotPanel !== 'function') {
throw new Error(
'CampScene.showCampSaveSlotPanel is unavailable.'
);
}
scene.showCampSaveSlotPanel();
});
await page.waitForFunction(
() => {
const scene =
window.__HEROS_GAME__?.scene.getScene('CampScene');
return (
scene?.saveSlotObjects?.length > 0 &&
scene?.saveSlotConfirmObjects?.length === 0
);
}
);
return readCampSaveModalState(page);
}
async function waitForCampSaveModalClosed(page) {
await page.waitForFunction(
() => {
const scene =
window.__HEROS_GAME__?.scene.getScene('CampScene');
return (
scene?.saveSlotObjects?.length === 0 &&
scene?.saveSlotConfirmObjects?.length === 0 &&
scene?.pendingSaveSlot === undefined
);
}
);
return readCampSaveModalState(page);
}
async function readCampSaveModalState(page) {
return page.evaluate(() => {
const scene =
window.__HEROS_GAME__?.scene.getScene('CampScene');
const camp = window.__HEROS_DEBUG__?.camp?.();
return {
slotPanelOpen:
(scene?.saveSlotObjects?.length ?? 0) > 0,
overwriteConfirmOpen:
(scene?.saveSlotConfirmObjects?.length ?? 0) > 0,
pendingSaveSlot: scene?.pendingSaveSlot ?? null,
selectedPriorityId:
camp?.thirdCampPreparation?.selectedPriorityId ??
null,
browserOpen:
camp?.thirdCampPreparation?.browserOpen ?? false,
activeSaveSlot: camp?.campaign?.activeSaveSlot ?? null
};
});
}
async function openPreparationBrowser(page) {
let lastState = await readCamp(page);
for (let attempt = 0; attempt < 2; attempt += 1) {
@@ -1806,6 +2399,7 @@ function assertPreparationSelection(camp, priorityId) {
const preparation = camp.thirdCampPreparation;
assert.equal(preparation.selectionRecordId, selectionRecordId);
assert.equal(preparation.ready, true);
assert.equal(preparation.confirmed, true);
assert.equal(
preparation.selectedPriorityId,
priorityId
@@ -1840,7 +2434,8 @@ async function assertCampaignSelectionPersisted(
{
sourceBattleId,
targetBattleId,
priorityId
priorityId,
confirmed: true
}
);
} else {