276 lines
7.3 KiB
JavaScript
276 lines
7.3 KiB
JavaScript
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;
|
|
}
|