feat: connect prologue choices to first battle

This commit is contained in:
2026-07-27 03:21:37 +09:00
parent 40dca5321e
commit a726ba129f
10 changed files with 1437 additions and 60 deletions

View File

@@ -125,6 +125,11 @@ try {
false,
'Narrative context should not impersonate a character portrait.'
);
assert.equal(
(await readVillage(page)).dialogue.portraitFrameVisible,
false,
'Narrative context must hide the empty portrait frame instead of resembling a missing asset.'
);
assert.equal(
(await readVillage(page)).dialogue.totalLines,
1,
@@ -169,7 +174,24 @@ try {
walkingZhangFei.x > 360 && walkingZhangFei.x < 1000,
`Zhang Fei should move progressively instead of teleporting: ${JSON.stringify(walkingZhangFei)}`
);
const queuedGuanTarget = villageNpc(village, 'guan-yu');
await page.mouse.click(queuedGuanTarget.x, queuedGuanTarget.y);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.village?.();
return (
state?.movement?.queuedIntent?.kind === 'pointer' &&
state?.narrativeMovement?.playerInputDeferred === true
);
});
await waitForVillageNpcPosition(page, 'zhang-fei', { x: 1000, y: 540 });
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.village?.();
return (
state?.movement?.appliedQueuedIntentCount >= 1 &&
state?.movement?.targetNpcId === 'guan-yu'
);
});
await waitForVillageInteractionTarget(page, 'guan-yu');
village = await readVillage(page);
const tavernZhangFei = villageNpc(village, 'zhang-fei');
assert.equal(tavernZhangFei.moving, false);
@@ -237,12 +259,24 @@ try {
walkingZhangToRecruitment.y > 540 && walkingZhangToRecruitment.y < 805,
`Zhang Fei should move progressively toward registration: ${JSON.stringify(walkingZhangToRecruitment)}`
);
const queuedClerkTarget = villageNpc(village, 'recruiting-clerk');
await page.mouse.click(queuedClerkTarget.x, queuedClerkTarget.y);
await page.waitForFunction(() => {
const state = window.__HEROS_DEBUG__?.village?.();
return (
state?.movement?.queuedIntent?.kind === 'pointer' &&
state?.narrativeMovement?.playerInputDeferred === true
);
});
await waitForVillageNarrativeMovement(page);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.village?.()?.movement?.targetNpcId === 'recruiting-clerk'
));
await waitForVillageInteractionTarget(page, 'recruiting-clerk');
village = await readVillage(page);
assertNpcPosition(village, 'guan-yu', { x: 850, y: 655 }, 'recruitment Guan Yu');
assertNpcPosition(village, 'zhang-fei', { x: 810, y: 805 }, 'recruitment Zhang Fei');
await teleportTo(page, 'recruiting-clerk');
await page.keyboard.press('e');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'recruiting-clerk'
@@ -453,7 +487,7 @@ try {
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'zou-jing'
));
const commandCamp = await readMilitiaCamp(page);
assert.equal(commandCamp.dialogue.totalLines, 5);
assert.equal(commandCamp.dialogue.totalLines, 1);
assert.equal(commandCamp.dialogue.portraitTextureKey, 'portrait-zou-jing-yellow-turban');
assert.deepEqual(
commandCamp.npcs
@@ -468,6 +502,88 @@ try {
assertBoundsInsideViewport(commandCamp.dialogue.bounds, 'final command dialogue');
await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-command.png');
await advanceMilitiaCampDialogueUntilClosed(page);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.open === true &&
window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.ready === true
));
let commandChoice = (await readMilitiaCamp(page)).commandChoice;
assert.equal(commandChoice.open, true);
assert.equal(commandChoice.selectedId, null);
assert.equal(commandChoice.pendingId, null);
assert.equal(commandChoice.confirmEnabled, false);
assert.deepEqual(
commandChoice.cards.map((card) => card.orderId),
['elite', 'mobile', 'siege']
);
assertBoundsInsideViewport(commandChoice.panelBounds, 'first command panel');
commandChoice.cards.forEach((card) => {
assert(card.title.length > 0);
assert(card.condition.length > 0);
assert(card.rewardGold > 0);
assert(card.rewardItems.length > 0);
assertBoundsInsideViewport(card.bounds, `first command card ${card.orderId}`);
});
assertBoundsInsideViewport(commandChoice.confirmBounds, 'first command confirm button');
assertBoundsDoNotOverlap(
commandChoice.cards.map((card) => ({ label: card.orderId, bounds: card.bounds })),
'first command cards'
);
await page.keyboard.press('1');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.pendingId === 'elite'
));
commandChoice = (await readMilitiaCamp(page)).commandChoice;
assert.equal(commandChoice.selectedId, null, 'Previewing a command must not persist it before confirmation.');
assert.equal(commandChoice.confirmEnabled, true);
await page.waitForTimeout(160);
await page.keyboard.down('2');
await page.waitForTimeout(80);
await page.keyboard.up('2');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.pendingId === 'mobile'
));
commandChoice = (await readMilitiaCamp(page)).commandChoice;
assert.equal(
commandChoice.cards.find((card) => card.orderId === 'mobile')?.pending,
true,
'The player must be able to change the previewed command before confirmation.'
);
const siegeCardBounds = commandChoice.cards.find((card) => card.orderId === 'siege')?.bounds;
assert(siegeCardBounds, 'The siege command card bounds are required for pointer selection.');
await page.mouse.click(
siegeCardBounds.x + siegeCardBounds.width / 2,
siegeCardBounds.y + siegeCardBounds.height / 2
);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.pendingId === 'siege'
));
const mobileCardBounds = commandChoice.cards.find((card) => card.orderId === 'mobile')?.bounds;
assert(mobileCardBounds, 'The mobile command card bounds are required for pointer selection.');
await page.mouse.click(
mobileCardBounds.x + mobileCardBounds.width / 2,
mobileCardBounds.y + mobileCardBounds.height / 2
);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.commandChoice?.pendingId === 'mobile'
));
await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-command-choice.png');
await page.keyboard.press('Enter');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'first-command-mobile'
));
const commandReaction = await readMilitiaCamp(page);
assert.equal(commandReaction.commandChoice.open, false);
assert.equal(commandReaction.dialogue.totalLines, 3);
assert.equal(commandReaction.dialogue.portraitTextureKey, 'portrait-zhang-fei-yellow-turban');
const savedCommand = await readCampaignSave(page);
assert.deepEqual(savedCommand.sortieOrderSelection, {
battleId: 'first-battle-zhuo-commandery',
orderId: 'mobile'
});
await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-command-reaction.png');
await advanceMilitiaCampDialogueUntilClosed(page);
await waitForStoryReady(page);
const departure = await readStory(page);
@@ -482,6 +598,10 @@ try {
);
assert(savedDeparture.completedTutorialIds.includes('prologue-camp-complete'));
assert(!savedDeparture.completedTutorialIds.includes('first-battle-basic-controls'));
assert.deepEqual(savedDeparture.sortieOrderSelection, {
battleId: 'first-battle-zhuo-commandery',
orderId: 'mobile'
});
await page.mouse.click(960, 850);
await page.waitForFunction(() => {
@@ -501,6 +621,9 @@ try {
const battleSave = await readCampaignSave(page);
assert.equal(battleSave.step, 'first-battle');
assert(!battleSave.completedTutorialIds.includes('first-battle-basic-controls'));
const firstBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.());
assert.equal(firstBattle.sortieOrder.selectedId, 'mobile');
assert.equal(firstBattle.sortieOrder.live.orderId, 'mobile');
await seedLegacyCompletedVillageSave(page, battleSave);
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
@@ -682,6 +805,17 @@ async function waitForVillageNarrativeMovement(page) {
}, undefined, { timeout: 6000 });
}
async function waitForVillageInteractionTarget(page, targetId) {
await page.waitForFunction((expectedTargetId) => {
const village = window.__HEROS_DEBUG__?.village?.();
return (
village?.interaction?.targetId === expectedTargetId &&
village?.interaction?.canInteract === true &&
village?.movement?.target === null
);
}, targetId, { timeout: 9000 });
}
async function advanceDialogueUntilClosed(page) {
for (let attempt = 0; attempt < 12; attempt += 1) {
const active = (await readVillage(page))?.dialogue?.active;
@@ -805,6 +939,25 @@ function assertNpcTextureKeys(npcs, expectedTextureKeyByNpcId, sceneLabel) {
);
}
function assertBoundsDoNotOverlap(entries, label) {
for (let leftIndex = 0; leftIndex < entries.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < entries.length; rightIndex += 1) {
const left = entries[leftIndex];
const right = entries[rightIndex];
const overlaps = !(
left.bounds.x + left.bounds.width <= right.bounds.x ||
right.bounds.x + right.bounds.width <= left.bounds.x ||
left.bounds.y + left.bounds.height <= right.bounds.y ||
right.bounds.y + right.bounds.height <= left.bounds.y
);
assert(
!overlaps,
`${label}: ${left.label} overlaps ${right.label}: ${JSON.stringify({ left, right })}`
);
}
}
}
function assertExplorationBackground(background, expectedKey) {
assert(background, `Expected ${expectedKey} debug state.`);
assert.equal(background.key, expectedKey);

View File

@@ -29,6 +29,7 @@ const expectedCampMarkerIds = [
'prologue-camp-inspect-arms',
'prologue-camp-complete'
];
const expectedFirstCommandOrderIds = ['elite', 'mobile', 'siege'];
const server = await createServer({
logLevel: 'error',
@@ -39,6 +40,7 @@ const server = await createServer({
try {
const village = await server.ssrLoadModule('/src/game/data/prologueVillage.ts');
const militiaCamp = await server.ssrLoadModule('/src/game/data/prologueMilitiaCamp.ts');
const sortieOrders = await server.ssrLoadModule('/src/game/data/sortieOrders.ts');
const campaign = await server.ssrLoadModule('/src/game/state/campaignState.ts');
const unitAssets = await server.ssrLoadModule('/src/game/data/unitAssets.ts');
const failures = [];
@@ -178,6 +180,58 @@ try {
'militia camp must have Zou Jing as the single final command interaction',
failures
);
const firstCommandChoices = militiaCamp.prologueMilitiaCampCommandChoices;
assertArrayEquals(
firstCommandChoices.map((choice) => choice.orderId),
expectedFirstCommandOrderIds,
'first command choice order',
failures
);
assert(
new Set(firstCommandChoices.map((choice) => choice.orderId)).size ===
expectedFirstCommandOrderIds.length,
'the first command choices must cover three unique sortie orders',
failures
);
for (const choice of firstCommandChoices) {
const definition = sortieOrders.sortieOrderDefinition(choice.orderId);
assert(
definition.id === choice.orderId &&
definition.rewardGold > 0 &&
definition.rewardItems.length > 0,
`first command ${choice.orderId} must reuse a complete battle order definition`,
failures
);
assert(
choice.label === sortieOrders.sortieOrderDisplayLabel(
'first-battle-zhuo-commandery',
choice.orderId
) &&
choice.lead.length >= 8 &&
choice.battlefieldPromise.length >= 20 &&
choice.firstBattleCondition.length >= 20,
`first command ${choice.orderId} must explain its player value and battlefield promise`,
failures
);
assert(
choice.reaction.length === 3 &&
choice.reaction.some((line) => line.speaker === '관우') &&
choice.reaction.some((line) => line.speaker === '장비'),
`first command ${choice.orderId} must receive distinct Guan Yu and Zhang Fei reactions`,
failures
);
verifyDialogueLines(
choice.reaction,
`militia camp/first-command/${choice.orderId}`,
unitAssets,
failures
);
}
assert(
campNpcs.find((npc) => npc.id === 'zou-jing')?.dialogue.length === 1,
'Zou Jing command setup should be compressed to one line so the new decision adds no extra input',
failures
);
assert(
campNpcs.some((npc) => !npc.objectiveId && !npc.departure),
'militia camp should include at least one optional volunteer conversation',
@@ -234,7 +288,7 @@ try {
console.log(
`Verified ${npcs.length} village NPCs, ${campNpcs.length} militia camp NPCs, ` +
`${openingPages.length + brotherhoodPages.length + departurePages.length} prologue story pages, ` +
'sequential meetings, camp preparation, and save markers.'
`${firstCommandChoices.length} first-command choices, sequential meetings, camp preparation, and save markers.`
);
} finally {
await server.close();