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