feat: make camp preparation affect first battle

This commit is contained in:
2026-07-27 04:19:40 +09:00
parent a726ba129f
commit 4d3f8b6a28
7 changed files with 608 additions and 50 deletions

View File

@@ -168,6 +168,11 @@ async function verifyRuntimeIntentState(browserInstance, targetUrl, scenario, op
{ timeout: 30000 }
);
}
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.battle()?.triggeredBattleEvents?.includes('opening') === true,
undefined,
{ timeout: 15000 }
);
const live = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const aliveEnemyCount = live?.units?.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length ?? 0;

View File

@@ -368,6 +368,18 @@ try {
assert.equal(militiaCamp.npcs.length, 5);
assert.equal(militiaCamp.objectives.length, 4);
assert.equal(militiaCamp.completedObjectiveIds.length, 0);
assert.deepEqual(
militiaCamp.preparationFeedback.map(({ id, completed, worldCueVisible }) => ({
id,
completed,
worldCueVisible
})),
[
{ id: 'review-scout-report', completed: false, worldCueVisible: false },
{ id: 'ready-vanguard', completed: false, worldCueVisible: false },
{ id: 'inspect-arms', completed: false, worldCueVisible: false }
]
);
assert.equal(militiaCamp.exitUnlocked, false);
assert.equal(
militiaCamp.dialogue.active,
@@ -440,6 +452,15 @@ try {
await advanceMilitiaCampDialogueUntilClosed(page);
militiaCamp = await readMilitiaCamp(page);
assert(militiaCamp.completedObjectiveIds.includes('inspect-arms'));
const supplyFeedback = militiaCamp.preparationFeedback.find(({ id }) => id === 'inspect-arms');
assert.equal(supplyFeedback?.worldCueVisible, true);
assertBoundsInsideCampMap(supplyFeedback?.worldCueBounds, 'completed supply world cue');
assert(
militiaCamp.preparationFeedback
.filter(({ id }) => id !== 'inspect-arms')
.every(({ worldCueVisible }) => worldCueVisible === false),
'Completing supplies must not reveal unrelated camp preparation cues.'
);
await teleportMilitiaCampTo(page, 'zou-jing');
await page.keyboard.press('e');
@@ -465,6 +486,11 @@ try {
militiaCamp.completedObjectiveIds.includes('inspect-arms'),
'A completed militia camp inspection must survive reload and Continue.'
);
assert.equal(
militiaCamp.preparationFeedback.find(({ id }) => id === 'inspect-arms')?.worldCueVisible,
true,
'A completed supply world cue must survive reload and Continue.'
);
assert.equal(
militiaCamp.dialogue.active,
false,
@@ -478,6 +504,22 @@ try {
[...militiaCamp.completedObjectiveIds].sort(),
['inspect-arms', 'ready-vanguard', 'review-scout-report'].sort()
);
assert(
militiaCamp.preparationFeedback.every(({ completed, worldCueVisible, worldCueBounds }) => {
if (!completed || !worldCueVisible) {
return false;
}
assertBoundsInsideCampMap(worldCueBounds, 'completed camp preparation cue');
return true;
}),
'Every completed preparation must remain visible in the camp world.'
);
assert.equal(
militiaCamp.preparationFeedback.find(({ id }) => id === 'ready-vanguard')?.ambientActorCount,
4,
'Zhang Fei preparation should visibly line up four non-interactive volunteers.'
);
assert.equal(militiaCamp.npcs.length, 5, 'Ambient formation actors must not become dialogue targets.');
assert.equal(militiaCamp.exitUnlocked, true);
await captureStableScreenshot(page, 'dist/verification-prologue-militia-camp-ready.png');
@@ -575,7 +617,7 @@ try {
));
const commandReaction = await readMilitiaCamp(page);
assert.equal(commandReaction.commandChoice.open, false);
assert.equal(commandReaction.dialogue.totalLines, 3);
assert.equal(commandReaction.dialogue.totalLines, 2);
assert.equal(commandReaction.dialogue.portraitTextureKey, 'portrait-zhang-fei-yellow-turban');
const savedCommand = await readCampaignSave(page);
assert.deepEqual(savedCommand.sortieOrderSelection, {
@@ -624,6 +666,89 @@ try {
const firstBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.());
assert.equal(firstBattle.sortieOrder.selectedId, 'mobile');
assert.equal(firstBattle.sortieOrder.live.orderId, 'mobile');
assert.equal(firstBattle.firstBattlePreparation.eligible, true);
assert.equal(firstBattle.firstBattlePreparation.allComplete, true);
assert.equal(firstBattle.firstBattlePreparation.completedCount, 3);
assert.equal(firstBattle.firstBattlePreparation.summaryVisible, true);
assert.match(
firstBattle.battleHud.deploymentPanel.subtitleText,
/막사 준비 3\/3/,
'The deployment subtitle should retain the completed camp preparation summary.'
);
assert.match(
firstBattle.battleHud.deploymentPanel.subtitleText,
/첫 군령/,
'The preparation summary must not replace the first command selected in camp.'
);
assertBoundsInsideViewport(
firstBattle.firstBattlePreparation.summaryBounds,
'first-battle preparation summary'
);
assert.deepEqual(
firstBattle.firstBattlePreparation.callbacks.map(({ callback, completed }) => ({
callback,
completed
})),
[
{ callback: 'enemy-intel', completed: true },
{ callback: 'deployment-flex', completed: true },
{ callback: 'field-salve', completed: true }
]
);
assert.equal(firstBattle.firstBattlePreparation.enemyIntel.visibleDuringDeployment, true);
assert(firstBattle.firstBattlePreparation.enemyIntel.forecastCount > 0);
assert(firstBattle.firstBattlePreparation.enemyIntel.visualCount > 0);
assert.deepEqual(firstBattle.firstBattlePreparation.deploymentFlex, {
enabled: true,
baseReserveSlotCount: 2,
bonusReserveSlotCount: 2,
activeReserveSlotCount: 4
});
assert.deepEqual(firstBattle.firstBattlePreparation.fieldSalve, {
enabled: true,
carrierUnitId: 'guan-yu',
itemId: 'salve',
count: 1
});
assert.equal(firstBattle.itemStocks['guan-yu'].salve, 1);
await captureStableScreenshot(page, 'dist/verification-first-battle-preparation.png');
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
scene?.saveBattleState?.(1);
scene?.loadBattleState?.(1);
});
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.battle?.()?.firstBattlePreparation?.fieldSalve?.count === 1,
undefined,
{ timeout: 30000 }
);
const resumedFirstBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.());
assert.equal(
resumedFirstBattle.firstBattlePreparation.fieldSalve.count,
1,
'Saving and resuming the first battle must not grant the preparation salve twice.'
);
assert.equal(resumedFirstBattle.itemStocks['guan-yu'].salve, 1);
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
scene?.scene.restart({ battleId: 'second-battle-yellow-turban-pursuit' });
});
await page.waitForFunction(() => {
const battle = window.__HEROS_DEBUG__?.battle?.();
return (
battle?.battleId === 'second-battle-yellow-turban-pursuit' &&
battle?.mapBackgroundReady === true
);
}, undefined, { timeout: 90000 });
const secondBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.());
assert.equal(secondBattle.firstBattlePreparation.eligible, false);
assert.equal(secondBattle.firstBattlePreparation.completedCount, 0);
assert(
secondBattle.firstBattlePreparation.callbacks.every(({ completed }) => completed === false),
'Militia-camp preparation callbacks must not leak into later battles.'
);
await seedLegacyCompletedVillageSave(page, battleSave);
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
@@ -924,6 +1049,17 @@ function assertBoundsInsideViewport(bounds, label) {
);
}
function assertBoundsInsideCampMap(bounds, label) {
assert(bounds, `${label}: bounds are required`);
assert(
bounds.x >= 0 &&
bounds.y >= 92 &&
bounds.x + bounds.width <= 1488 &&
bounds.y + bounds.height <= 958,
`${label}: expected bounds inside the militia-camp map, received ${JSON.stringify(bounds)}`
);
}
function assertNpcTextureKeys(npcs, expectedTextureKeyByNpcId, sceneLabel) {
assert(Array.isArray(npcs), `${sceneLabel}: expected an NPC debug-state array.`);
const actualMappings = npcs
@@ -975,7 +1111,7 @@ function assertExplorationBackground(background, expectedKey) {
}
async function captureStableScreenshot(page, path) {
await page.waitForTimeout(220);
await page.waitForTimeout(650);
const loopSlept = await page.evaluate(() => {
const loop = window.__HEROS_GAME__?.loop;
if (!loop || typeof loop.sleep !== 'function') {

View File

@@ -162,6 +162,24 @@ try {
'militia camp objective rows',
failures
);
const preparationPayoffs = militiaCamp.prologueMilitiaCampPreparationPayoffs;
assertArrayEquals(
preparationPayoffs.map((payoff) => payoff.objectiveId),
expectedCampObjectiveIds,
'militia camp preparation payoff order',
failures
);
assertArrayEquals(
preparationPayoffs.map((payoff) => payoff.battleCallback),
['enemy-intel', 'deployment-flex', 'field-salve'],
'militia camp preparation battle callbacks',
failures
);
assert(
preparationPayoffs.every((payoff) => payoff.worldCue.length > 0 && payoff.battleLabel.length > 0),
'every militia camp preparation must name its world cue and first-battle callback',
failures
);
assert(
campObjectives.find((objective) => objective.id === 'review-scout-report')?.label.includes('피난로'),
'Guan Yu scout objective must describe the evacuation-route information actually reviewed',
@@ -214,7 +232,7 @@ try {
failures
);
assert(
choice.reaction.length === 3 &&
choice.reaction.length === 2 &&
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`,
@@ -232,6 +250,13 @@ try {
'Zou Jing command setup should be compressed to one line so the new decision adds no extra input',
failures
);
assert(
['guan-yu', 'zhang-fei', 'quartermaster'].every((npcId) => (
campNpcs.find((npc) => npc.id === npcId)?.dialogue.length === 2
)),
'mandatory camp inspections should each resolve in two lines before their visible world payoff',
failures
);
assert(
campNpcs.some((npc) => !npc.objectiveId && !npc.departure),
'militia camp should include at least one optional volunteer conversation',