feat: resume exploration exactly and harden campaign saves
This commit is contained in:
1499
scripts/verify-camp-exploration-checkpoint-browser.mjs
Normal file
1499
scripts/verify-camp-exploration-checkpoint-browser.mjs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -82,15 +82,15 @@ try {
|
||||
verifyValidSetterPersistence(fixture);
|
||||
verifyNormalizationFailures(fixture);
|
||||
verifyCampaignStepClearsVisit(fixture);
|
||||
verifyBattleRecordUpdateClearsVisit(fixture, 'victory');
|
||||
verifyBattleRecordUpdateClearsVisit(fixture, 'defeat');
|
||||
verifySettledBattleReplayPreservesVisit(fixture, 'victory');
|
||||
verifySettledBattleReplayPreservesVisit(fixture, 'defeat');
|
||||
}
|
||||
|
||||
verifyCityMutualExclusion();
|
||||
verifyLegacySaveWithoutCampVisitField();
|
||||
|
||||
console.log(
|
||||
'Verified camp-visit resume state for all three visits: setter validation, base/active-slot persistence, reload retention, resume summaries, invalid-state normalization, campaign-step and battle-record clearing, city mutual exclusion, and legacy-save compatibility.'
|
||||
'Verified camp-visit resume state for all three visits: setter validation, base/active-slot persistence, reload retention, resume summaries, invalid-state normalization, campaign-step clearing, settled battle replay preservation, city mutual exclusion, and legacy-save compatibility.'
|
||||
);
|
||||
|
||||
function verifyValidSetterPersistence(fixture) {
|
||||
@@ -271,23 +271,31 @@ try {
|
||||
);
|
||||
}
|
||||
|
||||
function verifyBattleRecordUpdateClearsVisit(fixture, outcome) {
|
||||
function verifySettledBattleReplayPreservesVisit(
|
||||
fixture,
|
||||
outcome
|
||||
) {
|
||||
storage.clear();
|
||||
campaign.setCampaignState(validCampVisitState(fixture, 2));
|
||||
campaign.setActiveCampVisitId(fixture.visitId);
|
||||
|
||||
campaign.setFirstBattleReport(
|
||||
const replayedReport = campaign.setFirstBattleReport(
|
||||
battleReport(fixture.sourceBattleId, outcome)
|
||||
);
|
||||
assert.equal(
|
||||
replayedReport.outcome,
|
||||
'victory',
|
||||
`${fixture.visitId}: a settled ${outcome} replay must resolve to the existing victory.`
|
||||
);
|
||||
assert.equal(
|
||||
campaign.getCampaignState().activeCampVisitId,
|
||||
undefined,
|
||||
`${fixture.visitId}: a ${outcome} battle-record update must clear the active visit.`
|
||||
fixture.visitId,
|
||||
`${fixture.visitId}: a settled ${outcome} replay must preserve the active visit.`
|
||||
);
|
||||
assertPersistedVisit(
|
||||
undefined,
|
||||
fixture.visitId,
|
||||
2,
|
||||
`${fixture.visitId}: ${outcome} battle-record persistence`
|
||||
`${fixture.visitId}: settled ${outcome} replay persistence`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
636
scripts/verify-campaign-exploration-checkpoint-normalization.mjs
Normal file
636
scripts/verify-campaign-exploration-checkpoint-normalization.mjs
Normal file
@@ -0,0 +1,636 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const storage = new Map();
|
||||
globalThis.window = {
|
||||
localStorage: {
|
||||
getItem(key) {
|
||||
return storage.has(key) ? storage.get(key) : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
storage.set(key, String(value));
|
||||
},
|
||||
removeItem(key) {
|
||||
storage.delete(key);
|
||||
},
|
||||
clear() {
|
||||
storage.clear();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
server: { middlewareMode: true },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
try {
|
||||
const campaign = await server.ssrLoadModule(
|
||||
'/src/game/state/campaignState.ts'
|
||||
);
|
||||
const { battleScenarios } = await server.ssrLoadModule(
|
||||
'/src/game/data/battles.ts'
|
||||
);
|
||||
const firstPursuit = await server.ssrLoadModule(
|
||||
'/src/game/data/firstPursuitScoutMemory.ts'
|
||||
);
|
||||
const secondRelief = await server.ssrLoadModule(
|
||||
'/src/game/data/secondBattleReliefExploration.ts'
|
||||
);
|
||||
const thirdCamp = await server.ssrLoadModule(
|
||||
'/src/game/data/thirdCampExploration.ts'
|
||||
);
|
||||
|
||||
const campFixtures = [
|
||||
{
|
||||
label: 'first camp',
|
||||
visitId: firstPursuit.firstPursuitScoutVisitId,
|
||||
sourceBattleId: firstPursuit.firstPursuitScoutSourceBattleId,
|
||||
step: 'first-camp',
|
||||
dialogueKeys: [
|
||||
'visit-interaction',
|
||||
'camp-exit',
|
||||
'first-choice-response'
|
||||
],
|
||||
choiceModes: ['visit'],
|
||||
invalidOverlays: [
|
||||
{
|
||||
type: 'dialogue',
|
||||
dialogueKey: 'second-choice-response',
|
||||
lineIndex: 1
|
||||
},
|
||||
{ type: 'choice', mode: 'third-companion' },
|
||||
{ type: 'shop', sourceNpcId: 'quartermaster' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'second camp',
|
||||
visitId: secondRelief.secondBattleReliefVisitId,
|
||||
sourceBattleId: secondRelief.secondBattleReliefSourceBattleId,
|
||||
step: 'second-camp',
|
||||
dialogueKeys: [
|
||||
'visit-interaction',
|
||||
'camp-exit',
|
||||
'second-choice-response'
|
||||
],
|
||||
choiceModes: ['visit'],
|
||||
invalidOverlays: [
|
||||
{
|
||||
type: 'dialogue',
|
||||
dialogueKey: 'first-choice-response',
|
||||
lineIndex: 1
|
||||
},
|
||||
{ type: 'choice', mode: 'city-resonance' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'third camp',
|
||||
visitId: thirdCamp.thirdCampExplorationVisitId,
|
||||
sourceBattleId: thirdCamp.thirdCampExplorationSourceBattleId,
|
||||
step: 'third-camp',
|
||||
dialogueKeys: [
|
||||
'third-intro',
|
||||
'visit-interaction',
|
||||
'camp-exit',
|
||||
'third-companion-response'
|
||||
],
|
||||
choiceModes: ['third-companion'],
|
||||
invalidOverlays: [
|
||||
{
|
||||
type: 'dialogue',
|
||||
dialogueKey: 'first-choice-response',
|
||||
lineIndex: 1
|
||||
},
|
||||
{ type: 'choice', mode: 'visit' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const routes = [
|
||||
...campFixtures.map((fixture) => ({
|
||||
label: fixture.label,
|
||||
state: () => validCampState(fixture),
|
||||
scene: 'camp-visit-exploration',
|
||||
contextId: fixture.visitId,
|
||||
dialogueKeys: fixture.dialogueKeys,
|
||||
choiceOverlays: fixture.choiceModes.map((mode) => ({
|
||||
type: 'choice',
|
||||
mode,
|
||||
sourceNpcId: `${fixture.label}-npc`
|
||||
})),
|
||||
shopOverlays: [],
|
||||
invalidOverlays: fixture.invalidOverlays
|
||||
})),
|
||||
{
|
||||
label: 'city',
|
||||
state: validCityState,
|
||||
scene: 'city-stay',
|
||||
contextId: 'xuzhou',
|
||||
dialogueKeys: [
|
||||
'city-interaction',
|
||||
'city-exit',
|
||||
'city-resonance-response'
|
||||
],
|
||||
choiceOverlays: [
|
||||
{
|
||||
type: 'choice',
|
||||
mode: 'city-resonance',
|
||||
sourceNpcId: 'city-companion'
|
||||
}
|
||||
],
|
||||
shopOverlays: [
|
||||
{
|
||||
type: 'shop',
|
||||
sourceNpcId: 'city-quartermaster'
|
||||
}
|
||||
],
|
||||
invalidOverlays: [
|
||||
{
|
||||
type: 'dialogue',
|
||||
dialogueKey: 'first-choice-response',
|
||||
lineIndex: 1
|
||||
},
|
||||
{ type: 'choice', mode: 'visit' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'prologue village',
|
||||
state: validVillageState,
|
||||
scene: 'prologue-village',
|
||||
contextId: 'prologue-village',
|
||||
dialogueKeys: [
|
||||
'prologue-village-interaction',
|
||||
'prologue-village-oath'
|
||||
],
|
||||
choiceOverlays: [],
|
||||
shopOverlays: [],
|
||||
invalidOverlays: [
|
||||
{ type: 'choice', mode: 'city-resonance' },
|
||||
{
|
||||
type: 'dialogue',
|
||||
dialogueKey: 'prologue-camp-interaction',
|
||||
lineIndex: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'prologue militia camp',
|
||||
state: validMilitiaState,
|
||||
scene: 'prologue-militia-camp',
|
||||
contextId: 'prologue-militia-camp',
|
||||
dialogueKeys: [
|
||||
'prologue-camp-interaction',
|
||||
'prologue-camp-departure',
|
||||
'prologue-camp-command-response'
|
||||
],
|
||||
choiceOverlays: [
|
||||
{
|
||||
type: 'choice',
|
||||
mode: 'prologue-command',
|
||||
sourceNpcId: 'liu-bei'
|
||||
},
|
||||
{
|
||||
type: 'choice',
|
||||
mode: 'prologue-command',
|
||||
sourceNpcId: 'liu-bei',
|
||||
choiceId: 'mobile'
|
||||
}
|
||||
],
|
||||
shopOverlays: [],
|
||||
invalidOverlays: [
|
||||
{
|
||||
type: 'choice',
|
||||
mode: 'prologue-command',
|
||||
choiceId: 'unknown-order'
|
||||
},
|
||||
{ type: 'shop', sourceNpcId: 'camp-quartermaster' },
|
||||
{
|
||||
type: 'dialogue',
|
||||
dialogueKey: 'prologue-village-oath',
|
||||
lineIndex: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
verifyValidPolicyMatrix();
|
||||
verifyStoredInvalidOverlayPreservesPlayer();
|
||||
verifyInvalidSetterIsCompleteNoop();
|
||||
verifyStaleCampToCityRace();
|
||||
|
||||
console.log(
|
||||
'Verified exploration checkpoint normalization: complete per-context overlay policy matrix, stored invalid-overlay player preservation, invalid-setter state/timestamp/base+slot byte invariance, sortie-order choice validation, and stale camp-to-city write rejection.'
|
||||
);
|
||||
|
||||
function verifyValidPolicyMatrix() {
|
||||
for (const route of routes) {
|
||||
const overlays = [
|
||||
...route.dialogueKeys.map((dialogueKey) => ({
|
||||
type: 'dialogue',
|
||||
dialogueKey,
|
||||
sourceNpcId: `${route.label}-npc`,
|
||||
lineIndex: 4
|
||||
})),
|
||||
...route.choiceOverlays,
|
||||
...route.shopOverlays
|
||||
];
|
||||
|
||||
for (const overlay of overlays) {
|
||||
const checkpoint = checkpointFor(route, overlay);
|
||||
const loaded = loadRawState(
|
||||
withCheckpoint(route.state(), checkpoint)
|
||||
);
|
||||
assert.deepEqual(
|
||||
loaded.explorationCheckpoint,
|
||||
checkpoint,
|
||||
`${route.label}: valid ${overlay.type} overlay must survive stored-save normalization.`
|
||||
);
|
||||
}
|
||||
|
||||
const checkpointWithoutOverlay = checkpointFor(route);
|
||||
const loadedWithoutOverlay = loadRawState(
|
||||
withCheckpoint(route.state(), checkpointWithoutOverlay)
|
||||
);
|
||||
assert.deepEqual(
|
||||
loadedWithoutOverlay.explorationCheckpoint,
|
||||
checkpointWithoutOverlay,
|
||||
`${route.label}: an absent overlay must remain absent.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyStoredInvalidOverlayPreservesPlayer() {
|
||||
for (const route of routes) {
|
||||
for (const invalidOverlay of [
|
||||
...route.invalidOverlays,
|
||||
null,
|
||||
{ type: 'unknown-overlay' }
|
||||
]) {
|
||||
const rawCheckpoint = checkpointFor(
|
||||
route,
|
||||
invalidOverlay
|
||||
);
|
||||
const expectedCheckpoint = checkpointFor(route);
|
||||
const loaded = loadRawState(
|
||||
withCheckpoint(route.state(), rawCheckpoint)
|
||||
);
|
||||
assert.deepEqual(
|
||||
loaded.explorationCheckpoint,
|
||||
expectedCheckpoint,
|
||||
`${route.label}: an incompatible stored overlay must be removed without losing the player checkpoint.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function verifyInvalidSetterIsCompleteNoop() {
|
||||
const firstRoute = routes[0];
|
||||
assertSetterNoop(
|
||||
firstRoute,
|
||||
{
|
||||
...checkpointFor(firstRoute),
|
||||
player: {
|
||||
x: -1,
|
||||
y: 512,
|
||||
direction: 'south'
|
||||
}
|
||||
},
|
||||
'invalid player coordinates'
|
||||
);
|
||||
assertSetterNoop(
|
||||
firstRoute,
|
||||
{
|
||||
...checkpointFor(firstRoute),
|
||||
player: {
|
||||
x: 420,
|
||||
y: 512,
|
||||
direction: 'upward'
|
||||
}
|
||||
},
|
||||
'invalid player direction'
|
||||
);
|
||||
assertSetterNoop(
|
||||
firstRoute,
|
||||
checkpointFor(firstRoute, {
|
||||
type: 'dialogue',
|
||||
dialogueKey: 'city-interaction',
|
||||
lineIndex: 0
|
||||
}),
|
||||
'context-incompatible overlay'
|
||||
);
|
||||
assertSetterNoop(
|
||||
firstRoute,
|
||||
{
|
||||
...checkpointFor(firstRoute),
|
||||
contextId: campFixtures[1].visitId
|
||||
},
|
||||
'stale context'
|
||||
);
|
||||
|
||||
const militiaRoute = routes.find(
|
||||
({ scene }) => scene === 'prologue-militia-camp'
|
||||
);
|
||||
assert(militiaRoute);
|
||||
assertSetterNoop(
|
||||
militiaRoute,
|
||||
checkpointFor(militiaRoute, {
|
||||
type: 'choice',
|
||||
mode: 'prologue-command',
|
||||
choiceId: 'unknown-order'
|
||||
}),
|
||||
'invalid sortie order choice id'
|
||||
);
|
||||
|
||||
const cityRoute = routes.find(
|
||||
({ scene }) => scene === 'city-stay'
|
||||
);
|
||||
assert(cityRoute);
|
||||
assertSetterNoop(
|
||||
cityRoute,
|
||||
checkpointFor(cityRoute, {
|
||||
type: 'choice',
|
||||
mode: 'city-resonance',
|
||||
choiceId: 'mobile'
|
||||
}),
|
||||
'unexpected choice id outside prologue command'
|
||||
);
|
||||
}
|
||||
|
||||
function assertSetterNoop(route, invalidCheckpoint, label) {
|
||||
storage.clear();
|
||||
campaign.setCampaignState(route.state());
|
||||
campaign.setCampaignExplorationCheckpoint(
|
||||
setterCheckpointFor(
|
||||
checkpointFor(route, validOverlayFor(route))
|
||||
)
|
||||
);
|
||||
|
||||
const beforeState = campaign.getCampaignState();
|
||||
const beforeBase = storage.get(campaign.campaignStorageKey);
|
||||
const beforeSlot = storage.get(
|
||||
slotKey(beforeState.activeSaveSlot)
|
||||
);
|
||||
assert(beforeBase, `${route.label}: base fixture is missing.`);
|
||||
assert(beforeSlot, `${route.label}: slot fixture is missing.`);
|
||||
|
||||
const returned = campaign.setCampaignExplorationCheckpoint(
|
||||
setterCheckpointFor(invalidCheckpoint)
|
||||
);
|
||||
const afterState = campaign.getCampaignState();
|
||||
|
||||
assert.deepEqual(
|
||||
returned,
|
||||
beforeState,
|
||||
`${route.label} ${label}: setter return must preserve the complete state.`
|
||||
);
|
||||
assert.deepEqual(
|
||||
afterState,
|
||||
beforeState,
|
||||
`${route.label} ${label}: in-memory state and updatedAt must remain unchanged.`
|
||||
);
|
||||
assert.equal(
|
||||
storage.get(campaign.campaignStorageKey),
|
||||
beforeBase,
|
||||
`${route.label} ${label}: base-save bytes must remain unchanged.`
|
||||
);
|
||||
assert.equal(
|
||||
storage.get(slotKey(beforeState.activeSaveSlot)),
|
||||
beforeSlot,
|
||||
`${route.label} ${label}: slot-save bytes must remain unchanged.`
|
||||
);
|
||||
}
|
||||
|
||||
function verifyStaleCampToCityRace() {
|
||||
const cityRoute = routes.find(
|
||||
({ scene }) => scene === 'city-stay'
|
||||
);
|
||||
assert(cityRoute);
|
||||
|
||||
storage.clear();
|
||||
campaign.setCampaignState(cityRoute.state());
|
||||
campaign.setCampaignExplorationCheckpoint(
|
||||
setterCheckpointFor(
|
||||
checkpointFor(cityRoute, validOverlayFor(cityRoute))
|
||||
)
|
||||
);
|
||||
const beforeState = campaign.getCampaignState();
|
||||
const beforeBase = storage.get(campaign.campaignStorageKey);
|
||||
const beforeSlot = storage.get(
|
||||
slotKey(beforeState.activeSaveSlot)
|
||||
);
|
||||
|
||||
const staleCampCheckpoint = {
|
||||
version: 1,
|
||||
scene: 'camp-visit-exploration',
|
||||
contextId: campFixtures[0].visitId,
|
||||
player: {
|
||||
x: 701,
|
||||
y: 601,
|
||||
direction: 'west'
|
||||
},
|
||||
overlay: {
|
||||
type: 'dialogue',
|
||||
dialogueKey: 'visit-interaction',
|
||||
sourceNpcId: 'stale-camp-npc',
|
||||
lineIndex: 1
|
||||
}
|
||||
};
|
||||
const returned =
|
||||
campaign.setCampaignExplorationCheckpoint(
|
||||
staleCampCheckpoint
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
returned,
|
||||
beforeState,
|
||||
'A stale camp pagehide write after city activation must return the unchanged city state.'
|
||||
);
|
||||
assert.deepEqual(
|
||||
campaign.getCampaignState(),
|
||||
beforeState,
|
||||
'A stale camp pagehide write must not erase or replace the city checkpoint.'
|
||||
);
|
||||
assert.equal(
|
||||
storage.get(campaign.campaignStorageKey),
|
||||
beforeBase,
|
||||
'A stale camp pagehide write must not change base-save bytes.'
|
||||
);
|
||||
assert.equal(
|
||||
storage.get(slotKey(beforeState.activeSaveSlot)),
|
||||
beforeSlot,
|
||||
'A stale camp pagehide write must not change slot-save bytes.'
|
||||
);
|
||||
}
|
||||
|
||||
function validOverlayFor(route) {
|
||||
if (route.choiceOverlays.length > 0) {
|
||||
return route.choiceOverlays.at(-1);
|
||||
}
|
||||
return {
|
||||
type: 'dialogue',
|
||||
dialogueKey: route.dialogueKeys[0],
|
||||
sourceNpcId: `${route.label}-npc`,
|
||||
lineIndex: 1
|
||||
};
|
||||
}
|
||||
|
||||
function checkpointFor(route, overlay) {
|
||||
return {
|
||||
version: 1,
|
||||
scene: route.scene,
|
||||
contextId: route.contextId,
|
||||
player: {
|
||||
x: 420.25,
|
||||
y: 512.5,
|
||||
direction: 'south'
|
||||
},
|
||||
...(arguments.length >= 2 ? { overlay } : {}),
|
||||
savedAt: '2026-07-28T10:30:00.000Z'
|
||||
};
|
||||
}
|
||||
|
||||
function setterCheckpointFor(checkpoint) {
|
||||
const { savedAt: _savedAt, ...setterCheckpoint } = checkpoint;
|
||||
return setterCheckpoint;
|
||||
}
|
||||
|
||||
function withCheckpoint(state, checkpoint) {
|
||||
return {
|
||||
...state,
|
||||
explorationCheckpoint: clone(checkpoint)
|
||||
};
|
||||
}
|
||||
|
||||
function loadRawState(state) {
|
||||
storage.clear();
|
||||
const serialized = JSON.stringify(state);
|
||||
storage.set(campaign.campaignStorageKey, serialized);
|
||||
storage.set(
|
||||
slotKey(state.activeSaveSlot ?? 1),
|
||||
serialized
|
||||
);
|
||||
return campaign.loadCampaignState();
|
||||
}
|
||||
|
||||
function validCampState(fixture, slot = 2) {
|
||||
const report = battleReport(fixture.sourceBattleId);
|
||||
const state = campaign.createInitialCampaignState();
|
||||
state.updatedAt = '2026-07-28T10:00:00.000Z';
|
||||
state.step = fixture.step;
|
||||
state.activeSaveSlot = slot;
|
||||
state.gold = 420;
|
||||
state.roster = clone(report.units);
|
||||
state.bonds = clone(report.bonds);
|
||||
state.latestBattleId = fixture.sourceBattleId;
|
||||
state.firstBattleReport = report;
|
||||
state.battleHistory = {
|
||||
[fixture.sourceBattleId]: battleSettlement(report)
|
||||
};
|
||||
state.activeCampVisitId = fixture.visitId;
|
||||
delete state.pendingAftermathBattleId;
|
||||
delete state.activeCityStayId;
|
||||
return state;
|
||||
}
|
||||
|
||||
function validCityState(slot = 2) {
|
||||
const battleId = 'seventh-battle-xuzhou-rescue';
|
||||
const report = battleReport(battleId);
|
||||
const state = campaign.createInitialCampaignState();
|
||||
state.updatedAt = '2026-07-28T10:05:00.000Z';
|
||||
state.step = 'seventh-camp';
|
||||
state.activeSaveSlot = slot;
|
||||
state.gold = 640;
|
||||
state.roster = clone(report.units);
|
||||
state.bonds = clone(report.bonds);
|
||||
state.latestBattleId = battleId;
|
||||
state.firstBattleReport = report;
|
||||
state.battleHistory = {
|
||||
[battleId]: battleSettlement(report)
|
||||
};
|
||||
state.activeCityStayId = 'xuzhou';
|
||||
delete state.pendingAftermathBattleId;
|
||||
delete state.activeCampVisitId;
|
||||
return state;
|
||||
}
|
||||
|
||||
function validVillageState(slot = 2) {
|
||||
const state = campaign.createInitialCampaignState();
|
||||
state.updatedAt = '2026-07-28T10:10:00.000Z';
|
||||
state.step = 'prologue-town';
|
||||
state.activeSaveSlot = slot;
|
||||
return state;
|
||||
}
|
||||
|
||||
function validMilitiaState(slot = 2) {
|
||||
const state = campaign.createInitialCampaignState();
|
||||
state.updatedAt = '2026-07-28T10:15:00.000Z';
|
||||
state.step = 'prologue-camp';
|
||||
state.activeSaveSlot = slot;
|
||||
return state;
|
||||
}
|
||||
|
||||
function battleReport(battleId) {
|
||||
const scenario = battleScenarios[battleId];
|
||||
assert(scenario, `Missing battle scenario fixture ${battleId}.`);
|
||||
const alliedUnits = scenario.units
|
||||
.filter((unit) => unit.faction === 'ally')
|
||||
.map((unit) => clone(unit));
|
||||
return {
|
||||
battleId,
|
||||
battleTitle: scenario.title,
|
||||
outcome: 'victory',
|
||||
turnNumber: 5,
|
||||
rewardGold: 100,
|
||||
defeatedEnemies: scenario.units.filter(
|
||||
(unit) => unit.faction === 'enemy'
|
||||
).length,
|
||||
totalEnemies: scenario.units.filter(
|
||||
(unit) => unit.faction === 'enemy'
|
||||
).length,
|
||||
objectives: [],
|
||||
units: alliedUnits,
|
||||
bonds: scenario.bonds.map((bond) => ({
|
||||
...clone(bond),
|
||||
battleExp: 0
|
||||
})),
|
||||
itemRewards: [],
|
||||
campaignRewards: {
|
||||
supplies: [],
|
||||
equipment: [],
|
||||
reputation: [],
|
||||
recruits: [],
|
||||
unlocks: []
|
||||
},
|
||||
completedCampDialogues: [],
|
||||
completedCampVisits: [],
|
||||
createdAt: '2026-07-28T10:00:00.000Z'
|
||||
};
|
||||
}
|
||||
|
||||
function battleSettlement(report) {
|
||||
return {
|
||||
battleId: report.battleId,
|
||||
battleTitle: report.battleTitle,
|
||||
outcome: report.outcome,
|
||||
turnNumber: report.turnNumber,
|
||||
rewardGold: report.rewardGold,
|
||||
itemRewards: [...report.itemRewards],
|
||||
campaignRewards: clone(report.campaignRewards),
|
||||
objectives: [],
|
||||
units: [],
|
||||
bonds: [],
|
||||
completedAt: report.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
function slotKey(slot) {
|
||||
return `${campaign.campaignStorageKey}:slot-${slot}`;
|
||||
}
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
@@ -887,6 +887,10 @@ try {
|
||||
preJianYongSave.battleHistory[firstScenario.id].bonds = preJianYongSave.battleHistory[firstScenario.id].bonds.filter((bond) => bond.id !== 'liu-bei__jian-yong');
|
||||
preJianYongSave.battleHistory[firstScenario.id].campaignRewards.recruits = preJianYongSave.battleHistory[firstScenario.id].campaignRewards.recruits.filter((recruit) => recruit.unitId !== 'jian-yong');
|
||||
storage.set(campaignStorageKey, JSON.stringify(preJianYongSave));
|
||||
storage.set(
|
||||
`${campaignStorageKey}:slot-${preJianYongSave.activeSaveSlot}`,
|
||||
JSON.stringify(preJianYongSave)
|
||||
);
|
||||
const repairedEarlyChoiceSave = loadCampaignState();
|
||||
const persistedEarlyChoiceSave = saveCampaignState(repairedEarlyChoiceSave);
|
||||
assert(
|
||||
@@ -1152,6 +1156,10 @@ try {
|
||||
'unknown-battle'
|
||||
);
|
||||
storage.set(campaignStorageKey, JSON.stringify(corruptedFutureAcknowledgementSave));
|
||||
storage.set(
|
||||
`${campaignStorageKey}:slot-${corruptedFutureAcknowledgementSave.activeSaveSlot}`,
|
||||
JSON.stringify(corruptedFutureAcknowledgementSave)
|
||||
);
|
||||
const normalizedFutureAcknowledgementSave = loadCampaignState();
|
||||
assert(
|
||||
normalizedFutureAcknowledgementSave.acknowledgedVictoryRewardBattleIds.includes(firstScenario.id) &&
|
||||
@@ -1167,19 +1175,19 @@ try {
|
||||
});
|
||||
const revisedSettlement = getCampaignState();
|
||||
assert(
|
||||
revisedSettlement.gold === 140 &&
|
||||
revisedSettlement.inventory.Bean === 3 &&
|
||||
revisedSettlement.inventory.Wine === 1 &&
|
||||
revisedSettlement.inventory['Iron Sword'] === undefined &&
|
||||
revisedSettlement.inventory['Iron Armor'] === 1,
|
||||
`Expected revised battle report settlement to replace previous rewards instead of stacking: ${JSON.stringify(revisedSettlement)}`
|
||||
revisedSettlement.gold === 100 &&
|
||||
revisedSettlement.inventory.Bean === 2 &&
|
||||
revisedSettlement.inventory.Wine === undefined &&
|
||||
revisedSettlement.inventory['Iron Sword'] === 1 &&
|
||||
revisedSettlement.inventory['Iron Armor'] === undefined,
|
||||
`Expected a revised settled report to update history without replacing or resurrecting the already-committed economy: ${JSON.stringify(revisedSettlement)}`
|
||||
);
|
||||
applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] }, 'repeat-visit-left');
|
||||
applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] }, 'repeat-visit-right');
|
||||
const repeatedVisit = getCampaignState();
|
||||
assert(
|
||||
repeatedVisit.gold === 150 &&
|
||||
repeatedVisit.inventory.Bean === 5 &&
|
||||
repeatedVisit.gold === 110 &&
|
||||
repeatedVisit.inventory.Bean === 4 &&
|
||||
repeatedVisit.completedCampVisits.includes('repeat-visit') &&
|
||||
repeatedVisit.firstBattleReport?.completedCampVisits.includes('repeat-visit') &&
|
||||
repeatedVisit.campVisitChoiceIds['repeat-visit'] === 'repeat-visit-left' &&
|
||||
@@ -1198,8 +1206,8 @@ try {
|
||||
applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] });
|
||||
const visitResynced = getCampaignState();
|
||||
assert(
|
||||
visitResynced.gold === 150 &&
|
||||
visitResynced.inventory.Bean === 5 &&
|
||||
visitResynced.gold === 110 &&
|
||||
visitResynced.inventory.Bean === 4 &&
|
||||
visitResynced.completedCampVisits.includes('repeat-visit') &&
|
||||
visitResynced.firstBattleReport?.completedCampVisits.includes('repeat-visit'),
|
||||
`Expected report-only camp visit completion to resync without duplicate rewards: ${JSON.stringify(visitResynced)}`
|
||||
@@ -1207,7 +1215,7 @@ try {
|
||||
applyCampVisitReward('dirty-reward-visit', { itemRewards: ['', ' ', 'Bean -2', 'Bean +0', 'Bean x2'] });
|
||||
const dirtyRewardVisit = getCampaignState();
|
||||
assert(
|
||||
dirtyRewardVisit.inventory.Bean === 7 &&
|
||||
dirtyRewardVisit.inventory.Bean === 6 &&
|
||||
dirtyRewardVisit.inventory[''] === undefined &&
|
||||
dirtyRewardVisit.inventory['Bean -'] === undefined &&
|
||||
dirtyRewardVisit.completedCampVisits.includes('dirty-reward-visit'),
|
||||
@@ -1495,6 +1503,10 @@ try {
|
||||
corruptedContextualCommandState.battleHistory[firstScenario.id].sortieOrder.command.role = 'support';
|
||||
corruptedContextualCommandState.sortieOrderHistory[firstScenario.id].elite.command.usedTurn = orderBattleReport.turnNumber + 1;
|
||||
storage.set(campaignStorageKey, JSON.stringify(corruptedContextualCommandState));
|
||||
storage.set(
|
||||
`${campaignStorageKey}:slot-1`,
|
||||
JSON.stringify(corruptedContextualCommandState)
|
||||
);
|
||||
const normalizedContextualCommandState = loadCampaignState();
|
||||
assert(
|
||||
normalizedContextualCommandState.firstBattleReport?.sortieOrder?.command === undefined &&
|
||||
@@ -1506,6 +1518,10 @@ try {
|
||||
`Expected contextual normalization to remove ghost actors, role mismatches, and future command turns without dropping their order results: ${JSON.stringify(normalizedContextualCommandState)}`
|
||||
);
|
||||
storage.set(campaignStorageKey, validContextualCommandStateRaw);
|
||||
storage.set(
|
||||
`${campaignStorageKey}:slot-1`,
|
||||
validContextualCommandStateRaw
|
||||
);
|
||||
loadCampaignState();
|
||||
|
||||
orderBattleReport.sortieOrder.progress[0].achieved = false;
|
||||
@@ -1556,14 +1572,14 @@ try {
|
||||
setFirstBattleReport(revisedOrderReport);
|
||||
const revisedOrderSettlement = getCampaignState();
|
||||
assert(
|
||||
revisedOrderSettlement.gold === 320 &&
|
||||
revisedOrderSettlement.inventory.Bean === 3 &&
|
||||
revisedOrderSettlement.inventory.Wine === 1 &&
|
||||
revisedOrderSettlement.inventory['Iron Sword'] === undefined &&
|
||||
revisedOrderSettlement.inventory['Iron Armor'] === 1 &&
|
||||
revisedOrderSettlement.gold === 280 &&
|
||||
revisedOrderSettlement.inventory.Bean === 2 &&
|
||||
revisedOrderSettlement.inventory.Wine === undefined &&
|
||||
revisedOrderSettlement.inventory['Iron Sword'] === 1 &&
|
||||
revisedOrderSettlement.inventory['Iron Armor'] === undefined &&
|
||||
revisedOrderSettlement.inventory['상처약'] === 1 &&
|
||||
revisedOrderSettlement.claimedSortieOrderRewardIds.length === 1,
|
||||
`Expected ordinary reward replacement to preserve the separately claimed sortie-order reward: ${JSON.stringify(revisedOrderSettlement)}`
|
||||
`Expected revised ordinary reward metadata to preserve the already-committed economy and separately claimed sortie-order reward: ${JSON.stringify(revisedOrderSettlement)}`
|
||||
);
|
||||
|
||||
setFirstBattleReport({
|
||||
@@ -1573,7 +1589,7 @@ try {
|
||||
});
|
||||
const secondOrderSameBattle = getCampaignState();
|
||||
assert(
|
||||
secondOrderSameBattle.gold === 480 &&
|
||||
secondOrderSameBattle.gold === 440 &&
|
||||
secondOrderSameBattle.inventory['상처약'] === 1 &&
|
||||
secondOrderSameBattle.inventory['탁주'] === 1 &&
|
||||
secondOrderSameBattle.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:elite`) &&
|
||||
@@ -1597,7 +1613,7 @@ try {
|
||||
});
|
||||
const failedEliteReplay = getCampaignState();
|
||||
assert(
|
||||
failedEliteReplay.gold === 480 &&
|
||||
failedEliteReplay.gold === 440 &&
|
||||
failedEliteReplay.inventory['상처약'] === 1 &&
|
||||
failedEliteReplay.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:elite`) &&
|
||||
failedEliteReplay.firstBattleReport?.sortieOrder?.achieved === false &&
|
||||
@@ -1624,7 +1640,7 @@ try {
|
||||
});
|
||||
const sameOrderDifferentBattle = getCampaignState();
|
||||
assert(
|
||||
sameOrderDifferentBattle.gold === 780 &&
|
||||
sameOrderDifferentBattle.gold === 740 &&
|
||||
sameOrderDifferentBattle.inventory['상처약'] === 2 &&
|
||||
sameOrderDifferentBattle.claimedSortieOrderRewardIds.includes(`${secondScenario.id}:elite`) &&
|
||||
sameOrderDifferentBattle.claimedSortieOrderRewardIds.length === 3 &&
|
||||
@@ -1654,7 +1670,7 @@ try {
|
||||
const spentRewardReplay = getCampaignState();
|
||||
assert(
|
||||
spentRewardReplay.gold === 160 &&
|
||||
spentRewardReplay.inventory['탁주'] === 2 &&
|
||||
spentRewardReplay.inventory['탁주'] === 1 &&
|
||||
spentRewardReplay.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:mobile`) &&
|
||||
spentRewardReplay.firstBattleReport?.sortieOrder?.rewardGranted === true,
|
||||
`Expected a first order reward to remain intact even when previously settled ordinary gold/items were already spent: ${JSON.stringify(spentRewardReplay)}`
|
||||
|
||||
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.`
|
||||
);
|
||||
}
|
||||
@@ -290,7 +290,8 @@ function verifyPurchasePersistenceAndRollback({
|
||||
|
||||
const first = actionModule.purchaseCityStayEquipment(
|
||||
'xuzhou',
|
||||
'city-xuzhou-iron-spear'
|
||||
'city-xuzhou-iron-spear',
|
||||
'verify:preparation:purchase:1'
|
||||
);
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(first.purchaseNumber, 1);
|
||||
@@ -315,7 +316,8 @@ function verifyPurchasePersistenceAndRollback({
|
||||
|
||||
const second = actionModule.purchaseCityStayEquipment(
|
||||
'xuzhou',
|
||||
'city-xuzhou-iron-spear'
|
||||
'city-xuzhou-iron-spear',
|
||||
'verify:preparation:purchase:2'
|
||||
);
|
||||
assert.equal(second.ok, true);
|
||||
assert.equal(second.purchaseNumber, 2);
|
||||
@@ -352,7 +354,8 @@ function verifyPurchasePersistenceAndRollback({
|
||||
rejectStorageWrites = true;
|
||||
const failed = actionModule.purchaseCityStayEquipment(
|
||||
'xuzhou',
|
||||
'city-xuzhou-iron-spear'
|
||||
'city-xuzhou-iron-spear',
|
||||
'verify:preparation:purchase:failed'
|
||||
);
|
||||
rejectStorageWrites = false;
|
||||
assert.deepEqual(failed, { ok: false, reason: 'save-unavailable' });
|
||||
|
||||
275
scripts/verify-city-purchase-idempotency.mjs
Normal file
275
scripts/verify-city-purchase-idempotency.mjs
Normal file
@@ -0,0 +1,275 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const storage = new Map();
|
||||
let rejectBaseMirrorWrites = false;
|
||||
let rejectSlotWrites = false;
|
||||
globalThis.window = {
|
||||
localStorage: {
|
||||
getItem(key) {
|
||||
return storage.has(key) ? storage.get(key) : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
if (
|
||||
rejectSlotWrites &&
|
||||
key === 'heros-web:campaign-state:slot-1'
|
||||
) {
|
||||
throw new Error('Simulated canonical slot failure.');
|
||||
}
|
||||
if (
|
||||
rejectBaseMirrorWrites &&
|
||||
key === 'heros-web:campaign-state'
|
||||
) {
|
||||
throw new Error('Simulated compatibility mirror failure.');
|
||||
}
|
||||
storage.set(key, String(value));
|
||||
},
|
||||
removeItem(key) {
|
||||
storage.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const server = await createServer({
|
||||
logLevel: 'error',
|
||||
server: { middlewareMode: true, hmr: false },
|
||||
appType: 'custom'
|
||||
});
|
||||
|
||||
try {
|
||||
const campaignModule = await server.ssrLoadModule(
|
||||
'/src/game/state/campaignState.ts'
|
||||
);
|
||||
const actionModule = await server.ssrLoadModule(
|
||||
'/src/game/state/cityStayActions.ts'
|
||||
);
|
||||
const { itemInventoryLabel } = await server.ssrLoadModule(
|
||||
'/src/game/data/battleItems.ts'
|
||||
);
|
||||
|
||||
storage.clear();
|
||||
campaignModule.resetCampaignState();
|
||||
const initial = cityCampaign(campaignModule, 5000);
|
||||
campaignModule.setCampaignState(initial);
|
||||
|
||||
const label = itemInventoryLabel('iron-spear');
|
||||
const intentId = 'verify:xuzhou:iron-spear:1';
|
||||
const first = actionModule.purchaseCityStayEquipment(
|
||||
'xuzhou',
|
||||
'city-xuzhou-iron-spear',
|
||||
intentId
|
||||
);
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(first.replayed, false);
|
||||
assert.equal(first.purchaseNumber, 1);
|
||||
assert.equal(first.campaign.gold, 4380);
|
||||
assert.equal(first.campaign.inventory[label], 1);
|
||||
assert.deepEqual(
|
||||
first.campaign.cityEquipmentPurchaseReceipts[intentId],
|
||||
{
|
||||
version: 1,
|
||||
intentId,
|
||||
cityStayId: 'xuzhou',
|
||||
offerId: 'city-xuzhou-iron-spear',
|
||||
purchaseNumber: 1,
|
||||
completedAt:
|
||||
first.campaign.cityEquipmentPurchaseReceipts[intentId]
|
||||
.completedAt
|
||||
}
|
||||
);
|
||||
|
||||
const replay = actionModule.purchaseCityStayEquipment(
|
||||
'xuzhou',
|
||||
'city-xuzhou-iron-spear',
|
||||
intentId
|
||||
);
|
||||
assert.equal(replay.ok, true);
|
||||
assert.equal(replay.replayed, true);
|
||||
assert.equal(replay.purchaseNumber, 1);
|
||||
assert.equal(replay.campaign.gold, 4380);
|
||||
assert.equal(replay.campaign.inventory[label], 1);
|
||||
assert.equal(
|
||||
replay.campaign.cityEquipmentPurchaseCounts[
|
||||
'city-xuzhou-iron-spear'
|
||||
],
|
||||
1
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
actionModule.purchaseCityStayEquipment(
|
||||
'xuzhou',
|
||||
'city-xuzhou-lamellar-armor',
|
||||
intentId
|
||||
),
|
||||
{ ok: false, reason: 'intent-conflict' }
|
||||
);
|
||||
|
||||
const reloaded = campaignModule.loadCampaignState(1);
|
||||
assert.equal(reloaded.gold, 4380);
|
||||
const replayAfterReload =
|
||||
actionModule.purchaseCityStayEquipment(
|
||||
'xuzhou',
|
||||
'city-xuzhou-iron-spear',
|
||||
intentId
|
||||
);
|
||||
assert.equal(replayAfterReload.ok, true);
|
||||
assert.equal(replayAfterReload.replayed, true);
|
||||
assert.equal(replayAfterReload.campaign.gold, 4380);
|
||||
assert.equal(replayAfterReload.campaign.inventory[label], 1);
|
||||
|
||||
const fullInventory = structuredClone(
|
||||
replayAfterReload.campaign
|
||||
);
|
||||
fullInventory.inventory[label] =
|
||||
campaignModule.campaignInventoryCap(label);
|
||||
campaignModule.setCampaignState(fullInventory);
|
||||
assert.deepEqual(
|
||||
actionModule.purchaseCityStayEquipment(
|
||||
'xuzhou',
|
||||
'city-xuzhou-iron-spear',
|
||||
'verify:xuzhou:iron-spear:full'
|
||||
),
|
||||
{ ok: false, reason: 'inventory-full' }
|
||||
);
|
||||
const afterFull = campaignModule.getCampaignState();
|
||||
assert.equal(afterFull.gold, 4380);
|
||||
assert.equal(
|
||||
afterFull.cityEquipmentPurchaseCounts[
|
||||
'city-xuzhou-iron-spear'
|
||||
],
|
||||
1
|
||||
);
|
||||
|
||||
const bounded = structuredClone(afterFull);
|
||||
bounded.cityEquipmentPurchaseCounts[
|
||||
'city-xuzhou-iron-spear'
|
||||
] = 99;
|
||||
bounded.cityEquipmentPurchaseReceipts =
|
||||
Object.fromEntries(
|
||||
Array.from({ length: 140 }, (_, index) => {
|
||||
const receiptIntentId =
|
||||
`verify:bounded:${String(index).padStart(3, '0')}`;
|
||||
return [
|
||||
receiptIntentId,
|
||||
{
|
||||
version: 1,
|
||||
intentId: receiptIntentId,
|
||||
cityStayId: 'xuzhou',
|
||||
offerId: 'city-xuzhou-iron-spear',
|
||||
purchaseNumber: 1,
|
||||
completedAt: new Date(
|
||||
Date.UTC(2026, 0, 1, 0, 0, index)
|
||||
).toISOString()
|
||||
}
|
||||
];
|
||||
})
|
||||
);
|
||||
const normalized = campaignModule.setCampaignState(bounded);
|
||||
assert.equal(
|
||||
Object.keys(
|
||||
normalized.cityEquipmentPurchaseReceipts
|
||||
).length,
|
||||
128
|
||||
);
|
||||
assert(
|
||||
normalized.cityEquipmentPurchaseReceipts[
|
||||
'verify:bounded:139'
|
||||
]
|
||||
);
|
||||
assert.equal(
|
||||
normalized.cityEquipmentPurchaseReceipts[
|
||||
'verify:bounded:000'
|
||||
],
|
||||
undefined
|
||||
);
|
||||
|
||||
const beforeMirrorFailure =
|
||||
campaignModule.getCampaignState();
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
rejectBaseMirrorWrites = true;
|
||||
const slotCanonical = campaignModule.setCampaignState({
|
||||
...beforeMirrorFailure,
|
||||
gold: 4321
|
||||
});
|
||||
rejectBaseMirrorWrites = false;
|
||||
assert.equal(slotCanonical.gold, 4321);
|
||||
assert.equal(
|
||||
JSON.parse(
|
||||
storage.get(
|
||||
`${campaignModule.campaignStorageKey}:slot-1`
|
||||
)
|
||||
).gold,
|
||||
4321
|
||||
);
|
||||
assert.notEqual(
|
||||
JSON.parse(
|
||||
storage.get(campaignModule.campaignStorageKey)
|
||||
).gold,
|
||||
4321
|
||||
);
|
||||
assert.equal(
|
||||
campaignModule.loadCampaignState().gold,
|
||||
4321,
|
||||
'Default loading must prefer the newest canonical slot over a stale base mirror.'
|
||||
);
|
||||
|
||||
const rollbackSeed = cityCampaign(
|
||||
campaignModule,
|
||||
5000
|
||||
);
|
||||
campaignModule.setCampaignState(rollbackSeed);
|
||||
const beforeSlotFailure =
|
||||
campaignModule.getCampaignState();
|
||||
const storedBeforeSlotFailure = new Map(storage);
|
||||
rejectSlotWrites = true;
|
||||
const rejectedPurchase =
|
||||
actionModule.purchaseCityStayEquipment(
|
||||
'xuzhou',
|
||||
'city-xuzhou-iron-spear',
|
||||
'verify:xuzhou:slot-failure'
|
||||
);
|
||||
rejectSlotWrites = false;
|
||||
assert.deepEqual(rejectedPurchase, {
|
||||
ok: false,
|
||||
reason: 'save-unavailable'
|
||||
});
|
||||
assert.deepEqual(
|
||||
campaignModule.getCampaignState(),
|
||||
beforeSlotFailure,
|
||||
'A failed canonical write must not mutate in-memory gold, inventory, counts, or receipts.'
|
||||
);
|
||||
assert.deepEqual(
|
||||
[...storage.entries()],
|
||||
[...storedBeforeSlotFailure.entries()],
|
||||
'A failed canonical write must leave both stored copies unchanged.'
|
||||
);
|
||||
|
||||
console.log(
|
||||
'City purchase idempotency verification passed (single-charge replay, intent conflict rejection, persisted receipts, inventory cap protection, bounded receipt normalization, canonical slot ordering, newest-save loading, and failed-write rollback).'
|
||||
);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
function cityCampaign(campaignModule, gold) {
|
||||
const state = campaignModule.createInitialCampaignState();
|
||||
state.step = 'seventh-camp';
|
||||
state.latestBattleId = 'seventh-battle-xuzhou-rescue';
|
||||
state.activeCityStayId = 'xuzhou';
|
||||
state.gold = gold;
|
||||
state.battleHistory[
|
||||
'seventh-battle-xuzhou-rescue'
|
||||
] = {
|
||||
battleId: 'seventh-battle-xuzhou-rescue',
|
||||
battleTitle: 'Xuzhou rescue',
|
||||
outcome: 'victory',
|
||||
turnNumber: 18,
|
||||
rewardGold: 0,
|
||||
itemRewards: [],
|
||||
objectives: [],
|
||||
units: [],
|
||||
bonds: [],
|
||||
completedAt: new Date().toISOString()
|
||||
};
|
||||
return state;
|
||||
}
|
||||
1252
scripts/verify-city-stay-checkpoint-browser.mjs
Normal file
1252
scripts/verify-city-stay-checkpoint-browser.mjs
Normal file
File diff suppressed because it is too large
Load Diff
1196
scripts/verify-prologue-exploration-checkpoint-browser.mjs
Normal file
1196
scripts/verify-prologue-exploration-checkpoint-browser.mjs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@ const checks = [
|
||||
'scripts/verify-flow-segment-data.mjs',
|
||||
'scripts/verify-campaign-flow-data.mjs',
|
||||
'scripts/verify-campaign-save-normalization.mjs',
|
||||
'scripts/verify-campaign-exploration-checkpoint-normalization.mjs',
|
||||
'scripts/verify-campaign-settlement-idempotency.mjs',
|
||||
'scripts/verify-camp-visit-resume-state.mjs',
|
||||
'scripts/verify-campaign-completion.mjs',
|
||||
'scripts/verify-campaign-presentation-profiles.mjs',
|
||||
@@ -25,6 +27,7 @@ const checks = [
|
||||
'scripts/verify-camp-reward-data.mjs',
|
||||
'scripts/verify-city-stay-data.mjs',
|
||||
'scripts/verify-city-equipment-preparation.mjs',
|
||||
'scripts/verify-city-purchase-idempotency.mjs',
|
||||
'scripts/verify-xuzhou-stay-battle-payoff.mjs',
|
||||
'scripts/verify-xuzhou-stay-narrative-memory.mjs',
|
||||
'scripts/verify-prologue-village-data.mjs',
|
||||
|
||||
@@ -128,6 +128,7 @@ try {
|
||||
delete legacySave.acknowledgedVictoryRewardCategories;
|
||||
delete legacySave.dismissedVictoryRewardNoticeBattleIds;
|
||||
values.set(campaignStorageKey, JSON.stringify(legacySave));
|
||||
values.set(`${campaignStorageKey}:slot-${legacySave.activeSaveSlot}`, JSON.stringify(legacySave));
|
||||
const migratedLegacySave = loadCampaignState();
|
||||
assert(
|
||||
migratedLegacySave.acknowledgedVictoryRewardBattleIds.includes(scenario.id) &&
|
||||
@@ -150,6 +151,7 @@ try {
|
||||
'unknown-battle'
|
||||
];
|
||||
values.set(campaignStorageKey, JSON.stringify(partialSave));
|
||||
values.set(`${campaignStorageKey}:slot-${partialSave.activeSaveSlot}`, JSON.stringify(partialSave));
|
||||
const normalizedPartialSave = loadCampaignState();
|
||||
assert(
|
||||
JSON.stringify(normalizedPartialSave.acknowledgedVictoryRewardCategories) === JSON.stringify({ [scenario.id]: ['equipment'] }) &&
|
||||
|
||||
Reference in New Issue
Block a user