feat: resume exploration exactly and harden campaign saves
This commit is contained in:
584
scripts/verify-campaign-settlement-idempotency.mjs
Normal file
584
scripts/verify-campaign-settlement-idempotency.mjs
Normal file
@@ -0,0 +1,584 @@
|
||||
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.`
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user