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

File diff suppressed because it is too large Load Diff

View File

@@ -82,15 +82,15 @@ try {
verifyValidSetterPersistence(fixture);
verifyNormalizationFailures(fixture);
verifyCampaignStepClearsVisit(fixture);
verifyBattleRecordUpdateClearsVisit(fixture, 'victory');
verifyBattleRecordUpdateClearsVisit(fixture, 'defeat');
verifySettledBattleReplayPreservesVisit(fixture, 'victory');
verifySettledBattleReplayPreservesVisit(fixture, 'defeat');
}
verifyCityMutualExclusion();
verifyLegacySaveWithoutCampVisitField();
console.log(
'Verified camp-visit resume state for all three visits: setter validation, base/active-slot persistence, reload retention, resume summaries, invalid-state normalization, campaign-step and battle-record clearing, city mutual exclusion, and legacy-save compatibility.'
'Verified camp-visit resume state for all three visits: setter validation, base/active-slot persistence, reload retention, resume summaries, invalid-state normalization, campaign-step clearing, settled battle replay preservation, city mutual exclusion, and legacy-save compatibility.'
);
function verifyValidSetterPersistence(fixture) {
@@ -271,23 +271,31 @@ try {
);
}
function verifyBattleRecordUpdateClearsVisit(fixture, outcome) {
function verifySettledBattleReplayPreservesVisit(
fixture,
outcome
) {
storage.clear();
campaign.setCampaignState(validCampVisitState(fixture, 2));
campaign.setActiveCampVisitId(fixture.visitId);
campaign.setFirstBattleReport(
const replayedReport = campaign.setFirstBattleReport(
battleReport(fixture.sourceBattleId, outcome)
);
assert.equal(
replayedReport.outcome,
'victory',
`${fixture.visitId}: a settled ${outcome} replay must resolve to the existing victory.`
);
assert.equal(
campaign.getCampaignState().activeCampVisitId,
undefined,
`${fixture.visitId}: a ${outcome} battle-record update must clear the active visit.`
fixture.visitId,
`${fixture.visitId}: a settled ${outcome} replay must preserve the active visit.`
);
assertPersistedVisit(
undefined,
fixture.visitId,
2,
`${fixture.visitId}: ${outcome} battle-record persistence`
`${fixture.visitId}: settled ${outcome} replay persistence`
);
}

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

View File

@@ -887,6 +887,10 @@ try {
preJianYongSave.battleHistory[firstScenario.id].bonds = preJianYongSave.battleHistory[firstScenario.id].bonds.filter((bond) => bond.id !== 'liu-bei__jian-yong');
preJianYongSave.battleHistory[firstScenario.id].campaignRewards.recruits = preJianYongSave.battleHistory[firstScenario.id].campaignRewards.recruits.filter((recruit) => recruit.unitId !== 'jian-yong');
storage.set(campaignStorageKey, JSON.stringify(preJianYongSave));
storage.set(
`${campaignStorageKey}:slot-${preJianYongSave.activeSaveSlot}`,
JSON.stringify(preJianYongSave)
);
const repairedEarlyChoiceSave = loadCampaignState();
const persistedEarlyChoiceSave = saveCampaignState(repairedEarlyChoiceSave);
assert(
@@ -1152,6 +1156,10 @@ try {
'unknown-battle'
);
storage.set(campaignStorageKey, JSON.stringify(corruptedFutureAcknowledgementSave));
storage.set(
`${campaignStorageKey}:slot-${corruptedFutureAcknowledgementSave.activeSaveSlot}`,
JSON.stringify(corruptedFutureAcknowledgementSave)
);
const normalizedFutureAcknowledgementSave = loadCampaignState();
assert(
normalizedFutureAcknowledgementSave.acknowledgedVictoryRewardBattleIds.includes(firstScenario.id) &&
@@ -1167,19 +1175,19 @@ try {
});
const revisedSettlement = getCampaignState();
assert(
revisedSettlement.gold === 140 &&
revisedSettlement.inventory.Bean === 3 &&
revisedSettlement.inventory.Wine === 1 &&
revisedSettlement.inventory['Iron Sword'] === undefined &&
revisedSettlement.inventory['Iron Armor'] === 1,
`Expected revised battle report settlement to replace previous rewards instead of stacking: ${JSON.stringify(revisedSettlement)}`
revisedSettlement.gold === 100 &&
revisedSettlement.inventory.Bean === 2 &&
revisedSettlement.inventory.Wine === undefined &&
revisedSettlement.inventory['Iron Sword'] === 1 &&
revisedSettlement.inventory['Iron Armor'] === undefined,
`Expected a revised settled report to update history without replacing or resurrecting the already-committed economy: ${JSON.stringify(revisedSettlement)}`
);
applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] }, 'repeat-visit-left');
applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] }, 'repeat-visit-right');
const repeatedVisit = getCampaignState();
assert(
repeatedVisit.gold === 150 &&
repeatedVisit.inventory.Bean === 5 &&
repeatedVisit.gold === 110 &&
repeatedVisit.inventory.Bean === 4 &&
repeatedVisit.completedCampVisits.includes('repeat-visit') &&
repeatedVisit.firstBattleReport?.completedCampVisits.includes('repeat-visit') &&
repeatedVisit.campVisitChoiceIds['repeat-visit'] === 'repeat-visit-left' &&
@@ -1198,8 +1206,8 @@ try {
applyCampVisitReward('repeat-visit', { gold: 10, itemRewards: ['Bean x2'] });
const visitResynced = getCampaignState();
assert(
visitResynced.gold === 150 &&
visitResynced.inventory.Bean === 5 &&
visitResynced.gold === 110 &&
visitResynced.inventory.Bean === 4 &&
visitResynced.completedCampVisits.includes('repeat-visit') &&
visitResynced.firstBattleReport?.completedCampVisits.includes('repeat-visit'),
`Expected report-only camp visit completion to resync without duplicate rewards: ${JSON.stringify(visitResynced)}`
@@ -1207,7 +1215,7 @@ try {
applyCampVisitReward('dirty-reward-visit', { itemRewards: ['', ' ', 'Bean -2', 'Bean +0', 'Bean x2'] });
const dirtyRewardVisit = getCampaignState();
assert(
dirtyRewardVisit.inventory.Bean === 7 &&
dirtyRewardVisit.inventory.Bean === 6 &&
dirtyRewardVisit.inventory[''] === undefined &&
dirtyRewardVisit.inventory['Bean -'] === undefined &&
dirtyRewardVisit.completedCampVisits.includes('dirty-reward-visit'),
@@ -1495,6 +1503,10 @@ try {
corruptedContextualCommandState.battleHistory[firstScenario.id].sortieOrder.command.role = 'support';
corruptedContextualCommandState.sortieOrderHistory[firstScenario.id].elite.command.usedTurn = orderBattleReport.turnNumber + 1;
storage.set(campaignStorageKey, JSON.stringify(corruptedContextualCommandState));
storage.set(
`${campaignStorageKey}:slot-1`,
JSON.stringify(corruptedContextualCommandState)
);
const normalizedContextualCommandState = loadCampaignState();
assert(
normalizedContextualCommandState.firstBattleReport?.sortieOrder?.command === undefined &&
@@ -1506,6 +1518,10 @@ try {
`Expected contextual normalization to remove ghost actors, role mismatches, and future command turns without dropping their order results: ${JSON.stringify(normalizedContextualCommandState)}`
);
storage.set(campaignStorageKey, validContextualCommandStateRaw);
storage.set(
`${campaignStorageKey}:slot-1`,
validContextualCommandStateRaw
);
loadCampaignState();
orderBattleReport.sortieOrder.progress[0].achieved = false;
@@ -1556,14 +1572,14 @@ try {
setFirstBattleReport(revisedOrderReport);
const revisedOrderSettlement = getCampaignState();
assert(
revisedOrderSettlement.gold === 320 &&
revisedOrderSettlement.inventory.Bean === 3 &&
revisedOrderSettlement.inventory.Wine === 1 &&
revisedOrderSettlement.inventory['Iron Sword'] === undefined &&
revisedOrderSettlement.inventory['Iron Armor'] === 1 &&
revisedOrderSettlement.gold === 280 &&
revisedOrderSettlement.inventory.Bean === 2 &&
revisedOrderSettlement.inventory.Wine === undefined &&
revisedOrderSettlement.inventory['Iron Sword'] === 1 &&
revisedOrderSettlement.inventory['Iron Armor'] === undefined &&
revisedOrderSettlement.inventory['상처약'] === 1 &&
revisedOrderSettlement.claimedSortieOrderRewardIds.length === 1,
`Expected ordinary reward replacement to preserve the separately claimed sortie-order reward: ${JSON.stringify(revisedOrderSettlement)}`
`Expected revised ordinary reward metadata to preserve the already-committed economy and separately claimed sortie-order reward: ${JSON.stringify(revisedOrderSettlement)}`
);
setFirstBattleReport({
@@ -1573,7 +1589,7 @@ try {
});
const secondOrderSameBattle = getCampaignState();
assert(
secondOrderSameBattle.gold === 480 &&
secondOrderSameBattle.gold === 440 &&
secondOrderSameBattle.inventory['상처약'] === 1 &&
secondOrderSameBattle.inventory['탁주'] === 1 &&
secondOrderSameBattle.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:elite`) &&
@@ -1597,7 +1613,7 @@ try {
});
const failedEliteReplay = getCampaignState();
assert(
failedEliteReplay.gold === 480 &&
failedEliteReplay.gold === 440 &&
failedEliteReplay.inventory['상처약'] === 1 &&
failedEliteReplay.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:elite`) &&
failedEliteReplay.firstBattleReport?.sortieOrder?.achieved === false &&
@@ -1624,7 +1640,7 @@ try {
});
const sameOrderDifferentBattle = getCampaignState();
assert(
sameOrderDifferentBattle.gold === 780 &&
sameOrderDifferentBattle.gold === 740 &&
sameOrderDifferentBattle.inventory['상처약'] === 2 &&
sameOrderDifferentBattle.claimedSortieOrderRewardIds.includes(`${secondScenario.id}:elite`) &&
sameOrderDifferentBattle.claimedSortieOrderRewardIds.length === 3 &&
@@ -1654,7 +1670,7 @@ try {
const spentRewardReplay = getCampaignState();
assert(
spentRewardReplay.gold === 160 &&
spentRewardReplay.inventory['탁주'] === 2 &&
spentRewardReplay.inventory['탁주'] === 1 &&
spentRewardReplay.claimedSortieOrderRewardIds.includes(`${firstScenario.id}:mobile`) &&
spentRewardReplay.firstBattleReport?.sortieOrder?.rewardGranted === true,
`Expected a first order reward to remain intact even when previously settled ordinary gold/items were already spent: ${JSON.stringify(spentRewardReplay)}`

View File

@@ -0,0 +1,584 @@
import assert from 'node:assert/strict';
import { createServer } from 'vite';
const storage = new Map();
let rejectCanonicalSlotWrites = false;
globalThis.window = {
localStorage: {
getItem(key) {
return storage.has(key) ? storage.get(key) : null;
},
setItem(key, value) {
if (
rejectCanonicalSlotWrites &&
key === 'heros-web:campaign-state:slot-1'
) {
throw new Error(
'Simulated canonical campaign save failure.'
);
}
storage.set(key, String(value));
},
removeItem(key) {
storage.delete(key);
},
clear() {
storage.clear();
}
}
};
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true, hmr: false },
appType: 'custom'
});
try {
const campaign = await server.ssrLoadModule(
'/src/game/state/campaignState.ts'
);
const { battleScenarios } = await server.ssrLoadModule(
'/src/game/data/battles.ts'
);
const scenario =
battleScenarios['first-battle-zhuo-commandery'];
const report = {
battleId: scenario.id,
battleTitle: scenario.title,
outcome: 'victory',
turnNumber: 4,
rewardGold: 120,
defeatedEnemies: 2,
totalEnemies: 2,
objectives: [],
units: scenario.units.filter(
(unit) => unit.faction === 'ally'
),
bonds: scenario.bonds.map((bond) => ({
...bond,
battleExp: 0
})),
itemRewards: ['Bean x2', 'Iron Sword 1'],
campaignRewards: {
supplies: ['Bean x2'],
equipment: ['Iron Sword 1'],
reputation: [],
recruits: [],
unlocks: []
},
completedCampDialogues: [],
completedCampVisits: [],
createdAt: '2026-07-28T07:00:00.000Z'
};
campaign.resetCampaignState();
campaign.setFirstBattleReport(report);
const firstSettlement = campaign.getCampaignState();
const initialSettlementProgress =
battleHistoryProgressOf(
firstSettlement,
scenario.id
);
assert.deepEqual(
economyOf(firstSettlement),
{
gold: 120,
inventory: {
Bean: 2,
'Iron Sword': 1
}
},
'The first victory settlement must apply its ordinary economy exactly once.'
);
campaign.setFirstBattleReport(report);
const exactReplay = campaign.getCampaignState();
assert.deepEqual(
economyOf(exactReplay),
economyOf(firstSettlement),
'Replaying the exact same victory report must not duplicate gold or items.'
);
campaign.completeCampaignAftermath(scenario.id);
campaign.setActiveCampVisitId(
'first-pursuit-scout-tent'
);
campaign.setCampaignExplorationCheckpoint({
version: 1,
scene: 'camp-visit-exploration',
contextId: 'first-pursuit-scout-tent',
player: {
x: 824,
y: 716,
direction: 'east'
}
});
const consumed = campaign.getCampaignState();
const progressedUnit = consumed.roster[0];
const progressedBond = consumed.bonds[0];
assert(progressedUnit, 'Expected a settled allied unit.');
assert(progressedBond, 'Expected a settled campaign bond.');
consumed.gold = 0;
consumed.inventory = {};
progressedUnit.level = Math.min(
99,
progressedUnit.level + 1
);
progressedUnit.exp = 37;
progressedUnit.equipment.weapon.level = Math.min(
9,
progressedUnit.equipment.weapon.level + 1
);
progressedUnit.equipment.weapon.exp = 3;
progressedBond.level = Math.min(
100,
progressedBond.level + 1
);
progressedBond.exp = 41;
progressedBond.battleExp += 11;
campaign.setCampaignState(consumed);
const consumedEconomy = economyOf(
campaign.getCampaignState()
);
const progressedContext = progressAndContextOf(
campaign.getCampaignState(),
progressedUnit.id,
progressedBond.id
);
campaign.setFirstBattleReport(report);
const consumedExactReplay = campaign.getCampaignState();
assert.deepEqual(
economyOf(consumedExactReplay),
consumedEconomy,
'Replaying a settled victory must not resurrect its already-consumed gold or items.'
);
assert.deepEqual(
progressAndContextOf(
consumedExactReplay,
progressedUnit.id,
progressedBond.id
),
progressedContext,
'Replaying a settled victory must preserve post-settlement unit, bond, navigation, and exploration progress.'
);
const consumedReplayProgress =
reportAndSettlementProgressOf(
consumedExactReplay,
scenario.id
);
assert.deepEqual(
consumedReplayProgress.firstBattleReport.units.find(
(unit) => unit.id === progressedUnit.id
),
progressedContext.unit,
'A settled victory replay must refresh firstBattleReport unit progress from the live roster.'
);
assert.deepEqual(
consumedReplayProgress.firstBattleReport.bonds.find(
(bond) => bond.id === progressedBond.id
),
progressedContext.bond,
'A settled victory replay must refresh firstBattleReport bond progress from the live campaign bonds.'
);
assert.deepEqual(
consumedReplayProgress.battleHistory,
initialSettlementProgress,
'A settled victory replay must preserve the original battleHistory unit and bond snapshots.'
);
campaign.setFirstBattleReport({
...report,
rewardGold: 200,
itemRewards: ['Bean x5', 'Iron Armor 1'],
campaignRewards: {
supplies: ['Bean x5'],
equipment: ['Iron Armor 1'],
reputation: [],
recruits: [],
unlocks: []
},
createdAt: '2026-07-28T07:05:00.000Z'
});
const revisedReplay = campaign.getCampaignState();
assert.deepEqual(
economyOf(revisedReplay),
consumedEconomy,
'Revising an already-settled victory report must update history without replacing or resurrecting consumed economy.'
);
assert.deepEqual(
progressAndContextOf(
revisedReplay,
progressedUnit.id,
progressedBond.id
),
progressedContext,
'Revising a settled victory report must not rewind live unit, bond, navigation, or exploration state.'
);
assert.deepEqual(
reportAndSettlementProgressOf(
revisedReplay,
scenario.id
),
consumedReplayProgress,
'Revising a settled victory report must preserve both firstBattleReport and battleHistory unit/bond progress.'
);
const beforeStaleDefeat = campaign.getCampaignState();
const storedBeforeStaleDefeat = new Map(storage);
const staleDefeatResult = campaign.setFirstBattleReport({
...report,
outcome: 'defeat',
rewardGold: 0,
itemRewards: [],
createdAt: '2026-07-28T07:06:00.000Z'
});
assert.equal(
staleDefeatResult.outcome,
'victory',
'A late defeat callback must resolve to the already-settled victory report.'
);
assert.deepEqual(
campaign.getCampaignState(),
beforeStaleDefeat,
'A late defeat callback must not rewind a settled victory step, active exploration context, roster, bonds, or economy.'
);
assert.deepEqual(
[...storage.entries()],
[...storedBeforeStaleDefeat.entries()],
'A rejected late defeat callback must not write a replacement settlement.'
);
const reportlessSettlement =
campaign.getCampaignState();
delete reportlessSettlement.firstBattleReport;
campaign.setCampaignState(reportlessSettlement);
assertFailedCampaignMutationIsAtomic(
campaign,
'settled victory report recovery',
() => campaign.setFirstBattleReport(report)
);
const beforeReportlessDefeat =
campaign.getCampaignState();
const storedBeforeReportlessDefeat =
new Map(storage);
const reportlessDefeatResult =
campaign.setFirstBattleReport({
...report,
outcome: 'defeat',
rewardGold: 0,
itemRewards: [],
createdAt: '2026-07-28T07:07:00.000Z'
});
assert.equal(
reportlessDefeatResult.outcome,
'victory',
'A late defeat callback must still resolve to victory when the settled battle report is missing.'
);
assert.equal(
reportlessDefeatResult.rewardGold,
beforeReportlessDefeat.battleHistory[
scenario.id
].rewardGold,
'A reportless late defeat must recover its return value from the authoritative victory settlement.'
);
assert.deepEqual(
campaign.getCampaignState(),
beforeReportlessDefeat,
'A reportless late defeat must leave in-memory campaign state unchanged.'
);
assert.deepEqual(
[...storage.entries()],
[...storedBeforeReportlessDefeat.entries()],
'A reportless late defeat must leave canonical and mirror storage unchanged.'
);
const recoveredReport =
campaign.setFirstBattleReport(report);
const recoveredSettlement =
campaign.getCampaignState();
assert.equal(
recoveredReport.outcome,
'victory',
'A settled victory replay must restore a missing firstBattleReport.'
);
assert.equal(
recoveredSettlement.firstBattleReport?.outcome,
'victory',
'The recovered firstBattleReport must persist the settled victory outcome.'
);
assert.equal(
recoveredSettlement.firstBattleReport?.rewardGold,
revisedReplay.battleHistory[
scenario.id
].rewardGold,
'Recovered report rewards must come from the authoritative settlement rather than stale replay input.'
);
assert.deepEqual(
economyOf(recoveredSettlement),
consumedEconomy,
'Recovering a missing settled victory report must not resurrect consumed economy.'
);
assert.deepEqual(
reportAndSettlementProgressOf(
recoveredSettlement,
scenario.id
),
consumedReplayProgress,
'Recovering a missing settled victory report must preserve firstBattleReport and battleHistory unit/bond progress.'
);
const beforeMissingBond =
campaign.getCampaignState();
const storedBeforeMissingBond = new Map(storage);
const missingBondResult =
campaign.applyCampVisitReward(
'verify-missing-bond-visit',
{
bondId: 'verify-missing-bond',
bondExp: 10,
gold: 90,
itemRewards: ['Bean x3']
},
'verify-missing-bond-choice'
);
const afterMissingBond =
campaign.getCampaignState();
assert.equal(
missingBondResult,
undefined,
'A camp visit reward that requests an unavailable bond must fail.'
);
assert.deepEqual(
completionAndEconomyOf(afterMissingBond),
completionAndEconomyOf(beforeMissingBond),
'A missing requested bond must leave visit completion, choice history, gold, and inventory unchanged.'
);
assert.deepEqual(
[...storage.entries()],
[...storedBeforeMissingBond.entries()],
'A rejected missing-bond reward must not persist any partial state.'
);
const rollbackFixture = campaign.getCampaignState();
const rollbackBond = rollbackFixture.bonds.find(
(bond) =>
bond.unitIds.every((unitId) =>
rollbackFixture.roster.some(
(unit) => unit.id === unitId
)
)
);
assert(
rollbackBond,
'Expected a roster-backed bond for persistence rollback verification.'
);
rollbackBond.level = 30;
rollbackBond.exp = 0;
rollbackFixture.selectedSortieUnitIds = [
...rollbackBond.unitIds
];
campaign.setCampaignState(rollbackFixture);
assertFailedCampaignMutationIsAtomic(
campaign,
'sortie resonance selection',
() =>
campaign.setCampaignSortieResonanceSelection(
scenario.id,
rollbackBond.id
)
);
assertFailedCampaignMutationIsAtomic(
campaign,
'reserve training focus',
() =>
campaign.setCampaignReserveTrainingFocus(
'class-practice'
)
);
assertFailedCampaignMutationIsAtomic(
campaign,
'reserve training assignment',
() =>
campaign.setCampaignReserveTrainingAssignment(
rollbackFixture.roster[0].id,
'bond-practice'
)
);
assertFailedCampaignMutationIsAtomic(
campaign,
'victory reward notice dismissal',
() =>
campaign.dismissCampaignVictoryRewardNotice(
scenario.id
)
);
const rollbackRecruit = structuredClone(
rollbackFixture.roster[0]
);
rollbackRecruit.id = 'verify-rollback-recruit';
rollbackRecruit.name = 'Rollback Recruit';
assertFailedCampaignMutationIsAtomic(
campaign,
'roster expansion',
() =>
campaign.ensureCampaignRosterUnits([
rollbackRecruit
])
);
console.log(
'Campaign settlement idempotency verification passed: settled victory replays preserve report/history progress and active context, missing reports recover safely, stale defeats are ignored, missing-bond camp rewards are atomic, and canonical save failures leave memory and storage unchanged.'
);
} finally {
await server.close();
}
function economyOf(state) {
return {
gold: state.gold,
inventory: { ...state.inventory }
};
}
function completionAndEconomyOf(state) {
return {
gold: state.gold,
inventory: { ...state.inventory },
completedCampVisits: [
...state.completedCampVisits
],
reportCompletedCampVisits: [
...(state.firstBattleReport
?.completedCampVisits ?? [])
],
campVisitChoiceIds: {
...state.campVisitChoiceIds
}
};
}
function progressAndContextOf(state, unitId, bondId) {
const unit = state.roster.find(
(candidate) => candidate.id === unitId
);
const bond = state.bonds.find(
(candidate) => candidate.id === bondId
);
return {
step: state.step,
latestBattleId: state.latestBattleId ?? null,
pendingAftermathBattleId:
state.pendingAftermathBattleId ?? null,
activeCampVisitId: state.activeCampVisitId ?? null,
activeCityStayId: state.activeCityStayId ?? null,
explorationCheckpoint:
state.explorationCheckpoint ?? null,
unit: unit
? {
id: unit.id,
level: unit.level,
exp: unit.exp,
equipment: structuredClone(unit.equipment)
}
: null,
bond: bond
? {
id: bond.id,
level: bond.level,
exp: bond.exp,
battleExp: bond.battleExp
}
: null
};
}
function reportAndSettlementProgressOf(
state,
battleId
) {
return {
firstBattleReport: {
units: unitProgressOf(
state.firstBattleReport?.units ?? []
),
bonds: bondProgressOf(
state.firstBattleReport?.bonds ?? []
)
},
battleHistory: battleHistoryProgressOf(
state,
battleId
)
};
}
function battleHistoryProgressOf(state, battleId) {
const settlement = state.battleHistory[battleId];
return {
units: unitProgressOf(settlement?.units ?? []),
bonds: bondProgressOf(settlement?.bonds ?? [])
};
}
function unitProgressOf(units) {
return units
.map((unit) => ({
id: unit.id ?? unit.unitId,
level: unit.level,
exp: unit.exp,
equipment: structuredClone(unit.equipment)
}))
.sort((left, right) =>
left.id.localeCompare(right.id)
);
}
function bondProgressOf(bonds) {
return bonds
.map((bond) => ({
id: bond.id,
level: bond.level,
exp: bond.exp,
battleExp: bond.battleExp
}))
.sort((left, right) =>
left.id.localeCompare(right.id)
);
}
function assertFailedCampaignMutationIsAtomic(
campaign,
label,
mutate
) {
const before = campaign.getCampaignState();
const storedBefore = new Map(storage);
rejectCanonicalSlotWrites = true;
try {
assert.throws(
mutate,
/Simulated canonical campaign save failure/,
`${label} must surface the canonical save failure.`
);
} finally {
rejectCanonicalSlotWrites = false;
}
assert.deepEqual(
campaign.getCampaignState(),
before,
`${label} must not leave ghost in-memory state after a failed canonical save.`
);
assert.deepEqual(
[...storage.entries()],
[...storedBefore.entries()],
`${label} must not mutate canonical or mirror storage after a failed canonical save.`
);
}

View File

@@ -290,7 +290,8 @@ function verifyPurchasePersistenceAndRollback({
const first = actionModule.purchaseCityStayEquipment(
'xuzhou',
'city-xuzhou-iron-spear'
'city-xuzhou-iron-spear',
'verify:preparation:purchase:1'
);
assert.equal(first.ok, true);
assert.equal(first.purchaseNumber, 1);
@@ -315,7 +316,8 @@ function verifyPurchasePersistenceAndRollback({
const second = actionModule.purchaseCityStayEquipment(
'xuzhou',
'city-xuzhou-iron-spear'
'city-xuzhou-iron-spear',
'verify:preparation:purchase:2'
);
assert.equal(second.ok, true);
assert.equal(second.purchaseNumber, 2);
@@ -352,7 +354,8 @@ function verifyPurchasePersistenceAndRollback({
rejectStorageWrites = true;
const failed = actionModule.purchaseCityStayEquipment(
'xuzhou',
'city-xuzhou-iron-spear'
'city-xuzhou-iron-spear',
'verify:preparation:purchase:failed'
);
rejectStorageWrites = false;
assert.deepEqual(failed, { ok: false, reason: 'save-unavailable' });

View File

@@ -0,0 +1,275 @@
import assert from 'node:assert/strict';
import { createServer } from 'vite';
const storage = new Map();
let rejectBaseMirrorWrites = false;
let rejectSlotWrites = false;
globalThis.window = {
localStorage: {
getItem(key) {
return storage.has(key) ? storage.get(key) : null;
},
setItem(key, value) {
if (
rejectSlotWrites &&
key === 'heros-web:campaign-state:slot-1'
) {
throw new Error('Simulated canonical slot failure.');
}
if (
rejectBaseMirrorWrites &&
key === 'heros-web:campaign-state'
) {
throw new Error('Simulated compatibility mirror failure.');
}
storage.set(key, String(value));
},
removeItem(key) {
storage.delete(key);
}
}
};
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true, hmr: false },
appType: 'custom'
});
try {
const campaignModule = await server.ssrLoadModule(
'/src/game/state/campaignState.ts'
);
const actionModule = await server.ssrLoadModule(
'/src/game/state/cityStayActions.ts'
);
const { itemInventoryLabel } = await server.ssrLoadModule(
'/src/game/data/battleItems.ts'
);
storage.clear();
campaignModule.resetCampaignState();
const initial = cityCampaign(campaignModule, 5000);
campaignModule.setCampaignState(initial);
const label = itemInventoryLabel('iron-spear');
const intentId = 'verify:xuzhou:iron-spear:1';
const first = actionModule.purchaseCityStayEquipment(
'xuzhou',
'city-xuzhou-iron-spear',
intentId
);
assert.equal(first.ok, true);
assert.equal(first.replayed, false);
assert.equal(first.purchaseNumber, 1);
assert.equal(first.campaign.gold, 4380);
assert.equal(first.campaign.inventory[label], 1);
assert.deepEqual(
first.campaign.cityEquipmentPurchaseReceipts[intentId],
{
version: 1,
intentId,
cityStayId: 'xuzhou',
offerId: 'city-xuzhou-iron-spear',
purchaseNumber: 1,
completedAt:
first.campaign.cityEquipmentPurchaseReceipts[intentId]
.completedAt
}
);
const replay = actionModule.purchaseCityStayEquipment(
'xuzhou',
'city-xuzhou-iron-spear',
intentId
);
assert.equal(replay.ok, true);
assert.equal(replay.replayed, true);
assert.equal(replay.purchaseNumber, 1);
assert.equal(replay.campaign.gold, 4380);
assert.equal(replay.campaign.inventory[label], 1);
assert.equal(
replay.campaign.cityEquipmentPurchaseCounts[
'city-xuzhou-iron-spear'
],
1
);
assert.deepEqual(
actionModule.purchaseCityStayEquipment(
'xuzhou',
'city-xuzhou-lamellar-armor',
intentId
),
{ ok: false, reason: 'intent-conflict' }
);
const reloaded = campaignModule.loadCampaignState(1);
assert.equal(reloaded.gold, 4380);
const replayAfterReload =
actionModule.purchaseCityStayEquipment(
'xuzhou',
'city-xuzhou-iron-spear',
intentId
);
assert.equal(replayAfterReload.ok, true);
assert.equal(replayAfterReload.replayed, true);
assert.equal(replayAfterReload.campaign.gold, 4380);
assert.equal(replayAfterReload.campaign.inventory[label], 1);
const fullInventory = structuredClone(
replayAfterReload.campaign
);
fullInventory.inventory[label] =
campaignModule.campaignInventoryCap(label);
campaignModule.setCampaignState(fullInventory);
assert.deepEqual(
actionModule.purchaseCityStayEquipment(
'xuzhou',
'city-xuzhou-iron-spear',
'verify:xuzhou:iron-spear:full'
),
{ ok: false, reason: 'inventory-full' }
);
const afterFull = campaignModule.getCampaignState();
assert.equal(afterFull.gold, 4380);
assert.equal(
afterFull.cityEquipmentPurchaseCounts[
'city-xuzhou-iron-spear'
],
1
);
const bounded = structuredClone(afterFull);
bounded.cityEquipmentPurchaseCounts[
'city-xuzhou-iron-spear'
] = 99;
bounded.cityEquipmentPurchaseReceipts =
Object.fromEntries(
Array.from({ length: 140 }, (_, index) => {
const receiptIntentId =
`verify:bounded:${String(index).padStart(3, '0')}`;
return [
receiptIntentId,
{
version: 1,
intentId: receiptIntentId,
cityStayId: 'xuzhou',
offerId: 'city-xuzhou-iron-spear',
purchaseNumber: 1,
completedAt: new Date(
Date.UTC(2026, 0, 1, 0, 0, index)
).toISOString()
}
];
})
);
const normalized = campaignModule.setCampaignState(bounded);
assert.equal(
Object.keys(
normalized.cityEquipmentPurchaseReceipts
).length,
128
);
assert(
normalized.cityEquipmentPurchaseReceipts[
'verify:bounded:139'
]
);
assert.equal(
normalized.cityEquipmentPurchaseReceipts[
'verify:bounded:000'
],
undefined
);
const beforeMirrorFailure =
campaignModule.getCampaignState();
await new Promise((resolve) => setTimeout(resolve, 2));
rejectBaseMirrorWrites = true;
const slotCanonical = campaignModule.setCampaignState({
...beforeMirrorFailure,
gold: 4321
});
rejectBaseMirrorWrites = false;
assert.equal(slotCanonical.gold, 4321);
assert.equal(
JSON.parse(
storage.get(
`${campaignModule.campaignStorageKey}:slot-1`
)
).gold,
4321
);
assert.notEqual(
JSON.parse(
storage.get(campaignModule.campaignStorageKey)
).gold,
4321
);
assert.equal(
campaignModule.loadCampaignState().gold,
4321,
'Default loading must prefer the newest canonical slot over a stale base mirror.'
);
const rollbackSeed = cityCampaign(
campaignModule,
5000
);
campaignModule.setCampaignState(rollbackSeed);
const beforeSlotFailure =
campaignModule.getCampaignState();
const storedBeforeSlotFailure = new Map(storage);
rejectSlotWrites = true;
const rejectedPurchase =
actionModule.purchaseCityStayEquipment(
'xuzhou',
'city-xuzhou-iron-spear',
'verify:xuzhou:slot-failure'
);
rejectSlotWrites = false;
assert.deepEqual(rejectedPurchase, {
ok: false,
reason: 'save-unavailable'
});
assert.deepEqual(
campaignModule.getCampaignState(),
beforeSlotFailure,
'A failed canonical write must not mutate in-memory gold, inventory, counts, or receipts.'
);
assert.deepEqual(
[...storage.entries()],
[...storedBeforeSlotFailure.entries()],
'A failed canonical write must leave both stored copies unchanged.'
);
console.log(
'City purchase idempotency verification passed (single-charge replay, intent conflict rejection, persisted receipts, inventory cap protection, bounded receipt normalization, canonical slot ordering, newest-save loading, and failed-write rollback).'
);
} finally {
await server.close();
}
function cityCampaign(campaignModule, gold) {
const state = campaignModule.createInitialCampaignState();
state.step = 'seventh-camp';
state.latestBattleId = 'seventh-battle-xuzhou-rescue';
state.activeCityStayId = 'xuzhou';
state.gold = gold;
state.battleHistory[
'seventh-battle-xuzhou-rescue'
] = {
battleId: 'seventh-battle-xuzhou-rescue',
battleTitle: 'Xuzhou rescue',
outcome: 'victory',
turnNumber: 18,
rewardGold: 0,
itemRewards: [],
objectives: [],
units: [],
bonds: [],
completedAt: new Date().toISOString()
};
return state;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,8 @@ const checks = [
'scripts/verify-flow-segment-data.mjs',
'scripts/verify-campaign-flow-data.mjs',
'scripts/verify-campaign-save-normalization.mjs',
'scripts/verify-campaign-exploration-checkpoint-normalization.mjs',
'scripts/verify-campaign-settlement-idempotency.mjs',
'scripts/verify-camp-visit-resume-state.mjs',
'scripts/verify-campaign-completion.mjs',
'scripts/verify-campaign-presentation-profiles.mjs',
@@ -25,6 +27,7 @@ const checks = [
'scripts/verify-camp-reward-data.mjs',
'scripts/verify-city-stay-data.mjs',
'scripts/verify-city-equipment-preparation.mjs',
'scripts/verify-city-purchase-idempotency.mjs',
'scripts/verify-xuzhou-stay-battle-payoff.mjs',
'scripts/verify-xuzhou-stay-narrative-memory.mjs',
'scripts/verify-prologue-village-data.mjs',

View File

@@ -128,6 +128,7 @@ try {
delete legacySave.acknowledgedVictoryRewardCategories;
delete legacySave.dismissedVictoryRewardNoticeBattleIds;
values.set(campaignStorageKey, JSON.stringify(legacySave));
values.set(`${campaignStorageKey}:slot-${legacySave.activeSaveSlot}`, JSON.stringify(legacySave));
const migratedLegacySave = loadCampaignState();
assert(
migratedLegacySave.acknowledgedVictoryRewardBattleIds.includes(scenario.id) &&
@@ -150,6 +151,7 @@ try {
'unknown-battle'
];
values.set(campaignStorageKey, JSON.stringify(partialSave));
values.set(`${campaignStorageKey}:slot-${partialSave.activeSaveSlot}`, JSON.stringify(partialSave));
const normalizedPartialSave = loadCampaignState();
assert(
JSON.stringify(normalizedPartialSave.acknowledgedVictoryRewardCategories) === JSON.stringify({ [scenario.id]: ['equipment'] }) &&