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,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;
}