feat: resume exploration exactly and harden campaign saves
This commit is contained in:
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();
|
||||
}
|
||||
Reference in New Issue
Block a user