585 lines
16 KiB
JavaScript
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.`
|
|
);
|
|
}
|