659 lines
16 KiB
JavaScript
659 lines
16 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { createServer } from 'vite';
|
|
|
|
const storage = new Map();
|
|
let failCanonicalSlotWrites = false;
|
|
let storageReadCount = 0;
|
|
|
|
globalThis.window = {
|
|
localStorage: {
|
|
getItem(key) {
|
|
storageReadCount += 1;
|
|
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(String(key), String(value));
|
|
},
|
|
removeItem(key) {
|
|
storage.delete(String(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 battleSaves = await server.ssrLoadModule(
|
|
'/src/game/state/battleSaveStorage.ts'
|
|
);
|
|
|
|
verifyDefeatPreparationDurability();
|
|
verifyVictoryPreparationAndStoryAtomicity();
|
|
verifyCanonicalFailureAtomicity();
|
|
verifyStaleCheckpointNormalization();
|
|
verifySceneRoutingContracts();
|
|
|
|
console.log(
|
|
'Verified durable sortie-improvement checkpoints for defeat retry and victory next-sortie flows: atomic story intent, title-over-battle-save priority, camp restoration, launch-time consumption, canonical-write failure invariance, stale-state normalization, and redundant default-story write avoidance.'
|
|
);
|
|
|
|
function verifyDefeatPreparationDurability() {
|
|
resetToBattle();
|
|
campaign.setFirstBattleReport(
|
|
reportFixture('defeat')
|
|
);
|
|
const before =
|
|
campaign.getCampaignState();
|
|
const prepared =
|
|
campaign.beginCampaignSortiePreparation({
|
|
mode: 'retry',
|
|
sourceBattleId:
|
|
'first-battle-zhuo-commandery',
|
|
targetBattleId:
|
|
'first-battle-zhuo-commandery'
|
|
});
|
|
|
|
assert.deepEqual(
|
|
preparationProjection(prepared),
|
|
{
|
|
intent: 'sortie-improvement',
|
|
mode: 'retry',
|
|
sourceBattleId:
|
|
'first-battle-zhuo-commandery',
|
|
targetBattleId:
|
|
'first-battle-zhuo-commandery',
|
|
targetCampaignStep: 'first-battle'
|
|
}
|
|
);
|
|
assert.equal(
|
|
prepared.storyCheckpoint,
|
|
undefined,
|
|
'Defeat preparation must not invent an aftermath story.'
|
|
);
|
|
assertPersistedState(
|
|
prepared,
|
|
'defeat retry preparation'
|
|
);
|
|
|
|
const readCountBeforeResume =
|
|
storageReadCount;
|
|
assert.equal(
|
|
battleSaves.readCampaignBattleResume(
|
|
prepared,
|
|
prepared.activeSaveSlot,
|
|
{
|
|
getItem() {
|
|
throw new Error(
|
|
'A battle save must not be inspected while explicit preparation is pending.'
|
|
);
|
|
},
|
|
setItem() {},
|
|
removeItem() {}
|
|
}
|
|
),
|
|
undefined,
|
|
'Explicit sortie preparation must outrank a prior battle resume.'
|
|
);
|
|
assert.equal(
|
|
storageReadCount,
|
|
readCountBeforeResume,
|
|
'The isolated resume probe unexpectedly touched browser storage.'
|
|
);
|
|
|
|
const reloaded =
|
|
campaign.loadCampaignState(
|
|
prepared.activeSaveSlot
|
|
);
|
|
assert.deepEqual(
|
|
preparationProjection(reloaded),
|
|
preparationProjection(prepared),
|
|
'Reload must preserve the exact retry preparation destination.'
|
|
);
|
|
|
|
const launched = campaign.markCampaignStep(
|
|
'first-battle',
|
|
{ startBattle: true }
|
|
);
|
|
assert.equal(
|
|
launched.sortiePreparationCheckpoint,
|
|
undefined,
|
|
'A committed retry launch must consume the preparation checkpoint.'
|
|
);
|
|
assert.equal(
|
|
launched.battleResumeGeneration,
|
|
before.battleResumeGeneration + 1,
|
|
'Retry launch must advance the battle-resume generation atomically.'
|
|
);
|
|
assertPersistedState(
|
|
launched,
|
|
'retry launch consumption'
|
|
);
|
|
}
|
|
|
|
function verifyVictoryPreparationAndStoryAtomicity() {
|
|
resetToBattle();
|
|
campaign.setFirstBattleReport(
|
|
reportFixture('victory'),
|
|
{
|
|
storyCheckpoint:
|
|
storyWrite('default')
|
|
}
|
|
);
|
|
const settled =
|
|
campaign.getCampaignState();
|
|
const storyRevision =
|
|
settled.storyCheckpoint.revision;
|
|
const prepared =
|
|
campaign.beginCampaignSortiePreparation(
|
|
{
|
|
mode: 'next-sortie',
|
|
sourceBattleId:
|
|
'first-battle-zhuo-commandery',
|
|
targetBattleId:
|
|
'second-battle-yellow-turban-pursuit'
|
|
},
|
|
{
|
|
storyCheckpoint:
|
|
storyWrite(
|
|
'sortie-improvement'
|
|
)
|
|
}
|
|
);
|
|
|
|
assert.deepEqual(
|
|
preparationProjection(prepared),
|
|
{
|
|
intent: 'sortie-improvement',
|
|
mode: 'next-sortie',
|
|
sourceBattleId:
|
|
'first-battle-zhuo-commandery',
|
|
targetBattleId:
|
|
'second-battle-yellow-turban-pursuit',
|
|
targetCampaignStep: 'second-battle'
|
|
}
|
|
);
|
|
assert.equal(
|
|
prepared.storyCheckpoint.revision,
|
|
storyRevision,
|
|
'Changing only the aftermath continuation intent must retain its exact story revision.'
|
|
);
|
|
assert.equal(
|
|
prepared.storyCheckpoint
|
|
.continuationIntent,
|
|
'sortie-improvement'
|
|
);
|
|
assertPersistedState(
|
|
prepared,
|
|
'victory improvement and aftermath story'
|
|
);
|
|
|
|
const handoff =
|
|
campaign.markCampaignStoryHandoff(
|
|
prepared.storyCheckpoint.sequenceId,
|
|
prepared.storyCheckpoint.revision
|
|
);
|
|
const arrived =
|
|
campaign.completeCampaignStoryHandoffAftermath(
|
|
'first-battle-zhuo-commandery',
|
|
{
|
|
sequenceId:
|
|
handoff.storyCheckpoint.sequenceId,
|
|
revision:
|
|
handoff.storyCheckpoint.revision
|
|
}
|
|
);
|
|
assert.equal(
|
|
arrived.storyCheckpoint,
|
|
undefined
|
|
);
|
|
assert(
|
|
arrived.sortiePreparationCheckpoint,
|
|
'Camp arrival must retain the explicit improvement overlay checkpoint.'
|
|
);
|
|
assertPersistedState(
|
|
arrived,
|
|
'victory camp arrival'
|
|
);
|
|
|
|
const launched =
|
|
campaign.beginCampaignStory(
|
|
'second-battle',
|
|
{
|
|
version: 1,
|
|
sequenceId:
|
|
'second-battle-intro-fixture',
|
|
beatId:
|
|
'second-battle-intro-fixture',
|
|
pageIndex: 0,
|
|
continuationIntent: 'default'
|
|
},
|
|
{ startBattle: true }
|
|
);
|
|
assert.equal(
|
|
launched.sortiePreparationCheckpoint,
|
|
undefined,
|
|
'Beginning the next sortie story must consume the preparation checkpoint in the same campaign write.'
|
|
);
|
|
assert.equal(
|
|
launched.step,
|
|
'second-battle'
|
|
);
|
|
assertPersistedState(
|
|
launched,
|
|
'next-sortie story launch consumption'
|
|
);
|
|
}
|
|
|
|
function verifyCanonicalFailureAtomicity() {
|
|
resetToBattle();
|
|
campaign.setFirstBattleReport(
|
|
reportFixture('defeat')
|
|
);
|
|
assertCanonicalWriteFailure(
|
|
() =>
|
|
campaign.beginCampaignSortiePreparation({
|
|
mode: 'retry',
|
|
sourceBattleId:
|
|
'first-battle-zhuo-commandery',
|
|
targetBattleId:
|
|
'first-battle-zhuo-commandery'
|
|
}),
|
|
'defeat sortie preparation'
|
|
);
|
|
|
|
resetToBattle();
|
|
campaign.setFirstBattleReport(
|
|
reportFixture('victory'),
|
|
{
|
|
storyCheckpoint:
|
|
storyWrite('default')
|
|
}
|
|
);
|
|
assertCanonicalWriteFailure(
|
|
() =>
|
|
campaign.beginCampaignSortiePreparation(
|
|
{
|
|
mode: 'next-sortie',
|
|
sourceBattleId:
|
|
'first-battle-zhuo-commandery',
|
|
targetBattleId:
|
|
'second-battle-yellow-turban-pursuit'
|
|
},
|
|
{
|
|
storyCheckpoint:
|
|
storyWrite(
|
|
'sortie-improvement'
|
|
)
|
|
}
|
|
),
|
|
'victory sortie preparation and story intent'
|
|
);
|
|
}
|
|
|
|
function verifyStaleCheckpointNormalization() {
|
|
resetToBattle();
|
|
campaign.setFirstBattleReport(
|
|
reportFixture('defeat')
|
|
);
|
|
const valid =
|
|
campaign.beginCampaignSortiePreparation({
|
|
mode: 'retry',
|
|
sourceBattleId:
|
|
'first-battle-zhuo-commandery',
|
|
targetBattleId:
|
|
'first-battle-zhuo-commandery'
|
|
});
|
|
|
|
for (const [label, mutate] of [
|
|
[
|
|
'advanced campaign step',
|
|
(state) => {
|
|
state.step = 'second-battle';
|
|
}
|
|
],
|
|
[
|
|
'mismatched target campaign step',
|
|
(state) => {
|
|
state.sortiePreparationCheckpoint
|
|
.targetCampaignStep =
|
|
'second-battle';
|
|
}
|
|
],
|
|
[
|
|
'forged non-next battle and matching step',
|
|
(state) => {
|
|
state.sortiePreparationCheckpoint
|
|
.targetBattleId =
|
|
'third-battle-guangzong-road';
|
|
state.sortiePreparationCheckpoint
|
|
.targetCampaignStep =
|
|
'third-battle';
|
|
}
|
|
],
|
|
[
|
|
'wrong preparation mode',
|
|
(state) => {
|
|
state.sortiePreparationCheckpoint.mode =
|
|
'next-sortie';
|
|
}
|
|
],
|
|
[
|
|
'invalid timestamp',
|
|
(state) => {
|
|
state.sortiePreparationCheckpoint.savedAt =
|
|
'not-a-time';
|
|
}
|
|
]
|
|
]) {
|
|
const stale = structuredClone(valid);
|
|
mutate(stale);
|
|
const loaded = loadRawState(stale);
|
|
assert.equal(
|
|
loaded.sortiePreparationCheckpoint,
|
|
undefined,
|
|
`${label} must retire a stale sortie-preparation checkpoint.`
|
|
);
|
|
}
|
|
}
|
|
|
|
function verifySceneRoutingContracts() {
|
|
const titleSource = sourceFile(
|
|
'src/game/scenes/TitleScene.ts'
|
|
);
|
|
const continueSource = sourceSection(
|
|
titleSource,
|
|
'private async continueGameAsync(',
|
|
'private async continueStoryCheckpoint('
|
|
);
|
|
assertOrder(
|
|
continueSource,
|
|
'await this.continueStoryCheckpoint(campaign)',
|
|
'campaign.sortiePreparationCheckpoint',
|
|
'Title must finish or resume aftermath story before restoring preparation.'
|
|
);
|
|
assertOrder(
|
|
continueSource,
|
|
'campaign.sortiePreparationCheckpoint',
|
|
'readCampaignBattleResume(',
|
|
'Explicit preparation must outrank an older battle save.'
|
|
);
|
|
|
|
const campSource = sourceFile(
|
|
'src/game/scenes/CampScene.ts'
|
|
);
|
|
const campInit = sourceSection(
|
|
campSource,
|
|
'init(data?: CampSceneData)',
|
|
'\n create()'
|
|
);
|
|
assert(
|
|
campInit.includes(
|
|
'.sortiePreparationCheckpoint'
|
|
) &&
|
|
campInit.includes(
|
|
'durablePreparation.targetBattleId'
|
|
) &&
|
|
campInit.includes(
|
|
"durablePreparation.mode === 'retry'"
|
|
),
|
|
'Camp init must rebuild the exact improvement overlay and retry target from campaign state.'
|
|
);
|
|
|
|
const battleSource = sourceFile(
|
|
'src/game/scenes/BattleScene.ts'
|
|
);
|
|
const improvementSource = sourceSection(
|
|
battleSource,
|
|
'private openResultSortieImprovement(',
|
|
'private publishFirstBattleReport('
|
|
);
|
|
assertOrder(
|
|
improvementSource,
|
|
'this.commitResultSortiePreparation(',
|
|
'startLazyScene(this,',
|
|
'Result navigation must not begin until its durable destination is committed.'
|
|
);
|
|
const storyCommitSource = sourceSection(
|
|
battleSource,
|
|
'private commitResultStoryCheckpoint(',
|
|
'private handleResultContinue()'
|
|
);
|
|
assert(
|
|
storyCommitSource.includes(
|
|
"current.continuationIntent ===\n continuationIntent"
|
|
) &&
|
|
storyCommitSource.includes(
|
|
'return true;'
|
|
),
|
|
'An already exact default aftermath checkpoint must avoid a redundant storage write.'
|
|
);
|
|
}
|
|
|
|
function resetToBattle() {
|
|
failCanonicalSlotWrites = false;
|
|
storage.clear();
|
|
const state =
|
|
campaign.createInitialCampaignState();
|
|
state.step = 'first-battle';
|
|
state.activeSaveSlot = 1;
|
|
return campaign.setCampaignState(state);
|
|
}
|
|
|
|
function reportFixture(outcome) {
|
|
return {
|
|
battleId:
|
|
'first-battle-zhuo-commandery',
|
|
battleTitle: '탁현 방어전',
|
|
outcome,
|
|
turnNumber: 3,
|
|
rewardGold:
|
|
outcome === 'victory' ? 100 : 0,
|
|
defeatedEnemies:
|
|
outcome === 'victory' ? 1 : 0,
|
|
totalEnemies: 1,
|
|
objectives: [],
|
|
units: [],
|
|
bonds: [],
|
|
itemRewards: [],
|
|
completedCampDialogues: [],
|
|
completedCampVisits: [],
|
|
createdAt:
|
|
'2026-07-29T01:00:00.000Z'
|
|
};
|
|
}
|
|
|
|
function storyWrite(continuationIntent) {
|
|
return {
|
|
version: 1,
|
|
sequenceId:
|
|
'first-aftermath-fixture',
|
|
beatId:
|
|
'first-aftermath-fixture',
|
|
pageIndex: 0,
|
|
continuationIntent
|
|
};
|
|
}
|
|
|
|
function preparationProjection(state) {
|
|
const checkpoint =
|
|
state.sortiePreparationCheckpoint;
|
|
assert(
|
|
checkpoint,
|
|
'Expected a sortie-preparation checkpoint.'
|
|
);
|
|
return {
|
|
intent: checkpoint.intent,
|
|
mode: checkpoint.mode,
|
|
sourceBattleId:
|
|
checkpoint.sourceBattleId,
|
|
targetBattleId:
|
|
checkpoint.targetBattleId,
|
|
targetCampaignStep:
|
|
checkpoint.targetCampaignStep
|
|
};
|
|
}
|
|
|
|
function loadRawState(state) {
|
|
failCanonicalSlotWrites = false;
|
|
storage.clear();
|
|
const serialized = JSON.stringify(state);
|
|
storage.set(
|
|
campaign.campaignStorageKey,
|
|
serialized
|
|
);
|
|
storage.set(
|
|
slotKey(state.activeSaveSlot),
|
|
serialized
|
|
);
|
|
return campaign.loadCampaignState(
|
|
state.activeSaveSlot
|
|
);
|
|
}
|
|
|
|
function assertPersistedState(expected, label) {
|
|
assert.deepEqual(
|
|
campaign.getCampaignState(),
|
|
expected,
|
|
`${label}: memory diverged from the returned campaign.`
|
|
);
|
|
assert.deepEqual(
|
|
JSON.parse(
|
|
storage.get(
|
|
campaign.campaignStorageKey
|
|
)
|
|
),
|
|
expected,
|
|
`${label}: compatibility mirror diverged.`
|
|
);
|
|
assert.deepEqual(
|
|
JSON.parse(
|
|
storage.get(
|
|
slotKey(expected.activeSaveSlot)
|
|
)
|
|
),
|
|
expected,
|
|
`${label}: canonical slot diverged.`
|
|
);
|
|
}
|
|
|
|
function assertCanonicalWriteFailure(
|
|
action,
|
|
label
|
|
) {
|
|
const before =
|
|
persistentSnapshot();
|
|
failCanonicalSlotWrites = true;
|
|
try {
|
|
assert.throws(
|
|
action,
|
|
/Simulated canonical campaign save-slot failure/,
|
|
`${label} must surface its canonical write failure.`
|
|
);
|
|
} finally {
|
|
failCanonicalSlotWrites = false;
|
|
}
|
|
assert.deepEqual(
|
|
campaign.getCampaignState(),
|
|
before.state,
|
|
`${label} changed in-memory campaign state after failure.`
|
|
);
|
|
assert.equal(
|
|
storage.get(
|
|
campaign.campaignStorageKey
|
|
),
|
|
before.base,
|
|
`${label} changed the compatibility mirror after failure.`
|
|
);
|
|
assert.equal(
|
|
storage.get(
|
|
slotKey(before.state.activeSaveSlot)
|
|
),
|
|
before.slot,
|
|
`${label} changed the canonical slot after failure.`
|
|
);
|
|
}
|
|
|
|
function persistentSnapshot() {
|
|
const state =
|
|
campaign.getCampaignState();
|
|
return {
|
|
state,
|
|
base: storage.get(
|
|
campaign.campaignStorageKey
|
|
),
|
|
slot: storage.get(
|
|
slotKey(state.activeSaveSlot)
|
|
)
|
|
};
|
|
}
|
|
|
|
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}".`
|
|
);
|
|
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();
|
|
}
|