1118 lines
29 KiB
JavaScript
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();
|
|
}
|