feat: resume exploration exactly and harden campaign saves
This commit is contained in:
275
scripts/verify-city-purchase-idempotency.mjs
Normal file
275
scripts/verify-city-purchase-idempotency.mjs
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user