feat: add playable first-battle militia camp
This commit is contained in:
@@ -95,6 +95,22 @@ try {
|
||||
await page.waitForTimeout(120);
|
||||
assert.equal((await readVillage(page)).dialogue.active, false, 'Interaction outside the radius must not open dialogue.');
|
||||
|
||||
await teleportTo(page, 'guan-yu');
|
||||
await page.keyboard.press('e');
|
||||
await page.waitForFunction(() => (
|
||||
window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'guan-yu'
|
||||
));
|
||||
assert.equal(
|
||||
(await readVillage(page)).dialogue.totalLines,
|
||||
1,
|
||||
'Guan Yu must remain a locked encounter until Liu Bei has met Zhang Fei.'
|
||||
);
|
||||
await advanceDialogueUntilClosed(page);
|
||||
assert(
|
||||
!(await readVillage(page)).completedObjectiveIds.includes('meet-guan-yu'),
|
||||
'A locked Guan Yu interaction must not complete its objective.'
|
||||
);
|
||||
|
||||
await teleportTo(page, 'zhang-fei');
|
||||
await page.keyboard.press('e');
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'zhang-fei');
|
||||
@@ -134,7 +150,23 @@ try {
|
||||
assert(village.completedObjectiveIds.includes('meet-zhang-fei'), 'A completed NPC goal must survive reload and Continue.');
|
||||
assert.equal(village.dialogue.active, false, 'The one-time orientation line must not replay on Continue.');
|
||||
|
||||
for (const npcId of ['guan-yu', 'quartermaster']) {
|
||||
await teleportTo(page, 'recruiting-clerk');
|
||||
await page.keyboard.press('e');
|
||||
await page.waitForFunction(() => (
|
||||
window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'recruiting-clerk'
|
||||
));
|
||||
assert.equal(
|
||||
(await readVillage(page)).dialogue.totalLines,
|
||||
1,
|
||||
'The recruiting clerk must remain locked until Guan Yu has joined.'
|
||||
);
|
||||
await advanceDialogueUntilClosed(page);
|
||||
assert(
|
||||
!(await readVillage(page)).completedObjectiveIds.includes('register-volunteers'),
|
||||
'A locked recruiting interaction must not complete its objective.'
|
||||
);
|
||||
|
||||
for (const npcId of ['guan-yu', 'recruiting-clerk']) {
|
||||
await teleportTo(page, npcId);
|
||||
await page.keyboard.press('e');
|
||||
await page.waitForFunction((expectedNpcId) => (
|
||||
@@ -146,7 +178,7 @@ try {
|
||||
village = await readVillage(page);
|
||||
assert.deepEqual(
|
||||
[...village.completedObjectiveIds].sort(),
|
||||
['check-supplies', 'meet-guan-yu', 'meet-zhang-fei'].sort()
|
||||
['register-volunteers', 'meet-guan-yu', 'meet-zhang-fei'].sort()
|
||||
);
|
||||
assert.equal(village.exitUnlocked, true);
|
||||
assert.equal(village.oath.visible, true);
|
||||
@@ -155,26 +187,186 @@ try {
|
||||
await teleportTo(page, 'make-oath');
|
||||
await page.keyboard.press('e');
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'make-oath');
|
||||
assert.equal((await readVillage(page)).dialogue.totalLines, 6);
|
||||
assert.equal((await readVillage(page)).dialogue.totalLines, 4);
|
||||
await advanceDialogueUntilClosed(page);
|
||||
await waitForStoryReady(page);
|
||||
|
||||
const brotherhood = await readStory(page);
|
||||
assert.equal(brotherhood.totalPages, 1);
|
||||
assert.equal(brotherhood.currentPageId, 'militia-rises');
|
||||
assert.equal(brotherhood.nextScene, 'PrologueMilitiaCampScene');
|
||||
assert.equal(brotherhood.advanceHint, 'militia-camp-exploration');
|
||||
assert.equal(brotherhood.advanceLabel, '의용군 막사로');
|
||||
const savedBrotherhood = await readCampaignSave(page);
|
||||
assert.equal(
|
||||
savedBrotherhood.step,
|
||||
'prologue-town',
|
||||
'The campaign should remain resumable at the brotherhood bridge until the camp starts.'
|
||||
);
|
||||
assert(savedBrotherhood.completedTutorialIds.includes('prologue-village-complete'));
|
||||
assert(!savedBrotherhood.completedTutorialIds.includes('prologue-camp-entered'));
|
||||
|
||||
await page.keyboard.press('Space');
|
||||
await waitForMilitiaCampReady(page);
|
||||
await page.waitForTimeout(380);
|
||||
|
||||
let militiaCamp = await readMilitiaCamp(page);
|
||||
assert.equal(militiaCamp.campaignStep, 'prologue-camp');
|
||||
assert.equal(militiaCamp.requiredTexturesReady, true);
|
||||
assert.equal(militiaCamp.viewport.width, desktopBrowserViewport.width);
|
||||
assert.equal(militiaCamp.viewport.height, desktopBrowserViewport.height);
|
||||
assert.equal(militiaCamp.movement.speed, 300);
|
||||
assert.equal(militiaCamp.interaction.radius, 122);
|
||||
assert.equal(militiaCamp.npcs.length, 5);
|
||||
assert.equal(militiaCamp.objectives.length, 4);
|
||||
assert.equal(militiaCamp.completedObjectiveIds.length, 0);
|
||||
assert.equal(militiaCamp.exitUnlocked, false);
|
||||
assert.equal(
|
||||
militiaCamp.dialogue.active,
|
||||
true,
|
||||
'The first camp visit should explain why Liu Bei must inspect the camp.'
|
||||
);
|
||||
assertBoundsInsideViewport(militiaCamp.player.bounds, 'initial militia camp player');
|
||||
assertBoundsInsideViewport(militiaCamp.dialogue.bounds, 'initial militia camp dialogue');
|
||||
militiaCamp.npcs.forEach((npc) => assertBoundsInsideViewport(npc.bounds, `camp NPC ${npc.id}`));
|
||||
await advanceMilitiaCampDialogueUntilClosed(page);
|
||||
await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-initial.png');
|
||||
|
||||
await page.keyboard.press('e');
|
||||
await page.waitForTimeout(120);
|
||||
assert.equal(
|
||||
(await readMilitiaCamp(page)).dialogue.active,
|
||||
false,
|
||||
'Militia camp interaction outside the radius must not open dialogue.'
|
||||
);
|
||||
|
||||
const campPlayerBeforeMove = (await readMilitiaCamp(page)).player;
|
||||
await page.keyboard.down('ArrowUp');
|
||||
await page.waitForTimeout(360);
|
||||
await page.keyboard.up('ArrowUp');
|
||||
await page.keyboard.down('ArrowLeft');
|
||||
await page.waitForTimeout(980);
|
||||
await page.keyboard.up('ArrowLeft');
|
||||
await page.waitForTimeout(80);
|
||||
const campAfterMove = await readMilitiaCamp(page);
|
||||
assert(
|
||||
campPlayerBeforeMove.y - campAfterMove.player.y >= 70 &&
|
||||
campPlayerBeforeMove.x - campAfterMove.player.x >= 220,
|
||||
`Expected keyboard traversal through the militia camp: ${JSON.stringify({
|
||||
before: campPlayerBeforeMove,
|
||||
after: campAfterMove.player
|
||||
})}`
|
||||
);
|
||||
assert.equal(
|
||||
campAfterMove.interaction.targetId,
|
||||
'quartermaster',
|
||||
`Expected keyboard traversal to reach the quartermaster: ${JSON.stringify(campAfterMove.interaction)}`
|
||||
);
|
||||
assert.equal(campAfterMove.interaction.canInteract, true);
|
||||
assertBoundsInsideViewport(campAfterMove.interaction.promptBounds, 'militia camp interaction prompt');
|
||||
|
||||
await page.keyboard.press('e');
|
||||
await page.waitForFunction(() => (
|
||||
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'quartermaster'
|
||||
));
|
||||
const campDialoguePlayerBefore = (await readMilitiaCamp(page)).player;
|
||||
assertBoundsInsideViewport(
|
||||
(await readMilitiaCamp(page)).dialogue.bounds,
|
||||
'quartermaster dialogue'
|
||||
);
|
||||
await page.keyboard.down('ArrowRight');
|
||||
await page.waitForTimeout(360);
|
||||
await page.keyboard.up('ArrowRight');
|
||||
const campDialoguePlayerAfter = (await readMilitiaCamp(page)).player;
|
||||
assert(
|
||||
Math.abs(campDialoguePlayerAfter.x - campDialoguePlayerBefore.x) < 1 &&
|
||||
Math.abs(campDialoguePlayerAfter.y - campDialoguePlayerBefore.y) < 1,
|
||||
'Militia camp movement must remain blocked while dialogue is open.'
|
||||
);
|
||||
await advanceMilitiaCampDialogueUntilClosed(page);
|
||||
militiaCamp = await readMilitiaCamp(page);
|
||||
assert(militiaCamp.completedObjectiveIds.includes('inspect-arms'));
|
||||
|
||||
await teleportMilitiaCampTo(page, 'zou-jing');
|
||||
await page.keyboard.press('e');
|
||||
await page.waitForFunction(() => (
|
||||
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'zou-jing'
|
||||
));
|
||||
assert.equal((await readMilitiaCamp(page)).dialogue.totalLines, 1);
|
||||
await advanceMilitiaCampDialogueUntilClosed(page);
|
||||
assert.equal(
|
||||
(await readMilitiaCamp(page)).exitUnlocked,
|
||||
false,
|
||||
'Zou Jing must not release the sortie before every camp inspection is complete.'
|
||||
);
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await waitForDebugApi(page);
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene'));
|
||||
await clickLegacyTitleControl(page, 962, 310);
|
||||
await waitForMilitiaCampReady(page);
|
||||
await page.waitForTimeout(380);
|
||||
militiaCamp = await readMilitiaCamp(page);
|
||||
assert(
|
||||
militiaCamp.completedObjectiveIds.includes('inspect-arms'),
|
||||
'A completed militia camp inspection must survive reload and Continue.'
|
||||
);
|
||||
assert.equal(
|
||||
militiaCamp.dialogue.active,
|
||||
false,
|
||||
'The one-time militia camp orientation must not replay on Continue.'
|
||||
);
|
||||
|
||||
await interactWithMilitiaCampNpc(page, 'guan-yu');
|
||||
await interactWithMilitiaCampNpc(page, 'zhang-fei');
|
||||
militiaCamp = await readMilitiaCamp(page);
|
||||
assert.deepEqual(
|
||||
[...militiaCamp.completedObjectiveIds].sort(),
|
||||
['inspect-arms', 'ready-vanguard', 'review-scout-report'].sort()
|
||||
);
|
||||
assert.equal(militiaCamp.exitUnlocked, true);
|
||||
await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-ready.png');
|
||||
|
||||
await teleportMilitiaCampTo(page, 'zou-jing');
|
||||
await page.keyboard.press('e');
|
||||
await page.waitForFunction(() => (
|
||||
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === 'zou-jing'
|
||||
));
|
||||
const commandCamp = await readMilitiaCamp(page);
|
||||
assert.equal(commandCamp.dialogue.totalLines, 5);
|
||||
assert.deepEqual(
|
||||
commandCamp.npcs
|
||||
.filter((npc) => ['guan-yu', 'zhang-fei'].includes(npc.id))
|
||||
.map((npc) => ({ id: npc.id, x: npc.x, y: npc.y })),
|
||||
[
|
||||
{ id: 'guan-yu', x: 650, y: 420 },
|
||||
{ id: 'zhang-fei', x: 890, y: 420 }
|
||||
],
|
||||
'Guan Yu and Zhang Fei should visibly gather at the command tent before speaking.'
|
||||
);
|
||||
assertBoundsInsideViewport(commandCamp.dialogue.bounds, 'final command dialogue');
|
||||
await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-command.png');
|
||||
await advanceMilitiaCampDialogueUntilClosed(page);
|
||||
await waitForStoryReady(page);
|
||||
|
||||
const departure = await readStory(page);
|
||||
assert.equal(departure.totalPages, 4);
|
||||
assert.equal(departure.totalPages, 2);
|
||||
assert.equal(departure.currentPageId, 'first-sortie');
|
||||
assert.equal(departure.nextScene, 'BattleScene');
|
||||
const savedDeparture = await readCampaignSave(page);
|
||||
assert.equal(savedDeparture.step, 'prologue-town', 'The campaign should remain resumable in departure story until BattleScene starts.');
|
||||
assert(savedDeparture.completedTutorialIds.includes('prologue-village-complete'));
|
||||
assert.equal(
|
||||
savedDeparture.step,
|
||||
'prologue-camp',
|
||||
'The campaign should remain resumable at the departure story until BattleScene starts.'
|
||||
);
|
||||
assert(savedDeparture.completedTutorialIds.includes('prologue-camp-complete'));
|
||||
assert(!savedDeparture.completedTutorialIds.includes('first-battle-basic-controls'));
|
||||
|
||||
for (let pageIndex = 1; pageIndex < 4; pageIndex += 1) {
|
||||
await page.mouse.click(960, 850);
|
||||
await page.waitForFunction((expectedPageIndex) => {
|
||||
const scene = window.__HEROS_DEBUG__?.scene('StoryScene');
|
||||
return scene?.getDebugState?.()?.pageIndex === expectedPageIndex && scene?.transitioning === false;
|
||||
}, pageIndex);
|
||||
}
|
||||
await page.mouse.click(960, 850);
|
||||
await page.waitForFunction(() => {
|
||||
const scene = window.__HEROS_DEBUG__?.scene('StoryScene');
|
||||
return scene?.getDebugState?.()?.pageIndex === 1 && scene?.transitioning === false;
|
||||
});
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForFunction(() => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle?.();
|
||||
@@ -189,6 +381,20 @@ try {
|
||||
assert.equal(battleSave.step, 'first-battle');
|
||||
assert(!battleSave.completedTutorialIds.includes('first-battle-basic-controls'));
|
||||
|
||||
await seedLegacyCompletedVillageSave(page, battleSave);
|
||||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await waitForDebugApi(page);
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene'));
|
||||
await clickLegacyTitleControl(page, 962, 310);
|
||||
await waitForStoryReady(page);
|
||||
const legacyBridge = await readStory(page);
|
||||
assert.equal(legacyBridge.currentPageId, 'militia-rises');
|
||||
assert.equal(legacyBridge.nextScene, 'PrologueMilitiaCampScene');
|
||||
const normalizedLegacySave = await readCampaignSave(page);
|
||||
assert.equal(normalizedLegacySave.step, 'prologue-town');
|
||||
assert(normalizedLegacySave.completedTutorialIds.includes('prologue-village-check-supplies'));
|
||||
assert(normalizedLegacySave.completedTutorialIds.includes('prologue-village-complete'));
|
||||
|
||||
assert.deepEqual(pageErrors, [], `Expected no browser page errors: ${JSON.stringify(pageErrors)}`);
|
||||
assert.deepEqual(
|
||||
consoleErrors.filter((message) => !message.includes('The AudioContext was not allowed to start')),
|
||||
@@ -197,9 +403,9 @@ try {
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Verified the playable prologue village at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
|
||||
`DPR ${desktopBrowserDeviceScaleFactor}: opening handoff, direct movement, distance-gated dialogue, ` +
|
||||
'dialogue movement lock, locked oath, objective autosave/reload, final oath, departure story, and first deployment.'
|
||||
`Verified the playable prologue village and militia camp at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
|
||||
`DPR ${desktopBrowserDeviceScaleFactor}: sequential meetings, direct movement, distance-gated dialogue, ` +
|
||||
'autosave/reload, legacy-save routing, locked oath and command, camp preparation, departure story, and first deployment.'
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
@@ -259,6 +465,28 @@ async function waitForVillageReady(page) {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForMilitiaCampReady(page) {
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const militiaCamp = window.__HEROS_DEBUG__?.militiaCamp?.();
|
||||
return (
|
||||
window.__HEROS_DEBUG__?.activeScenes?.().includes('PrologueMilitiaCampScene') &&
|
||||
militiaCamp?.scene === 'PrologueMilitiaCampScene' &&
|
||||
militiaCamp?.ready === true
|
||||
);
|
||||
}, undefined, { timeout: 30000 });
|
||||
} catch (error) {
|
||||
const diagnostic = await page.evaluate(() => ({
|
||||
activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
|
||||
militiaCamp: window.__HEROS_DEBUG__?.militiaCamp?.() ?? null,
|
||||
story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null
|
||||
}));
|
||||
throw new Error(`Playable militia camp did not become ready: ${JSON.stringify(diagnostic)}`, {
|
||||
cause: error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function readStory(page) {
|
||||
return page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.());
|
||||
}
|
||||
@@ -267,6 +495,10 @@ async function readVillage(page) {
|
||||
return page.evaluate(() => window.__HEROS_DEBUG__?.village?.());
|
||||
}
|
||||
|
||||
async function readMilitiaCamp(page) {
|
||||
return page.evaluate(() => window.__HEROS_DEBUG__?.militiaCamp?.());
|
||||
}
|
||||
|
||||
async function teleportTo(page, targetId) {
|
||||
const teleported = await page.evaluate((id) => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('PrologueVillageScene');
|
||||
@@ -276,6 +508,24 @@ async function teleportTo(page, targetId) {
|
||||
await page.waitForTimeout(80);
|
||||
}
|
||||
|
||||
async function teleportMilitiaCampTo(page, targetId) {
|
||||
const teleported = await page.evaluate((id) => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('PrologueMilitiaCampScene');
|
||||
return scene?.debugTeleportTo?.(id) ?? false;
|
||||
}, targetId);
|
||||
assert.equal(teleported, true, `Expected militia camp debug teleport to ${targetId}.`);
|
||||
await page.waitForTimeout(80);
|
||||
}
|
||||
|
||||
async function interactWithMilitiaCampNpc(page, npcId) {
|
||||
await teleportMilitiaCampTo(page, npcId);
|
||||
await page.keyboard.press('e');
|
||||
await page.waitForFunction((expectedNpcId) => (
|
||||
window.__HEROS_DEBUG__?.militiaCamp?.()?.dialogue?.sourceNpcId === expectedNpcId
|
||||
), npcId);
|
||||
await advanceMilitiaCampDialogueUntilClosed(page);
|
||||
}
|
||||
|
||||
async function advanceDialogueUntilClosed(page) {
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const active = (await readVillage(page))?.dialogue?.active;
|
||||
@@ -288,6 +538,52 @@ async function advanceDialogueUntilClosed(page) {
|
||||
throw new Error(`Dialogue did not close: ${JSON.stringify(await readVillage(page))}`);
|
||||
}
|
||||
|
||||
async function advanceMilitiaCampDialogueUntilClosed(page) {
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const active = (await readMilitiaCamp(page))?.dialogue?.active;
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
await page.mouse.click(960, 850);
|
||||
await page.waitForTimeout(130);
|
||||
}
|
||||
throw new Error(`Militia camp dialogue did not close: ${JSON.stringify(await readMilitiaCamp(page))}`);
|
||||
}
|
||||
|
||||
async function seedLegacyCompletedVillageSave(page, sourceState) {
|
||||
await page.evaluate((state) => {
|
||||
const legacyState = {
|
||||
...state,
|
||||
updatedAt: new Date().toISOString(),
|
||||
step: 'prologue-town',
|
||||
roster: [],
|
||||
bonds: [],
|
||||
inventory: {},
|
||||
selectedSortieUnitIds: [],
|
||||
sortieFormationAssignments: {},
|
||||
sortieItemAssignments: {},
|
||||
battleHistory: {},
|
||||
latestBattleId: undefined,
|
||||
latestBattleReport: undefined,
|
||||
completedTutorialIds: [
|
||||
'prologue-village-entered',
|
||||
'prologue-village-meet-zhang-fei',
|
||||
'prologue-village-meet-guan-yu',
|
||||
'prologue-village-check-supplies',
|
||||
'prologue-village-complete'
|
||||
]
|
||||
};
|
||||
const serialized = JSON.stringify(legacyState);
|
||||
window.localStorage.setItem('heros-web:campaign-state', serialized);
|
||||
window.localStorage.setItem('heros-web:campaign-state:slot-1', serialized);
|
||||
for (const key of Object.keys(window.localStorage)) {
|
||||
if (key.startsWith('heros-web:battle:') || key === 'heros-web:first-battle-state') {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
}, sourceState);
|
||||
}
|
||||
|
||||
async function readCampaignSave(page) {
|
||||
return page.evaluate(() => {
|
||||
const raw = window.localStorage.getItem('heros-web:campaign-state');
|
||||
|
||||
Reference in New Issue
Block a user