Files
heros_web/scripts/verify-campaign-story-checkpoint-normalization.mjs

1118 lines
29 KiB
JavaScript

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createServer } from 'vite';
const storage = new Map();
let failCanonicalSlotWrites = false;
globalThis.window = {
localStorage: {
getItem(key) {
return storage.has(key) ? storage.get(key) : null;
},
setItem(key, value) {
if (
failCanonicalSlotWrites &&
String(key).includes(':slot-')
) {
throw new Error(
'Simulated canonical campaign save-slot failure.'
);
}
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 story = await server.ssrLoadModule(
'/src/game/state/campaignStoryCheckpoint.ts'
);
const campaignFlow = await server.ssrLoadModule(
'/src/game/data/campaignFlow.ts'
);
const scenario = await server.ssrLoadModule(
'/src/game/data/scenario.ts'
);
const pages = [
{
id: 'sequence-new',
legacyBeatIds: ['sequence-old'],
chapter: '회귀 검증',
background: 'story-chaos',
text: '첫 장면'
},
{
id: 'beat-inserted',
chapter: '회귀 검증',
background: 'story-chaos',
text: '나중에 삽입된 장면'
},
{
id: 'beat-final',
legacyBeatIds: ['beat-old'],
chapter: '회귀 검증',
background: 'story-chaos',
text: '이어 읽어야 할 장면'
}
];
verifyPhaseContract();
verifyStableBeatResolution();
verifyLegacyCheckpointMigration();
verifyUnknownBeatIsRejected();
verifyStoryRouteResolution();
verifyStoryExplorationMutualExclusion();
verifyBeginCampaignStoryAtomicCommit();
verifyTutorialStoryAtomicCommit();
verifyHandoffRejectsLateReadingWrites();
verifyExactHandoffConsumption();
verifyLegacyFirstVictoryStoryTransition();
verifyExactClearRejectsStaleRevision();
verifyInvalidDirectWriteIsCompleteNoop();
verifyCanonicalStorageFailureIsAtomic();
verifyDestinationOwnedHandoffContract();
console.log(
'Verified campaign story checkpoints: stable-beat and legacy migration, reading/handoff phases, story/exploration exclusion, atomic story entry commits, stale-battle retirement, exact destination-owned handoff consumption, legacy victory-step advancement, stale-revision races, invalid-write no-ops, and canonical storage-failure invariance.'
);
function verifyPhaseContract() {
assert.deepEqual(
story.campaignStoryCheckpointPhases,
['reading', 'handoff'],
'Story checkpoint phases must remain the explicit reading/handoff pair.'
);
}
function verifyStableBeatResolution() {
const checkpoint = normalizedCheckpoint({
revision: 3,
sequenceId: 'sequence-new',
beatId: 'beat-final',
pageIndex: 0
});
const resolution = story.resolveCampaignStoryCheckpoint(
checkpoint,
pages,
'prologue'
);
assert.deepEqual(
resolution,
{
pageIndex: 2,
sequenceId: 'sequence-new',
beatId: 'beat-final',
migrated: true
},
'A stable beat ID must win over a conflicting legacy page index.'
);
}
function verifyLegacyCheckpointMigration() {
const rawState = initialState('prologue');
rawState.updatedAt = '2026-07-28T10:00:00.000Z';
rawState.storyCheckpointRevision = 7;
rawState.storyCheckpoint = {
version: 0,
sequenceId: 'sequence-new',
legacyPageIndex: 2,
savedAt: '2026-07-28T10:01:00.000Z'
};
const loaded = loadRawState(rawState);
assert.deepEqual(
loaded.storyCheckpoint,
{
version: 1,
revision: 7,
sequenceId: 'sequence-new',
phase: 'reading',
pageIndex: 2,
campaignStep: 'prologue',
continuationIntent: 'default',
savedAt: '2026-07-28T10:01:00.000Z'
},
'A legacy pageIndex-only checkpoint must normalize without losing its position.'
);
assert.deepEqual(
story.resolveCampaignStoryCheckpoint(
loaded.storyCheckpoint,
pages,
loaded.step
),
{
pageIndex: 2,
sequenceId: 'sequence-new',
beatId: 'beat-final',
migrated: true
},
'A legacy pageIndex-only checkpoint must migrate to the canonical beat ID.'
);
const renamedCheckpoint = normalizedCheckpoint({
revision: 8,
sequenceId: 'sequence-old',
beatId: 'beat-old',
pageIndex: 0
});
assert.deepEqual(
story.resolveCampaignStoryCheckpoint(
renamedCheckpoint,
pages,
'prologue'
),
{
pageIndex: 2,
sequenceId: 'sequence-new',
beatId: 'beat-final',
migrated: true
},
'Legacy sequence and beat aliases must migrate to canonical IDs.'
);
}
function verifyUnknownBeatIsRejected() {
const checkpoint = normalizedCheckpoint({
revision: 9,
sequenceId: 'sequence-new',
beatId: 'beat-removed',
pageIndex: 2
});
assert.equal(
story.resolveCampaignStoryCheckpoint(
checkpoint,
pages,
'prologue'
),
undefined,
'An unknown stable beat must not fall back to an unrelated page index.'
);
}
function verifyStoryRouteResolution() {
const cases = [
{
label: 'ordinary battle intro',
pages: scenario.secondBattleIntroPages,
step: 'second-battle',
nextBattleId:
'second-battle-yellow-turban-pursuit'
},
{
label: 'Hanzhong king council',
pages: scenario.hanzhongKingCouncilPages,
step: 'hanzhong-king-camp'
},
{
label: 'Shu Han foundation',
pages: scenario.shuHanFoundationPages,
step: 'shu-han-foundation-camp'
},
{
label: 'Baidi entrustment',
pages: scenario.baidiEntrustmentPages,
step: 'baidi-entrustment-camp'
},
{
label: 'northern campaign preparation',
pages: scenario.northernCampaignPrepPages,
step: 'northern-campaign-prep-camp'
}
];
for (const routeCase of cases) {
const sequenceId =
story.storySequenceIdForPages(
routeCase.pages
);
const resolved =
campaignFlow.getSortieFlowForStorySequence(
sequenceId,
routeCase.step
);
assert(
resolved,
`${routeCase.label} must resolve from its stable story sequence.`
);
assert.equal(
resolved.campaignStep,
routeCase.step,
`${routeCase.label} resolved to the wrong campaign step.`
);
assert.equal(
resolved.nextBattleId,
routeCase.nextBattleId,
`${routeCase.label} resolved to the wrong destination battle.`
);
assert.equal(
story.storySequenceIdForPages(resolved.pages),
sequenceId,
`${routeCase.label} resolved to a different story sequence.`
);
}
}
function verifyStoryExplorationMutualExclusion() {
resetTo(initialState('prologue-town'));
const exploring =
campaign.setCampaignExplorationCheckpoint(
villageCheckpoint()
);
assert(exploring.explorationCheckpoint);
assert.equal(exploring.storyCheckpoint, undefined);
const reading = campaign.setCampaignStoryCheckpoint(
storyWrite('village-story', 'village-story', 0)
);
assert(reading.storyCheckpoint);
assert.equal(
reading.explorationCheckpoint,
undefined,
'Starting a story must retire the exploration checkpoint.'
);
assertPersistedState(
reading,
'story replacing exploration'
);
const storyRevision = reading.storyCheckpointRevision;
const exploringAgain =
campaign.setCampaignExplorationCheckpoint(
villageCheckpoint()
);
assert(exploringAgain.explorationCheckpoint);
assert.equal(
exploringAgain.storyCheckpoint,
undefined,
'Starting exploration must retire the story checkpoint.'
);
assert.equal(
exploringAgain.storyCheckpointRevision,
storyRevision + 1,
'Retiring a story checkpoint must invalidate its revision.'
);
assertPersistedState(
exploringAgain,
'exploration replacing story'
);
}
function verifyBeginCampaignStoryAtomicCommit() {
resetTo(initialState('prologue-town'));
const exploring =
campaign.setCampaignExplorationCheckpoint(
villageCheckpoint()
);
const previousRevision =
exploring.storyCheckpointRevision;
const previousBattleGeneration =
exploring.battleResumeGeneration;
const staleBattleSaveKey =
`heros-web:battle:first-battle-zhuo-commandery:slot-${exploring.activeSaveSlot}`;
storage.set(
staleBattleSaveKey,
'{"fixture":"superseded battle"}'
);
const begun = campaign.beginCampaignStory(
'first-battle',
storyWrite(
'first-battle-opening',
'first-battle-opening',
0
),
{ startBattle: true }
);
assert.equal(begun.step, 'first-battle');
assert.equal(begun.explorationCheckpoint, undefined);
assert.equal(begun.activeCityStayId, undefined);
assert.equal(begun.activeCampVisitId, undefined);
assert.equal(
begun.storyCheckpointRevision,
previousRevision + 1
);
assert.equal(
begun.battleResumeGeneration,
previousBattleGeneration + 1,
'A new battle story must advance the durable battle-resume generation.'
);
assert.deepEqual(
storySnapshot(begun),
{
sequenceId: 'first-battle-opening',
beatId: 'first-battle-opening',
pageIndex: 0,
campaignStep: 'first-battle',
phase: 'reading'
},
'beginCampaignStory must save the new step and first beat together.'
);
assert.equal(
begun.updatedAt,
begun.storyCheckpoint.savedAt
);
assertPersistedState(
begun,
'beginCampaignStory atomic commit'
);
assert.equal(
storage.has(staleBattleSaveKey),
false,
'A committed story departure must retire the superseded battle resume.'
);
}
function verifyTutorialStoryAtomicCommit() {
resetTo(initialState('prologue-town'));
const exploring =
campaign.setCampaignExplorationCheckpoint(
villageCheckpoint()
);
const previousRevision =
exploring.storyCheckpointRevision;
const tutorialId =
campaign.prologueVillageCampaignTutorialIds.complete;
const completed = campaign.completeCampaignTutorial(
tutorialId,
{
clearExplorationCheckpointScene:
'prologue-village',
storyCheckpoint: storyWrite(
'volunteers-rise',
'volunteers-rise',
0
)
}
);
assert(
completed.completedTutorialIds.includes(tutorialId),
'The tutorial completion must be part of the committed state.'
);
assert.equal(completed.explorationCheckpoint, undefined);
assert.equal(
completed.storyCheckpointRevision,
previousRevision + 1
);
assert.deepEqual(
storySnapshot(completed),
{
sequenceId: 'volunteers-rise',
beatId: 'volunteers-rise',
pageIndex: 0,
campaignStep: 'prologue-town',
phase: 'reading'
},
'completeCampaignTutorial must save completion and the first story beat together.'
);
assert.equal(
completed.updatedAt,
completed.storyCheckpoint.savedAt
);
assertPersistedState(
completed,
'tutorial and story atomic commit'
);
}
function verifyHandoffRejectsLateReadingWrites() {
resetTo(initialState('prologue'));
const reading = campaign.setCampaignStoryCheckpoint(
storyWrite('handoff-story', 'handoff-story', 0)
);
const revision = reading.storyCheckpoint.revision;
const handedOff = campaign.markCampaignStoryHandoff(
'handoff-story',
revision
);
assert.equal(
handedOff.storyCheckpoint.phase,
'handoff'
);
assertPersistedState(handedOff, 'story handoff');
assertCompleteNoop(
() =>
campaign.setCampaignStoryCheckpoint(
storyWrite(
'handoff-story',
'late-reading',
1,
revision
)
),
'A late revision-bound reading write after handoff'
);
assertCompleteNoop(
() =>
campaign.setCampaignStoryCheckpoint(
storyWrite(
'handoff-story',
'pagehide-reading',
2
)
),
'A late pagehide reading write without a revision after handoff'
);
}
function verifyExactHandoffConsumption() {
resetTo(initialState('prologue'));
const reading = campaign.setCampaignStoryCheckpoint(
storyWrite(
'destination-owned-story',
'destination-owned-story',
0
)
);
const reference = {
sequenceId:
reading.storyCheckpoint.sequenceId,
revision: reading.storyCheckpoint.revision
};
assertCompleteNoop(
() =>
campaign.consumeCampaignStoryHandoff(
reference
),
'A destination trying to consume a reading checkpoint'
);
const handedOff =
campaign.markCampaignStoryHandoff(
reference.sequenceId,
reference.revision
);
assert.equal(
handedOff.storyCheckpoint.phase,
'handoff'
);
assertCompleteNoop(
() =>
campaign.consumeCampaignStoryHandoff({
...reference,
revision: reference.revision + 1
}),
'A stale destination handoff reference'
);
assertCompleteNoop(
() =>
campaign.consumeCampaignStoryHandoff({
...reference,
sequenceId: 'different-story'
}),
'A mismatched destination handoff reference'
);
const consumed =
campaign.consumeCampaignStoryHandoff(
reference
);
assert.equal(consumed.storyCheckpoint, undefined);
assert.equal(
consumed.storyCheckpointRevision,
reference.revision + 1,
'An exact destination handoff consume must invalidate its revision.'
);
assertPersistedState(
consumed,
'exact destination handoff consumption'
);
assertCompleteNoop(
() =>
campaign.consumeCampaignStoryHandoff(
reference
),
'A repeated destination handoff consume'
);
}
function verifyLegacyFirstVictoryStoryTransition() {
const legacyState = initialState(
'first-victory-story'
);
legacyState.gold = 321;
resetTo(legacyState);
const reading =
campaign.setCampaignStoryCheckpoint(
storyWrite(
'legacy-first-victory',
'legacy-first-victory',
0
)
);
const reference = {
sequenceId:
reading.storyCheckpoint.sequenceId,
revision:
reading.storyCheckpoint.revision
};
const handedOff =
campaign.markCampaignStoryHandoff(
reference.sequenceId,
reference.revision
);
assert.equal(
handedOff.storyCheckpoint.phase,
'handoff'
);
assertCompleteNoop(
() =>
campaign.completeLegacyFirstVictoryStoryHandoff(
{
...reference,
revision:
reference.revision + 1
}
),
'A stale legacy first-victory handoff reference'
);
const completed =
campaign.completeLegacyFirstVictoryStoryHandoff(
reference
);
assert.equal(
completed.step,
'first-camp',
'Completing the supported legacy victory story must advance to the current first-camp flow.'
);
assert.equal(
completed.latestBattleId,
'first-battle-zhuo-commandery'
);
assert.equal(
completed.storyCheckpoint,
undefined
);
assert.equal(
completed.storyCheckpointRevision,
reference.revision + 1
);
assert.equal(
completed.gold,
321,
'Legacy step migration must not settle rewards again.'
);
assertPersistedState(
completed,
'legacy first-victory handoff transition'
);
}
function verifyExactClearRejectsStaleRevision() {
resetTo(initialState('prologue'));
const reading = campaign.setCampaignStoryCheckpoint(
storyWrite('clear-story', 'clear-story', 0)
);
const revision = reading.storyCheckpoint.revision;
const cleared = campaign.clearCampaignStoryCheckpoint(
'clear-story',
revision
);
assert.equal(cleared.storyCheckpoint, undefined);
assert.equal(
cleared.storyCheckpointRevision,
revision + 1,
'An exact clear must invalidate the cleared checkpoint revision.'
);
assertPersistedState(cleared, 'exact story clear');
assertCompleteNoop(
() =>
campaign.setCampaignStoryCheckpoint(
storyWrite(
'clear-story',
'stale-reading',
1,
revision
)
),
'A stale write after an exact story clear'
);
}
function verifyInvalidDirectWriteIsCompleteNoop() {
resetTo(initialState('prologue'));
assertCompleteNoop(
() =>
campaign.setCampaignStoryCheckpoint({
version: 1,
sequenceId: 'invalid story id!',
beatId: 'invalid-beat',
pageIndex: 0
}),
'An invalid direct story write'
);
}
function verifyCanonicalStorageFailureIsAtomic() {
resetTo(initialState('prologue-town'));
const staleBattleSaveKey =
'heros-web:battle:first-battle-zhuo-commandery:slot-2';
storage.set(
staleBattleSaveKey,
'{"fixture":"must survive failed commit"}'
);
assertCanonicalWriteFailure(
() =>
campaign.beginCampaignStory(
'first-battle',
storyWrite(
'failed-begin',
'failed-begin',
0
),
{ startBattle: true }
),
'beginCampaignStory'
);
assert.equal(
storage.get(staleBattleSaveKey),
'{"fixture":"must survive failed commit"}',
'A failed story commit must not retire the prior battle resume.'
);
assertCanonicalWriteFailure(
() =>
campaign.completeCampaignTutorial(
campaign.prologueVillageCampaignTutorialIds
.complete,
{
storyCheckpoint: storyWrite(
'failed-tutorial',
'failed-tutorial',
0
)
}
),
'completeCampaignTutorial'
);
const priorCampaign =
initialState('first-battle', 1);
priorCampaign.battleResumeGeneration = 9;
resetTo(priorCampaign);
const priorBattleSaveKey =
'heros-web:battle:first-battle-zhuo-commandery:slot-1';
storage.set(
priorBattleSaveKey,
'{"fixture":"must survive failed new campaign"}'
);
assertCanonicalWriteFailure(
() =>
campaign.startNewCampaign({
storyCheckpoint: storyWrite(
'failed-new-campaign',
'failed-new-campaign',
0
)
}),
'startNewCampaign'
);
assert.equal(
storage.get(priorBattleSaveKey),
'{"fixture":"must survive failed new campaign"}',
'A failed new-campaign commit must preserve the overwritten slot battle resume.'
);
resetTo(
initialState('first-victory-story')
);
const legacyReading =
campaign.setCampaignStoryCheckpoint(
storyWrite(
'failed-legacy-handoff',
'failed-legacy-handoff',
0
)
);
const legacyReference = {
sequenceId:
legacyReading.storyCheckpoint
.sequenceId,
revision:
legacyReading.storyCheckpoint
.revision
};
campaign.markCampaignStoryHandoff(
legacyReference.sequenceId,
legacyReference.revision
);
assertCanonicalWriteFailure(
() =>
campaign.completeLegacyFirstVictoryStoryHandoff(
legacyReference
),
'completeLegacyFirstVictoryStoryHandoff'
);
}
function verifyDestinationOwnedHandoffContract() {
const titleSource = sourceFile(
'src/game/scenes/TitleScene.ts'
);
const titleContinue = sourceSection(
titleSource,
'private async continueGameAsync(',
'private async continueStoryCheckpoint('
);
assertOrder(
titleContinue,
'continueStoryCheckpoint(campaign)',
'readCampaignBattleResume(campaign',
'Title Continue must prefer a durable story checkpoint over a superseded battle snapshot.'
);
assert(
titleSource.includes(
'battleResume = campaign.storyCheckpoint'
),
'Title save summaries must describe the story checkpoint instead of a superseded battle snapshot.'
);
assert(
/create\(data\?: TitleSceneData\)[\s\S]*?this\.consumeTitleStoryHandoff\([\s\S]*?private consumeTitleStoryHandoff\([\s\S]*?completeLegacyFirstVictoryStoryHandoff\(/.test(
titleSource
) &&
/candidate\.destinationScene === 'TitleScene'[\s\S]*?this\.consumeTitleStoryHandoff\(/.test(
titleSource
),
'Both normal and restored Story-to-Title handoffs must atomically advance the supported legacy first-victory step.'
);
const villageCreate = sourceSection(
sourceFile(
'src/game/scenes/PrologueVillageScene.ts'
),
'create(data?: PrologueVillageSceneData)',
'getDebugState()'
);
assertOrder(
villageCreate,
'this.prepareCampaignProgress()',
'consumeCampaignStoryHandoff(data?.storyHandoff)',
'The village must establish a recoverable campaign step before consuming its story handoff.'
);
const militiaCreate = sourceSection(
sourceFile(
'src/game/scenes/PrologueMilitiaCampScene.ts'
),
'create(data?: PrologueMilitiaCampSceneData)',
'getDebugState()'
);
assertOrder(
militiaCreate,
'this.prepareCampaignProgress()',
'consumeCampaignStoryHandoff(data?.storyHandoff)',
'The militia camp must establish a recoverable campaign step before consuming its story handoff.'
);
const campSource = sourceFile(
'src/game/scenes/CampScene.ts'
);
const campInit = sourceSection(
campSource,
'init(data?: CampSceneData)',
'create()'
);
assert(
campInit.includes(
'completeCampaignStoryHandoffAftermath('
) &&
campInit.includes(
'completeCampaignAftermath('
) &&
campInit.includes(
'consumeCampaignStoryHandoff('
),
'Camp must atomically consume exact aftermath handoffs while retaining legacy independent completion and handoff paths.'
);
const campInitOffset = campSource.indexOf(
'init(data?: CampSceneData)'
);
assert(
campSource.indexOf(
'consumeCampaignStoryHandoff(',
campInitOffset
) <
campSource.indexOf(
'this.campaign = getCampaignState()'
),
'Camp must consume the handoff before caching campaign state so a stale snapshot cannot resurrect it.'
);
const battleSource = sourceFile(
'src/game/scenes/BattleScene.ts'
);
const battleInit = sourceSection(
battleSource,
'init(data?: BattleSceneData)',
'create()'
);
assert(
battleInit.includes(
'this.storyHandoff = data?.storyHandoff'
) &&
!battleInit.includes(
'consumeCampaignStoryHandoff('
),
'Battle preload must retain, not consume, the durable handoff.'
);
const battleCreate = sourceSection(
battleSource,
'create()',
'getDebugState()'
);
assertOrder(
battleCreate,
"markCampaignStep('first-battle',",
'consumeCampaignStoryHandoff(this.storyHandoff)',
'Battle must establish its recoverable campaign step before consuming the handoff.'
);
}
function normalizedCheckpoint({
revision,
sequenceId,
beatId,
pageIndex
}) {
const checkpoint =
story.normalizeCampaignStoryCheckpoint(
{
version: 1,
revision,
sequenceId,
phase: 'reading',
beatId,
pageIndex,
campaignStep: 'prologue',
continuationIntent: 'default',
savedAt: '2026-07-28T10:30:00.000Z'
},
'prologue',
revision
);
assert(
checkpoint,
`Expected a valid checkpoint for ${sequenceId}/${beatId}.`
);
return checkpoint;
}
function initialState(step, slot = 2) {
const state = campaign.createInitialCampaignState();
state.updatedAt = '2026-07-28T10:00:00.000Z';
state.step = step;
state.activeSaveSlot = slot;
return state;
}
function villageCheckpoint() {
return {
version: 1,
scene: 'prologue-village',
contextId: 'prologue-village',
player: {
x: 420.25,
y: 512.5,
direction: 'south'
}
};
}
function storyWrite(
sequenceId,
beatId,
pageIndex,
revision
) {
return {
version: 1,
...(revision === undefined ? {} : { revision }),
sequenceId,
beatId,
pageIndex
};
}
function storySnapshot(state) {
const checkpoint = state.storyCheckpoint;
assert(checkpoint, 'Expected a story checkpoint.');
return {
sequenceId: checkpoint.sequenceId,
beatId: checkpoint.beatId,
pageIndex: checkpoint.pageIndex,
campaignStep: checkpoint.campaignStep,
phase: checkpoint.phase
};
}
function resetTo(state) {
failCanonicalSlotWrites = false;
storage.clear();
return campaign.setCampaignState(state);
}
function loadRawState(state) {
failCanonicalSlotWrites = false;
storage.clear();
const serialized = JSON.stringify(state);
storage.set(campaign.campaignStorageKey, serialized);
storage.set(
slotKey(state.activeSaveSlot ?? 1),
serialized
);
return campaign.loadCampaignState();
}
function assertPersistedState(expected, label) {
assert.deepEqual(
campaign.getCampaignState(),
expected,
`${label}: in-memory state must match the returned state.`
);
assert.deepEqual(
JSON.parse(storage.get(campaign.campaignStorageKey)),
expected,
`${label}: base-save state must match the returned state.`
);
assert.deepEqual(
JSON.parse(
storage.get(slotKey(expected.activeSaveSlot))
),
expected,
`${label}: canonical slot-save state must match the returned state.`
);
}
function assertCompleteNoop(action, label) {
const before = persistentSnapshot();
const returned = action();
assert.deepEqual(
returned,
before.state,
`${label} must return the unchanged state.`
);
assert.deepEqual(
campaign.getCampaignState(),
before.state,
`${label} must leave memory and updatedAt unchanged.`
);
assert.equal(
storage.get(campaign.campaignStorageKey),
before.base,
`${label} must leave base-save bytes unchanged.`
);
assert.equal(
storage.get(slotKey(before.state.activeSaveSlot)),
before.slot,
`${label} must leave slot-save bytes unchanged.`
);
}
function assertCanonicalWriteFailure(action, label) {
const before = persistentSnapshot();
failCanonicalSlotWrites = true;
try {
assert.throws(
action,
/Simulated canonical campaign save-slot failure/,
`${label} must surface a canonical storage failure.`
);
} finally {
failCanonicalSlotWrites = false;
}
assert.deepEqual(
campaign.getCampaignState(),
before.state,
`${label} storage failure must leave memory unchanged.`
);
assert.equal(
storage.get(campaign.campaignStorageKey),
before.base,
`${label} storage failure must leave base-save bytes unchanged.`
);
assert.equal(
storage.get(slotKey(before.state.activeSaveSlot)),
before.slot,
`${label} storage failure must leave slot-save bytes unchanged.`
);
}
function persistentSnapshot() {
const state = campaign.getCampaignState();
const base = storage.get(campaign.campaignStorageKey);
const slot = storage.get(slotKey(state.activeSaveSlot));
assert(base, 'Expected the base campaign save fixture.');
assert(slot, 'Expected the slot campaign save fixture.');
return { state, base, slot };
}
function slotKey(slot) {
return `${campaign.campaignStorageKey}:slot-${slot}`;
}
function sourceFile(path) {
return readFileSync(
new URL(`../${path}`, import.meta.url),
'utf8'
);
}
function sourceSection(source, startMarker, endMarker) {
const start = source.indexOf(startMarker);
assert(
start >= 0,
`Missing source marker "${startMarker}".`
);
const end = source.indexOf(
endMarker,
start + startMarker.length
);
assert(
end > start,
`Missing source marker "${endMarker}" after "${startMarker}".`
);
return source.slice(start, end);
}
function assertOrder(source, first, second, message) {
const firstIndex = source.indexOf(first);
const secondIndex = source.indexOf(second);
assert(
firstIndex >= 0 &&
secondIndex > firstIndex,
message
);
}
} finally {
await server.close();
}