feat: resume exploration exactly and harden campaign saves

This commit is contained in:
2026-07-28 17:39:20 +09:00
parent a08d251098
commit 8a6c8c2cd7
24 changed files with 8958 additions and 413 deletions

View 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();
}