Files
heros_web/scripts/verify-second-battle-relief-exploration.mjs

658 lines
20 KiB
JavaScript

import { createServer } from 'vite';
const storage = new Map();
let rejectStorageWrites = false;
globalThis.window = {
localStorage: {
getItem(key) {
return storage.has(key) ? storage.get(key) : null;
},
setItem(key, value) {
if (rejectStorageWrites) {
throw new Error('Simulated campaign storage write failure.');
}
storage.set(key, String(value));
},
removeItem(key) {
storage.delete(key);
},
clear() {
storage.clear();
}
}
};
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true, hmr: false },
appType: 'custom'
});
try {
const dataModule = await server.ssrLoadModule(
'/src/game/data/secondBattleReliefExploration.ts'
);
const memoryModule = await server.ssrLoadModule(
'/src/game/data/secondBattleReliefMemory.ts'
);
const actionModule = await server.ssrLoadModule(
'/src/game/state/secondBattleReliefActions.ts'
);
const campaignModule = await server.ssrLoadModule(
'/src/game/state/campaignState.ts'
);
const { battleScenarios } = await server.ssrLoadModule(
'/src/game/data/battles.ts'
);
const { usableCatalog } = await server.ssrLoadModule(
'/src/game/data/battleUsables.ts'
);
const explorationAssets = await server.ssrLoadModule(
'/src/game/data/explorationCharacterAssets.ts'
);
const portraitAssets = await server.ssrLoadModule(
'/src/game/data/portraitAssets.ts'
);
const firstScoutModule = await server.ssrLoadModule(
'/src/game/data/firstPursuitScoutMemory.ts'
);
verifyCanonicalDefinition(
dataModule,
battleScenarios,
usableCatalog,
explorationAssets,
portraitAssets
);
verifyBattleResultAndScoutReview(
dataModule,
memoryModule,
firstScoutModule
);
verifyTargetBattleMemories(dataModule, memoryModule);
verifyGuardedPersistentProgress(
dataModule,
memoryModule,
actionModule,
campaignModule,
battleScenarios
);
console.log(
'Second-battle relief exploration verification passed (canonical route data, ordered resumable objectives, first-scout result review, guarded rewards, and third-battle memory isolation).'
);
} finally {
await server.close();
}
function verifyCanonicalDefinition(
dataModule,
battleScenarios,
usableCatalog,
explorationAssets,
portraitAssets
) {
const definition =
dataModule.secondBattleReliefExplorationDefinition;
const source =
battleScenarios[dataModule.secondBattleReliefSourceBattleId];
const target =
battleScenarios[dataModule.secondBattleReliefTargetBattleId];
assert(
definition.id === dataModule.secondBattleReliefVisitId &&
definition.sourceBattleId === source.id &&
definition.targetBattleId === target.id,
'The relief route must stay scoped from the second victory to the third battle.'
);
assert(
source.objectives.some(
(objective) =>
objective.id ===
dataModule.secondBattleReliefSourceObjectiveIds.village
) &&
source.objectives.some(
(objective) =>
objective.id ===
dataModule.secondBattleReliefSourceObjectiveIds.quick
),
'The relief memory must reference the authored village and quick-victory objectives.'
);
const actorIds = new Set();
definition.actors.forEach((actor) => {
assert(!actorIds.has(actor.id), `Duplicate relief actor id ${actor.id}.`);
actorIds.add(actor.id);
assert(
pointInside(actor, definition.movementBounds),
`Actor ${actor.id} must stay inside the FHD exploration movement bounds.`
);
assert(
explorationAssets.hasExplorationCharacterAsset(actor.textureKey),
`Actor ${actor.id} must use an existing exploration character sheet.`
);
assert(
typeof actor.portraitKey === 'string' &&
portraitAssets.portraitAssetEntriesForKey(actor.portraitKey)
.length > 0,
`Actor ${actor.id} must use an existing face portrait in dialogue.`
);
assert(
actor.dialogue.length >= 2 && actor.repeatDialogue.length >= 1,
`Actor ${actor.id} must have first-time and repeat dialogue.`
);
});
assert(
pointInside(definition.player, definition.movementBounds) &&
pointInside(definition.exit, definition.movementBounds),
'The player start and camp exit must stay inside the movement bounds.'
);
const objectiveIds = new Set();
definition.objectives.forEach((objective, index) => {
assert(
!objectiveIds.has(objective.id),
`Duplicate relief objective id ${objective.id}.`
);
assert(
actorIds.has(objective.targetActorId),
`Objective ${objective.id} must point to a canonical actor.`
);
objective.prerequisiteObjectiveIds.forEach((prerequisiteId) => {
assert(
objectiveIds.has(prerequisiteId),
`Objective ${objective.id} prerequisite ${prerequisiteId} must appear earlier in the route.`
);
});
assert(
objective.id ===
dataModule.secondBattleReliefObjectiveIds[index],
`Objective ${objective.id} must retain the authored route order.`
);
objectiveIds.add(objective.id);
});
assert(
JSON.stringify(
dataModule.secondBattleReliefPrerequisiteObjectiveIds
) ===
JSON.stringify(
dataModule.secondBattleReliefObjectiveIds.slice(0, -1)
),
'Every field interaction before the final Jian Yong choice must be a persisted prerequisite.'
);
assert(
JSON.stringify(definition.choices.map(({ id }) => id)) ===
JSON.stringify(dataModule.secondBattleReliefChoiceIds),
'The two relief choices must remain canonical and ordered.'
);
definition.choices.forEach((choice) => {
const effect = choice.thirdBattleEffect;
assert(
choice.bondExp > 0 &&
choice.itemRewards.length === 1 &&
/^.+\s+\d+$/.test(choice.itemRewards[0]),
`Choice ${choice.id} must provide valid bond and item feedback.`
);
assert(
target.objectives.some(
(objective) => objective.id === effect.focusObjectiveId
),
`Choice ${choice.id} must focus an objective in the third battle.`
);
assert(
target.units.some(
(unit) =>
unit.id === effect.recommendedUnitId &&
unit.faction === 'ally'
),
`Choice ${choice.id} must recommend an allied third-battle unit.`
);
if (effect.kind === 'village-supply-line') {
assert(
usableCatalog[effect.carriedUsableId]?.command === 'item',
'The village supply payoff must reference a real battle item.'
);
} else {
assert(
target.units.some(
(unit) =>
unit.id === effect.trackedEnemyUnitId &&
unit.faction === 'enemy'
),
'The ferry intelligence payoff must track the authored third-battle leader.'
);
}
});
}
function verifyBattleResultAndScoutReview(
dataModule,
memoryModule,
firstScoutModule
) {
const cases = [
{
choiceId: firstScoutModule.firstPursuitScoutChoiceIds[0],
villageAchieved: true,
quickAchieved: true,
expectedFocus: 'river',
expectedOutcome: 'confirmed'
},
{
choiceId: firstScoutModule.firstPursuitScoutChoiceIds[0],
villageAchieved: true,
quickAchieved: false,
expectedFocus: 'river',
expectedOutcome: 'missed'
},
{
choiceId: firstScoutModule.firstPursuitScoutChoiceIds[1],
villageAchieved: true,
quickAchieved: false,
expectedFocus: 'village',
expectedOutcome: 'confirmed'
},
{
choiceId: firstScoutModule.firstPursuitScoutChoiceIds[1],
villageAchieved: false,
quickAchieved: true,
expectedFocus: 'village',
expectedOutcome: 'missed'
}
];
cases.forEach((testCase) => {
const campaign = literalCampaign(dataModule, {
villageAchieved: testCase.villageAchieved,
quickAchieved: testCase.quickAchieved,
firstScoutVisitId: firstScoutModule.firstPursuitScoutVisitId,
firstScoutChoiceId: testCase.choiceId
});
const context =
memoryModule.resolveSecondBattleReliefContext(campaign);
assert(
context?.scoutReview.choiceId === testCase.choiceId &&
context.scoutReview.focus === testCase.expectedFocus &&
context.scoutReview.outcome === testCase.expectedOutcome,
`The first scout choice must be reviewed against its matching second-battle result: ${JSON.stringify({
testCase,
context
})}`
);
});
const unrecorded =
memoryModule.resolveSecondBattleReliefContext(
literalCampaign(dataModule, {
villageAchieved: false,
quickAchieved: false
})
);
assert(
unrecorded?.scoutReview.outcome === 'unrecorded',
'Legacy saves without a first-scout marker must receive a safe unrecorded fallback.'
);
}
function verifyTargetBattleMemories(dataModule, memoryModule) {
dataModule.secondBattleReliefChoiceIds.forEach((choiceId) => {
const campaign = literalCampaign(dataModule, {
villageAchieved: choiceId ===
'restore-northern-village',
quickAchieved: choiceId === 'trace-ferry-messenger',
reliefChoiceId: choiceId
});
const memory = memoryModule.resolveSecondBattleReliefMemory({
campaign,
battleId: dataModule.secondBattleReliefTargetBattleId
});
assert(
memory?.choiceId === choiceId &&
memory.effect.readiness === 'reinforced' &&
memory.effect.sourceObjectiveAchieved === true &&
memory.openingLines.length === 3 &&
memory.openingLines[0] === memory.campSummaryLine,
`Choice ${choiceId} must resolve a reinforced third-battle memory.`
);
const recoveredCampaign = literalCampaign(dataModule, {
villageAchieved: false,
quickAchieved: false,
reliefChoiceId: choiceId
});
const recovered = memoryModule.resolveSecondBattleReliefMemory({
campaign: recoveredCampaign,
battleId: dataModule.secondBattleReliefTargetBattleId
});
assert(
recovered?.effect.readiness === 'recovered' &&
recovered.effect.sourceObjectiveAchieved === false,
`Choice ${choiceId} must turn a missed source objective into a clearly labelled recovery, not a false success.`
);
assert(
memoryModule.resolveSecondBattleReliefMemory({
campaign,
battleId: dataModule.secondBattleReliefSourceBattleId
}) === undefined,
`Choice ${choiceId} memory must not leak back into the source battle.`
);
});
const forged = literalCampaign(dataModule, {
villageAchieved: true,
quickAchieved: true,
reliefChoiceId: 'forged-choice'
});
assert(
memoryModule.resolveSecondBattleReliefMemory({
campaign: forged,
battleId: dataModule.secondBattleReliefTargetBattleId
}) === undefined,
'A forged relief choice must never produce a third-battle effect.'
);
}
function verifyGuardedPersistentProgress(
dataModule,
memoryModule,
actionModule,
campaignModule,
battleScenarios
) {
storage.clear();
campaignModule.resetCampaignState();
assert(
actionModule.recordSecondBattleReliefObjective(
dataModule.secondBattleReliefObjectiveIds[0]
).reason === 'invalid-campaign',
'Relief progress must reject completion before the second victory camp.'
);
prepareSecondVictory(
campaignModule,
battleScenarios,
dataModule,
{ villageAchieved: true, quickAchieved: false }
);
assert(
actionModule.recordSecondBattleReliefObjective(
dataModule.secondBattleReliefObjectiveIds[1]
).reason === 'prerequisites-incomplete',
'The ferry testimony must not be recordable before the village check.'
);
assert(
actionModule.completeSecondBattleReliefExploration(
dataModule.secondBattleReliefChoiceIds[0]
).reason === 'prerequisites-incomplete',
'The final choice must remain locked until every field objective is persisted.'
);
dataModule.secondBattleReliefPrerequisiteObjectiveIds.forEach(
(objectiveId, index) => {
const result =
actionModule.recordSecondBattleReliefObjective(objectiveId);
assert(
result.ok === true &&
result.completedObjectiveIds.length === index + 1,
`Objective ${objectiveId} must persist in authored order.`
);
}
);
const resumedProgress = actionModule.secondBattleReliefProgress();
assert(
resumedProgress.choiceReady === true &&
JSON.stringify(resumedProgress.completedObjectiveIds) ===
JSON.stringify(
dataModule.secondBattleReliefPrerequisiteObjectiveIds
),
'Reloadable campaign markers must reconstruct all completed field objectives.'
);
const choice =
dataModule.secondBattleReliefExplorationDefinition.choices[0];
const inventoryBefore =
campaignModule.getCampaignState().inventory;
const completed =
actionModule.completeSecondBattleReliefExploration(choice.id);
assert(
completed.ok === true &&
completed.memory.choiceId === choice.id &&
completed.memory.targetBattleId ===
dataModule.secondBattleReliefTargetBattleId &&
completed.campaign.completedCampVisits.includes(
dataModule.secondBattleReliefVisitId
) &&
completed.campaign.campVisitChoiceIds[
dataModule.secondBattleReliefVisitId
] === choice.id,
'A valid final choice must atomically save the route, reward, and third-battle memory.'
);
assertRewardApplied(choice, inventoryBefore, completed.campaign);
const completedProgress = actionModule.secondBattleReliefProgress();
assert(
completedProgress.completed === true &&
completedProgress.nextObjectiveId === null &&
completedProgress.choiceReady === false,
'A completed route must not keep advertising a stale final objective or another choice.'
);
assert(
actionModule.completeSecondBattleReliefExploration(choice.id)
.reason === 'already-completed',
'The completed route must reject duplicate rewards.'
);
prepareSecondVictory(
campaignModule,
battleScenarios,
dataModule,
{ villageAchieved: false, quickAchieved: true }
);
dataModule.secondBattleReliefPrerequisiteObjectiveIds.forEach(
(objectiveId) => {
const result =
actionModule.recordSecondBattleReliefObjective(objectiveId);
assert(result.ok === true, `Expected progress for ${objectiveId}.`);
}
);
const beforeFailedSave = progressSnapshot(
campaignModule.getCampaignState(),
dataModule
);
let failedSave;
rejectStorageWrites = true;
try {
failedSave = actionModule.completeSecondBattleReliefExploration(
dataModule.secondBattleReliefChoiceIds[1]
);
} finally {
rejectStorageWrites = false;
}
const afterFailedSave = progressSnapshot(
campaignModule.getCampaignState(),
dataModule
);
assert(
failedSave?.ok === false &&
failedSave.reason === 'save-unavailable' &&
JSON.stringify(afterFailedSave) ===
JSON.stringify(beforeFailedSave),
`A failed save must restore the route, rewards, and memory markers: ${JSON.stringify({
failedSave,
beforeFailedSave,
afterFailedSave
})}`
);
const finalCampaign = campaignModule.getCampaignState();
assert(
memoryModule.resolveSecondBattleReliefMemory({
campaign: finalCampaign,
battleId: dataModule.secondBattleReliefTargetBattleId
}) === undefined,
'A failed final write must not leave a ghost third-battle memory.'
);
}
function prepareSecondVictory(
campaignModule,
battleScenarios,
dataModule,
{ villageAchieved, quickAchieved }
) {
storage.clear();
campaignModule.resetCampaignState();
const scenario =
battleScenarios[dataModule.secondBattleReliefSourceBattleId];
campaignModule.setFirstBattleReport({
battleId: scenario.id,
battleTitle: scenario.title,
outcome: 'victory',
turnNumber: quickAchieved ? 18 : 27,
rewardGold: scenario.baseVictoryGold,
defeatedEnemies: scenario.units.filter(
(unit) => unit.faction === 'enemy'
).length,
totalEnemies: scenario.units.filter(
(unit) => unit.faction === 'enemy'
).length,
objectives: scenario.objectives.map((objective) => {
const achieved = objective.id ===
dataModule.secondBattleReliefSourceObjectiveIds.village
? villageAchieved
: objective.id ===
dataModule.secondBattleReliefSourceObjectiveIds.quick
? quickAchieved
: true;
return {
id: objective.id,
label: objective.label,
achieved,
status: achieved ? 'done' : 'failed',
detail: achieved ? '달성' : '미달',
rewardGold: objective.rewardGold
};
}),
units: scenario.units,
bonds: scenario.bonds.map((bond) => ({
...bond,
battleExp: 0
})),
itemRewards: [],
completedCampDialogues: [],
completedCampVisits: [],
createdAt: '2026-07-27T00:00:00.000Z'
});
const campaign = campaignModule.getCampaignState();
assert(
campaign.step === 'second-camp' &&
campaign.latestBattleId === scenario.id,
'The canonical second victory must route into second-camp.'
);
return campaign;
}
function literalCampaign(
dataModule,
{
villageAchieved,
quickAchieved,
reliefChoiceId,
firstScoutVisitId,
firstScoutChoiceId
}
) {
const completedCampVisits = [];
const campVisitChoiceIds = {};
if (reliefChoiceId) {
completedCampVisits.push(dataModule.secondBattleReliefVisitId);
campVisitChoiceIds[dataModule.secondBattleReliefVisitId] =
reliefChoiceId;
}
if (firstScoutVisitId && firstScoutChoiceId) {
completedCampVisits.push(firstScoutVisitId);
campVisitChoiceIds[firstScoutVisitId] = firstScoutChoiceId;
}
return {
battleHistory: {
[dataModule.secondBattleReliefSourceBattleId]: {
battleId: dataModule.secondBattleReliefSourceBattleId,
outcome: 'victory',
turnNumber: quickAchieved ? 18 : 27,
objectives: [
{
id: dataModule.secondBattleReliefSourceObjectiveIds.village,
achieved: villageAchieved,
status: villageAchieved ? 'done' : 'failed'
},
{
id: dataModule.secondBattleReliefSourceObjectiveIds.quick,
achieved: quickAchieved,
status: quickAchieved ? 'done' : 'failed'
}
]
}
},
completedCampVisits,
campVisitChoiceIds
};
}
function assertRewardApplied(choice, inventoryBefore, campaign) {
choice.itemRewards.forEach((reward) => {
const match = /^(.+?)\s+(\d+)$/.exec(reward);
assert(match, `Invalid reward label ${reward}.`);
const [, label, amountText] = match;
const amount = Number.parseInt(amountText, 10);
assert(
(campaign.inventory[label] ?? 0) ===
(inventoryBefore[label] ?? 0) + amount,
`Expected ${reward} to be granted exactly once.`
);
});
}
function progressSnapshot(campaign, dataModule) {
const relevantIds = campaign.completedCampVisits.filter(
(visitId) =>
visitId === dataModule.secondBattleReliefVisitId ||
visitId.startsWith(
`${dataModule.secondBattleReliefVisitId}:objective:`
)
);
return {
completedCampVisits: relevantIds,
reportCompletedCampVisits:
campaign.firstBattleReport?.completedCampVisits.filter(
(visitId) => relevantIds.includes(visitId)
) ?? [],
choiceId:
campaign.campVisitChoiceIds[
dataModule.secondBattleReliefVisitId
] ?? null,
inventory: { ...campaign.inventory },
bonds: campaign.bonds.map(
({ id, level, exp, battleExp }) => ({
id,
level,
exp,
battleExp
})
)
};
}
function pointInside(point, bounds) {
return (
point.x >= bounds.x &&
point.y >= bounds.y &&
point.x <= bounds.x + bounds.width &&
point.y <= bounds.y + bounds.height
);
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}