Files
heros_web/scripts/verify-campaign-settlement-idempotency.mjs

585 lines
16 KiB
JavaScript

import assert from 'node:assert/strict';
import { createServer } from 'vite';
const storage = new Map();
let rejectCanonicalSlotWrites = false;
globalThis.window = {
localStorage: {
getItem(key) {
return storage.has(key) ? storage.get(key) : null;
},
setItem(key, value) {
if (
rejectCanonicalSlotWrites &&
key === 'heros-web:campaign-state:slot-1'
) {
throw new Error(
'Simulated canonical campaign save 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 campaign = await server.ssrLoadModule(
'/src/game/state/campaignState.ts'
);
const { battleScenarios } = await server.ssrLoadModule(
'/src/game/data/battles.ts'
);
const scenario =
battleScenarios['first-battle-zhuo-commandery'];
const report = {
battleId: scenario.id,
battleTitle: scenario.title,
outcome: 'victory',
turnNumber: 4,
rewardGold: 120,
defeatedEnemies: 2,
totalEnemies: 2,
objectives: [],
units: scenario.units.filter(
(unit) => unit.faction === 'ally'
),
bonds: scenario.bonds.map((bond) => ({
...bond,
battleExp: 0
})),
itemRewards: ['Bean x2', 'Iron Sword 1'],
campaignRewards: {
supplies: ['Bean x2'],
equipment: ['Iron Sword 1'],
reputation: [],
recruits: [],
unlocks: []
},
completedCampDialogues: [],
completedCampVisits: [],
createdAt: '2026-07-28T07:00:00.000Z'
};
campaign.resetCampaignState();
campaign.setFirstBattleReport(report);
const firstSettlement = campaign.getCampaignState();
const initialSettlementProgress =
battleHistoryProgressOf(
firstSettlement,
scenario.id
);
assert.deepEqual(
economyOf(firstSettlement),
{
gold: 120,
inventory: {
Bean: 2,
'Iron Sword': 1
}
},
'The first victory settlement must apply its ordinary economy exactly once.'
);
campaign.setFirstBattleReport(report);
const exactReplay = campaign.getCampaignState();
assert.deepEqual(
economyOf(exactReplay),
economyOf(firstSettlement),
'Replaying the exact same victory report must not duplicate gold or items.'
);
campaign.completeCampaignAftermath(scenario.id);
campaign.setActiveCampVisitId(
'first-pursuit-scout-tent'
);
campaign.setCampaignExplorationCheckpoint({
version: 1,
scene: 'camp-visit-exploration',
contextId: 'first-pursuit-scout-tent',
player: {
x: 824,
y: 716,
direction: 'east'
}
});
const consumed = campaign.getCampaignState();
const progressedUnit = consumed.roster[0];
const progressedBond = consumed.bonds[0];
assert(progressedUnit, 'Expected a settled allied unit.');
assert(progressedBond, 'Expected a settled campaign bond.');
consumed.gold = 0;
consumed.inventory = {};
progressedUnit.level = Math.min(
99,
progressedUnit.level + 1
);
progressedUnit.exp = 37;
progressedUnit.equipment.weapon.level = Math.min(
9,
progressedUnit.equipment.weapon.level + 1
);
progressedUnit.equipment.weapon.exp = 3;
progressedBond.level = Math.min(
100,
progressedBond.level + 1
);
progressedBond.exp = 41;
progressedBond.battleExp += 11;
campaign.setCampaignState(consumed);
const consumedEconomy = economyOf(
campaign.getCampaignState()
);
const progressedContext = progressAndContextOf(
campaign.getCampaignState(),
progressedUnit.id,
progressedBond.id
);
campaign.setFirstBattleReport(report);
const consumedExactReplay = campaign.getCampaignState();
assert.deepEqual(
economyOf(consumedExactReplay),
consumedEconomy,
'Replaying a settled victory must not resurrect its already-consumed gold or items.'
);
assert.deepEqual(
progressAndContextOf(
consumedExactReplay,
progressedUnit.id,
progressedBond.id
),
progressedContext,
'Replaying a settled victory must preserve post-settlement unit, bond, navigation, and exploration progress.'
);
const consumedReplayProgress =
reportAndSettlementProgressOf(
consumedExactReplay,
scenario.id
);
assert.deepEqual(
consumedReplayProgress.firstBattleReport.units.find(
(unit) => unit.id === progressedUnit.id
),
progressedContext.unit,
'A settled victory replay must refresh firstBattleReport unit progress from the live roster.'
);
assert.deepEqual(
consumedReplayProgress.firstBattleReport.bonds.find(
(bond) => bond.id === progressedBond.id
),
progressedContext.bond,
'A settled victory replay must refresh firstBattleReport bond progress from the live campaign bonds.'
);
assert.deepEqual(
consumedReplayProgress.battleHistory,
initialSettlementProgress,
'A settled victory replay must preserve the original battleHistory unit and bond snapshots.'
);
campaign.setFirstBattleReport({
...report,
rewardGold: 200,
itemRewards: ['Bean x5', 'Iron Armor 1'],
campaignRewards: {
supplies: ['Bean x5'],
equipment: ['Iron Armor 1'],
reputation: [],
recruits: [],
unlocks: []
},
createdAt: '2026-07-28T07:05:00.000Z'
});
const revisedReplay = campaign.getCampaignState();
assert.deepEqual(
economyOf(revisedReplay),
consumedEconomy,
'Revising an already-settled victory report must update history without replacing or resurrecting consumed economy.'
);
assert.deepEqual(
progressAndContextOf(
revisedReplay,
progressedUnit.id,
progressedBond.id
),
progressedContext,
'Revising a settled victory report must not rewind live unit, bond, navigation, or exploration state.'
);
assert.deepEqual(
reportAndSettlementProgressOf(
revisedReplay,
scenario.id
),
consumedReplayProgress,
'Revising a settled victory report must preserve both firstBattleReport and battleHistory unit/bond progress.'
);
const beforeStaleDefeat = campaign.getCampaignState();
const storedBeforeStaleDefeat = new Map(storage);
const staleDefeatResult = campaign.setFirstBattleReport({
...report,
outcome: 'defeat',
rewardGold: 0,
itemRewards: [],
createdAt: '2026-07-28T07:06:00.000Z'
});
assert.equal(
staleDefeatResult.outcome,
'victory',
'A late defeat callback must resolve to the already-settled victory report.'
);
assert.deepEqual(
campaign.getCampaignState(),
beforeStaleDefeat,
'A late defeat callback must not rewind a settled victory step, active exploration context, roster, bonds, or economy.'
);
assert.deepEqual(
[...storage.entries()],
[...storedBeforeStaleDefeat.entries()],
'A rejected late defeat callback must not write a replacement settlement.'
);
const reportlessSettlement =
campaign.getCampaignState();
delete reportlessSettlement.firstBattleReport;
campaign.setCampaignState(reportlessSettlement);
assertFailedCampaignMutationIsAtomic(
campaign,
'settled victory report recovery',
() => campaign.setFirstBattleReport(report)
);
const beforeReportlessDefeat =
campaign.getCampaignState();
const storedBeforeReportlessDefeat =
new Map(storage);
const reportlessDefeatResult =
campaign.setFirstBattleReport({
...report,
outcome: 'defeat',
rewardGold: 0,
itemRewards: [],
createdAt: '2026-07-28T07:07:00.000Z'
});
assert.equal(
reportlessDefeatResult.outcome,
'victory',
'A late defeat callback must still resolve to victory when the settled battle report is missing.'
);
assert.equal(
reportlessDefeatResult.rewardGold,
beforeReportlessDefeat.battleHistory[
scenario.id
].rewardGold,
'A reportless late defeat must recover its return value from the authoritative victory settlement.'
);
assert.deepEqual(
campaign.getCampaignState(),
beforeReportlessDefeat,
'A reportless late defeat must leave in-memory campaign state unchanged.'
);
assert.deepEqual(
[...storage.entries()],
[...storedBeforeReportlessDefeat.entries()],
'A reportless late defeat must leave canonical and mirror storage unchanged.'
);
const recoveredReport =
campaign.setFirstBattleReport(report);
const recoveredSettlement =
campaign.getCampaignState();
assert.equal(
recoveredReport.outcome,
'victory',
'A settled victory replay must restore a missing firstBattleReport.'
);
assert.equal(
recoveredSettlement.firstBattleReport?.outcome,
'victory',
'The recovered firstBattleReport must persist the settled victory outcome.'
);
assert.equal(
recoveredSettlement.firstBattleReport?.rewardGold,
revisedReplay.battleHistory[
scenario.id
].rewardGold,
'Recovered report rewards must come from the authoritative settlement rather than stale replay input.'
);
assert.deepEqual(
economyOf(recoveredSettlement),
consumedEconomy,
'Recovering a missing settled victory report must not resurrect consumed economy.'
);
assert.deepEqual(
reportAndSettlementProgressOf(
recoveredSettlement,
scenario.id
),
consumedReplayProgress,
'Recovering a missing settled victory report must preserve firstBattleReport and battleHistory unit/bond progress.'
);
const beforeMissingBond =
campaign.getCampaignState();
const storedBeforeMissingBond = new Map(storage);
const missingBondResult =
campaign.applyCampVisitReward(
'verify-missing-bond-visit',
{
bondId: 'verify-missing-bond',
bondExp: 10,
gold: 90,
itemRewards: ['Bean x3']
},
'verify-missing-bond-choice'
);
const afterMissingBond =
campaign.getCampaignState();
assert.equal(
missingBondResult,
undefined,
'A camp visit reward that requests an unavailable bond must fail.'
);
assert.deepEqual(
completionAndEconomyOf(afterMissingBond),
completionAndEconomyOf(beforeMissingBond),
'A missing requested bond must leave visit completion, choice history, gold, and inventory unchanged.'
);
assert.deepEqual(
[...storage.entries()],
[...storedBeforeMissingBond.entries()],
'A rejected missing-bond reward must not persist any partial state.'
);
const rollbackFixture = campaign.getCampaignState();
const rollbackBond = rollbackFixture.bonds.find(
(bond) =>
bond.unitIds.every((unitId) =>
rollbackFixture.roster.some(
(unit) => unit.id === unitId
)
)
);
assert(
rollbackBond,
'Expected a roster-backed bond for persistence rollback verification.'
);
rollbackBond.level = 30;
rollbackBond.exp = 0;
rollbackFixture.selectedSortieUnitIds = [
...rollbackBond.unitIds
];
campaign.setCampaignState(rollbackFixture);
assertFailedCampaignMutationIsAtomic(
campaign,
'sortie resonance selection',
() =>
campaign.setCampaignSortieResonanceSelection(
scenario.id,
rollbackBond.id
)
);
assertFailedCampaignMutationIsAtomic(
campaign,
'reserve training focus',
() =>
campaign.setCampaignReserveTrainingFocus(
'class-practice'
)
);
assertFailedCampaignMutationIsAtomic(
campaign,
'reserve training assignment',
() =>
campaign.setCampaignReserveTrainingAssignment(
rollbackFixture.roster[0].id,
'bond-practice'
)
);
assertFailedCampaignMutationIsAtomic(
campaign,
'victory reward notice dismissal',
() =>
campaign.dismissCampaignVictoryRewardNotice(
scenario.id
)
);
const rollbackRecruit = structuredClone(
rollbackFixture.roster[0]
);
rollbackRecruit.id = 'verify-rollback-recruit';
rollbackRecruit.name = 'Rollback Recruit';
assertFailedCampaignMutationIsAtomic(
campaign,
'roster expansion',
() =>
campaign.ensureCampaignRosterUnits([
rollbackRecruit
])
);
console.log(
'Campaign settlement idempotency verification passed: settled victory replays preserve report/history progress and active context, missing reports recover safely, stale defeats are ignored, missing-bond camp rewards are atomic, and canonical save failures leave memory and storage unchanged.'
);
} finally {
await server.close();
}
function economyOf(state) {
return {
gold: state.gold,
inventory: { ...state.inventory }
};
}
function completionAndEconomyOf(state) {
return {
gold: state.gold,
inventory: { ...state.inventory },
completedCampVisits: [
...state.completedCampVisits
],
reportCompletedCampVisits: [
...(state.firstBattleReport
?.completedCampVisits ?? [])
],
campVisitChoiceIds: {
...state.campVisitChoiceIds
}
};
}
function progressAndContextOf(state, unitId, bondId) {
const unit = state.roster.find(
(candidate) => candidate.id === unitId
);
const bond = state.bonds.find(
(candidate) => candidate.id === bondId
);
return {
step: state.step,
latestBattleId: state.latestBattleId ?? null,
pendingAftermathBattleId:
state.pendingAftermathBattleId ?? null,
activeCampVisitId: state.activeCampVisitId ?? null,
activeCityStayId: state.activeCityStayId ?? null,
explorationCheckpoint:
state.explorationCheckpoint ?? null,
unit: unit
? {
id: unit.id,
level: unit.level,
exp: unit.exp,
equipment: structuredClone(unit.equipment)
}
: null,
bond: bond
? {
id: bond.id,
level: bond.level,
exp: bond.exp,
battleExp: bond.battleExp
}
: null
};
}
function reportAndSettlementProgressOf(
state,
battleId
) {
return {
firstBattleReport: {
units: unitProgressOf(
state.firstBattleReport?.units ?? []
),
bonds: bondProgressOf(
state.firstBattleReport?.bonds ?? []
)
},
battleHistory: battleHistoryProgressOf(
state,
battleId
)
};
}
function battleHistoryProgressOf(state, battleId) {
const settlement = state.battleHistory[battleId];
return {
units: unitProgressOf(settlement?.units ?? []),
bonds: bondProgressOf(settlement?.bonds ?? [])
};
}
function unitProgressOf(units) {
return units
.map((unit) => ({
id: unit.id ?? unit.unitId,
level: unit.level,
exp: unit.exp,
equipment: structuredClone(unit.equipment)
}))
.sort((left, right) =>
left.id.localeCompare(right.id)
);
}
function bondProgressOf(bonds) {
return bonds
.map((bond) => ({
id: bond.id,
level: bond.level,
exp: bond.exp,
battleExp: bond.battleExp
}))
.sort((left, right) =>
left.id.localeCompare(right.id)
);
}
function assertFailedCampaignMutationIsAtomic(
campaign,
label,
mutate
) {
const before = campaign.getCampaignState();
const storedBefore = new Map(storage);
rejectCanonicalSlotWrites = true;
try {
assert.throws(
mutate,
/Simulated canonical campaign save failure/,
`${label} must surface the canonical save failure.`
);
} finally {
rejectCanonicalSlotWrites = false;
}
assert.deepEqual(
campaign.getCampaignState(),
before,
`${label} must not leave ghost in-memory state after a failed canonical save.`
);
assert.deepEqual(
[...storage.entries()],
[...storedBefore.entries()],
`${label} must not mutate canonical or mirror storage after a failed canonical save.`
);
}