feat: connect prologue choices to first battle
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user