import assert from 'node:assert/strict'; import { createServer } from 'vite'; const storage = new Map(); let rejectStorageWrites = false; globalThis.window = { localStorage: { getItem(key) { return storage.has(key) ? storage.get(key) : null; }, setItem(key, value) { if (rejectStorageWrites) { throw new Error('Simulated campaign storage write 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 preparationModule = await server.ssrLoadModule( '/src/game/data/cityEquipmentPreparation.ts' ); 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' ); const { xuzhouRecruitUnits } = await server.ssrLoadModule( '/src/game/data/scenario.ts' ); verifyCanonicalOfferAndCountNormalization(preparationModule); verifyEquipIntentNormalization({ preparationModule, campaignModule }); const purchased = verifyPurchasePersistenceAndRollback({ actionModule, campaignModule, itemInventoryLabel, xuzhouRecruitUnits }); const prepared = verifyPreparationResolution({ preparationModule, campaignModule, purchased }); verifyCampaignNormalizationAndInvalidation({ preparationModule, campaignModule, prepared }); verifyStoredContributionRosterCrossValidation({ campaignModule, prepared }); verifyContributionNormalization(preparationModule, prepared.snapshot); verifyOldSaveCompatibility(campaignModule); console.log( 'City equipment preparation verification passed (canonical Xuzhou purchase provenance, pending equip intent normalization, duplicate purchase counts, storage rollback, exact equip resolution, previous-item baseline deltas, target-battle isolation, swap invalidation, historical report/settlement contribution cross-validation, later equipment-change retention, and old-save compatibility).' ); } finally { await server.close(); } function verifyCanonicalOfferAndCountNormalization(preparationModule) { const canonical = preparationModule.findCanonicalCityEquipmentOffer( 'xuzhou', 'city-xuzhou-iron-spear' ); assert(canonical, 'Expected the Xuzhou iron spear offer to be canonical.'); assert.equal(canonical.cityStay.afterBattleId, 'seventh-battle-xuzhou-rescue'); assert.equal(canonical.cityStay.nextBattleId, 'eighth-battle-xiaopei-supply-road'); assert.equal(canonical.offer.itemId, 'iron-spear'); assert.equal(canonical.offer.slot, 'weapon'); assert.equal(canonical.offer.price, 620); assert.equal(canonical.item.attackBonus, 3); assert.equal( preparationModule.cityEquipmentPreparationSelectionRecordId, 'city-equipment-preparation' ); assert.equal( preparationModule.findCanonicalCityEquipmentOffer( 'xinye', canonical.offer.id ), undefined, 'An offer must not be portable to another city.' ); assert.equal( preparationModule.findCanonicalCityEquipmentOffer('xuzhou', 'unknown'), undefined ); assert.deepEqual( preparationModule.normalizeCityEquipmentPurchaseCounts({ [canonical.offer.id]: 500, 'city-xuzhou-lamellar-armor': 2.9, unknown: 8, 'city-xuzhou-grain-pouch': -4 }), { [canonical.offer.id]: 99, 'city-xuzhou-lamellar-armor': 2 } ); } function verifyEquipIntentNormalization({ preparationModule, campaignModule }) { const offerId = 'city-xuzhou-iron-spear'; const purchaseCounts = { [offerId]: 2 }; const intent = preparationModule.createCityEquipmentEquipIntent({ cityStayId: 'xuzhou', offerId, purchaseNumber: 2, purchaseCounts }); assert.deepEqual(intent, { version: 1, intentRecordId: 'city-equipment-equip-intent', cityStayId: 'xuzhou', sourceBattleId: 'seventh-battle-xuzhou-rescue', targetBattleId: 'eighth-battle-xiaopei-supply-road', offerId, itemId: 'iron-spear', slot: 'weapon', price: 620, purchaseNumber: 2 }); assert.equal( preparationModule.normalizeCityEquipmentEquipIntent( { ...intent, price: 1 }, purchaseCounts ), undefined, 'A forged equip intent must be rejected.' ); assert.equal( preparationModule.normalizeCityEquipmentEquipIntent( intent, { [offerId]: 1 } ), undefined, 'An equip intent must be backed by its exact purchase number.' ); const intentWithUnit = preparationModule.normalizeCityEquipmentEquipIntent( { ...intent, unitId: 'mi-zhu' }, purchaseCounts, [ { id: 'mi-zhu', name: '미축', faction: 'ally', equipment: {} } ] ); assert.equal(intentWithUnit.unitId, 'mi-zhu'); assert.equal( preparationModule.normalizeCityEquipmentEquipIntent( { ...intent, unitId: 'ghost-unit' }, purchaseCounts, [ { id: 'mi-zhu', name: '미축', faction: 'ally', equipment: {} } ] ), undefined, 'A pending target must reference a current allied unit.' ); const campaign = campaignModule.createInitialCampaignState(); campaign.step = 'seventh-camp'; campaign.latestBattleId = 'seventh-battle-xuzhou-rescue'; campaign.battleHistory[ 'seventh-battle-xuzhou-rescue' ] = { battleId: 'seventh-battle-xuzhou-rescue', battleTitle: '서주 구원전', outcome: 'victory', turnNumber: 18, rewardGold: 0, itemRewards: [], objectives: [], units: [], bonds: [], completedAt: new Date().toISOString() }; campaign.cityEquipmentPurchaseCounts = purchaseCounts; campaign.cityEquipmentEquipIntent = structuredClone(intent); const normalized = campaignModule.setCampaignState(campaign); assert.deepEqual( normalized.cityEquipmentEquipIntent, intent, 'A canonical pending equip intent must survive campaign normalization.' ); const forgedCampaign = campaignModule.setCampaignState({ ...structuredClone(campaign), cityEquipmentEquipIntent: { ...intent, targetBattleId: 'ninth-battle-xuzhou-gate-night-raid' } }); assert.equal( forgedCampaign.cityEquipmentEquipIntent, undefined, 'Campaign normalization must discard a forged equip intent.' ); const advancedCampaign = campaignModule.setCampaignState({ ...structuredClone(campaign), step: 'eighth-battle' }); assert.equal( advancedCampaign.cityEquipmentEquipIntent, undefined, 'A pending equip intent must expire when its source camp ends.' ); } function verifyPurchasePersistenceAndRollback({ actionModule, campaignModule, itemInventoryLabel, xuzhouRecruitUnits }) { storage.clear(); rejectStorageWrites = false; campaignModule.resetCampaignState(); const miZhu = structuredClone( xuzhouRecruitUnits.find((unit) => unit.id === 'mi-zhu') ); assert(miZhu, 'Expected the canonical Mi Zhu recruit.'); const initial = campaignModule.createInitialCampaignState(); initial.step = 'seventh-camp'; initial.latestBattleId = 'seventh-battle-xuzhou-rescue'; initial.activeCityStayId = 'xuzhou'; initial.gold = 5000; initial.roster = [miZhu]; initial.battleHistory['seventh-battle-xuzhou-rescue'] = { battleId: 'seventh-battle-xuzhou-rescue', battleTitle: '서주 구원전', outcome: 'victory', turnNumber: 18, rewardGold: 0, itemRewards: [], objectives: [], units: [], bonds: [], completedAt: new Date().toISOString() }; campaignModule.setCampaignState(initial); assert.deepEqual( actionModule.selectCityStayEquipmentForCamp( 'xuzhou', 'city-xuzhou-iron-spear' ), { ok: false, reason: 'not-purchased' }, 'The camp handoff must not be created before the offer is purchased.' ); const first = actionModule.purchaseCityStayEquipment( 'xuzhou', 'city-xuzhou-iron-spear' ); assert.equal(first.ok, true); assert.equal(first.purchaseNumber, 1); assert.equal(first.campaign.gold, 4380); const label = itemInventoryLabel('iron-spear'); assert.equal(first.campaign.inventory[label], 1); assert.equal( first.campaign.cityEquipmentPurchaseCounts[ 'city-xuzhou-iron-spear' ], 1 ); assert.equal(first.campaign.cityEquipmentPreparation, undefined); assertStoredPurchase(storage.get(campaignModule.campaignStorageKey), 4380, 1, 1, label); assertStoredPurchase( storage.get(`${campaignModule.campaignStorageKey}:slot-1`), 4380, 1, 1, label ); const second = actionModule.purchaseCityStayEquipment( 'xuzhou', 'city-xuzhou-iron-spear' ); assert.equal(second.ok, true); assert.equal(second.purchaseNumber, 2); assert.equal(second.campaign.gold, 3760); assert.equal(second.campaign.inventory[label], 2); assert.equal( second.campaign.cityEquipmentPurchaseCounts[ 'city-xuzhou-iron-spear' ], 2 ); const selected = actionModule.selectCityStayEquipmentForCamp( 'xuzhou', 'city-xuzhou-iron-spear' ); assert.equal(selected.ok, true); assert.equal(selected.purchaseNumber, 2); assert.deepEqual(selected.campaign.cityEquipmentEquipIntent, { version: 1, intentRecordId: 'city-equipment-equip-intent', cityStayId: 'xuzhou', sourceBattleId: 'seventh-battle-xuzhou-rescue', targetBattleId: 'eighth-battle-xiaopei-supply-road', offerId: 'city-xuzhou-iron-spear', itemId: 'iron-spear', slot: 'weapon', price: 620, purchaseNumber: 2 }); const beforeFailure = campaignModule.getCampaignState(); const storedBeforeFailure = new Map(storage); rejectStorageWrites = true; const failed = actionModule.purchaseCityStayEquipment( 'xuzhou', 'city-xuzhou-iron-spear' ); rejectStorageWrites = false; assert.deepEqual(failed, { ok: false, reason: 'save-unavailable' }); const { updatedAt: actualUpdatedAt, ...actualAfterFailure } = campaignModule.getCampaignState(); const { updatedAt: expectedUpdatedAt, ...expectedAfterFailure } = beforeFailure; assert( typeof actualUpdatedAt === 'string' && typeof expectedUpdatedAt === 'string', 'Rollback snapshots must retain valid campaign timestamps.' ); assert.deepEqual( actualAfterFailure, expectedAfterFailure, 'A failed storage write must restore the in-memory campaign snapshot.' ); assert.deepEqual( new Map(storage), storedBeforeFailure, 'A failed purchase must not partially change persisted storage.' ); const reloaded = campaignModule.loadCampaignState(1); assert.equal(reloaded.gold, 3760); assert.equal(reloaded.inventory[label], 2); assert.equal( reloaded.cityEquipmentPurchaseCounts['city-xuzhou-iron-spear'], 2 ); assert.deepEqual( reloaded.cityEquipmentEquipIntent, selected.campaign.cityEquipmentEquipIntent, 'The market-to-camp handoff must survive a save-slot reload.' ); return { campaign: reloaded, label }; } function verifyPreparationResolution({ preparationModule, campaignModule, purchased }) { const equipped = structuredClone(purchased.campaign); const miZhu = equipped.roster.find((unit) => unit.id === 'mi-zhu'); assert(miZhu); miZhu.equipment.weapon = { itemId: 'iron-spear', level: 2, exp: 17 }; const normalizedEquipped = campaignModule.setCampaignState(equipped); const snapshot = preparationModule.createCityEquipmentPreparationSnapshot({ cityStayId: 'xuzhou', offerId: 'city-xuzhou-iron-spear', purchaseNumber: 2, unitId: 'mi-zhu', previousItemId: 'training-sword', roster: normalizedEquipped.roster, purchaseCounts: normalizedEquipped.cityEquipmentPurchaseCounts }); assert.deepEqual(snapshot, { version: 1, selectionRecordId: 'city-equipment-preparation', cityStayId: 'xuzhou', sourceBattleId: 'seventh-battle-xuzhou-rescue', targetBattleId: 'eighth-battle-xiaopei-supply-road', offerId: 'city-xuzhou-iron-spear', itemId: 'iron-spear', slot: 'weapon', price: 620, purchaseNumber: 2, unitId: 'mi-zhu', previousItemId: 'training-sword' }); const resolved = preparationModule.resolveCityEquipmentPreparation( snapshot, { battleId: 'eighth-battle-xiaopei-supply-road', roster: normalizedEquipped.roster, purchaseCounts: normalizedEquipped.cityEquipmentPurchaseCounts } ); assert(resolved); assert.equal(resolved.equipmentLevel, 2); assert.equal(resolved.equipmentExp, 17); assert.equal(resolved.preparedBonuses.attack, 4); assert.equal(resolved.previousBonuses.attack, 2); assert.deepEqual(resolved.statDelta, { attack: 2, defense: 0, strategy: 0 }); assert.equal( preparationModule.resolveCityEquipmentPreparation(snapshot, { battleId: 'ninth-battle-xuzhou-gate-night-raid', roster: normalizedEquipped.roster, purchaseCounts: normalizedEquipped.cityEquipmentPurchaseCounts }), undefined, 'The purchase preparation must apply only to its exact target battle.' ); assert.equal( preparationModule.normalizeCityEquipmentPreparationSnapshot(snapshot, { roster: normalizedEquipped.roster, purchaseCounts: { 'city-xuzhou-iron-spear': 1 } }), undefined, 'The recorded purchase number must be backed by purchase provenance.' ); assert.equal( preparationModule.createCityEquipmentPreparationSnapshot({ cityStayId: 'xuzhou', offerId: 'city-xuzhou-grain-pouch', purchaseNumber: 1, unitId: 'mi-zhu', previousItemId: 'grain-pouch', roster: normalizedEquipped.roster, purchaseCounts: { 'city-xuzhou-grain-pouch': 1 } }), undefined, 'A zero-value equipment swap must not become a battle preparation.' ); return { campaign: normalizedEquipped, snapshot, resolved }; } function verifyCampaignNormalizationAndInvalidation({ preparationModule, campaignModule, prepared }) { const withPreparation = { ...structuredClone(prepared.campaign), cityEquipmentPurchaseCounts: { ...prepared.campaign.cityEquipmentPurchaseCounts, unknown: 9, 'city-xuzhou-lamellar-armor': 1000 }, cityEquipmentPreparation: structuredClone(prepared.snapshot) }; let normalized = campaignModule.setCampaignState(withPreparation); assert.equal(normalized.cityEquipmentPurchaseCounts.unknown, undefined); assert.equal( normalized.cityEquipmentPurchaseCounts[ 'city-xuzhou-lamellar-armor' ], 99 ); assert.deepEqual(normalized.cityEquipmentPreparation, prepared.snapshot); const contribution = { ...prepared.snapshot, deployed: true, offensiveActions: 2, defensiveHits: 0, bonusDamage: 4, preventedDamage: 0 }; const completedAt = new Date().toISOString(); const report = { battleId: prepared.snapshot.targetBattleId, battleTitle: '소패 보급로 방어전', outcome: 'victory', turnNumber: 9, rewardGold: 0, defeatedEnemies: 0, totalEnemies: 0, objectives: [], units: structuredClone(normalized.roster), bonds: [], itemRewards: [], cityEquipmentContribution: contribution, completedCampDialogues: [], completedCampVisits: [], createdAt: completedAt }; const settlement = { battleId: report.battleId, battleTitle: report.battleTitle, outcome: report.outcome, turnNumber: report.turnNumber, rewardGold: 0, itemRewards: [], objectives: [], units: normalized.roster.map((unit) => ({ unitId: unit.id, name: unit.name, level: unit.level, exp: unit.exp, hp: unit.hp, maxHp: unit.maxHp, equipment: structuredClone(unit.equipment) })), bonds: [], cityEquipmentContribution: contribution, completedAt }; normalized = campaignModule.setCampaignState({ ...normalized, firstBattleReport: report, battleHistory: { [report.battleId]: settlement } }); assert.deepEqual( normalized.firstBattleReport.cityEquipmentContribution, contribution ); assert.deepEqual( normalized.battleHistory[report.battleId].cityEquipmentContribution, contribution ); const swappedOff = structuredClone(normalized); const miZhu = swappedOff.roster.find((unit) => unit.id === 'mi-zhu'); miZhu.equipment.weapon.itemId = 'training-sword'; const invalidated = campaignModule.setCampaignState(swappedOff); assert.equal( invalidated.cityEquipmentPreparation, undefined, 'Swapping the purchased item off must invalidate the active preparation.' ); const corrupt = preparationModule.normalizeCityEquipmentPreparationSnapshot( { ...prepared.snapshot, price: 1 }, { roster: prepared.campaign.roster, purchaseCounts: prepared.campaign.cityEquipmentPurchaseCounts } ); assert.equal(corrupt, undefined); } function verifyStoredContributionRosterCrossValidation({ campaignModule, prepared }) { const contribution = { ...prepared.snapshot, deployed: true, offensiveActions: 3, defensiveHits: 0, bonusDamage: 6, preventedDamage: 0 }; const completedAt = new Date().toISOString(); const reportRoster = structuredClone(prepared.campaign.roster); const createState = ({ roster = structuredClone(prepared.campaign.roster), reportUnits = structuredClone(reportRoster), storedContribution = structuredClone(contribution) } = {}) => { const report = { battleId: prepared.snapshot.targetBattleId, battleTitle: 'Xuzhou equipment contribution audit', outcome: 'victory', turnNumber: 9, rewardGold: 0, defeatedEnemies: 0, totalEnemies: 0, objectives: [], units: reportUnits, bonds: [], itemRewards: [], cityEquipmentContribution: structuredClone(storedContribution), completedCampDialogues: [], completedCampVisits: [], createdAt: completedAt }; const settlement = { battleId: report.battleId, battleTitle: report.battleTitle, outcome: report.outcome, turnNumber: report.turnNumber, rewardGold: 0, itemRewards: [], objectives: [], units: reportUnits.map((unit) => ({ unitId: unit.id, name: unit.name, level: unit.level, exp: unit.exp, hp: unit.hp, maxHp: unit.maxHp, equipment: structuredClone(unit.equipment) })), bonds: [], cityEquipmentContribution: structuredClone(storedContribution), completedAt }; return { ...structuredClone(prepared.campaign), roster, latestBattleId: report.battleId, firstBattleReport: report, battleHistory: { ...structuredClone(prepared.campaign.battleHistory), [report.battleId]: settlement } }; }; const assertContributionState = ( campaign, expected, message ) => { assert.equal( Boolean( campaign.firstBattleReport ?.cityEquipmentContribution ), expected, `${message} (latest report)` ); assert.equal( Boolean( campaign.battleHistory[ prepared.snapshot.targetBattleId ]?.cityEquipmentContribution ), expected, `${message} (battle settlement)` ); }; const valid = campaignModule.setCampaignState(createState()); assertContributionState( valid, true, 'A canonical contribution backed by the current equipped roster must remain' ); const forged = campaignModule.setCampaignState( createState({ storedContribution: { ...contribution, price: 1 } }) ); assertContributionState( forged, false, 'A forged canonical identity must be removed' ); const ghost = campaignModule.setCampaignState( createState({ storedContribution: { ...contribution, unitId: 'ghost-unit' } }) ); assertContributionState( ghost, false, 'A contribution for a unit absent from the current roster must be removed' ); const wrongEquipmentRoster = structuredClone( prepared.campaign.roster ); wrongEquipmentRoster[0].equipment.weapon.itemId = 'training-sword'; const changedAfterBattle = campaignModule.setCampaignState( createState({ roster: wrongEquipmentRoster }) ); assertContributionState( changedAfterBattle, true, 'Later equipment changes must not erase a canonical historical contribution' ); const wrongHistoricalEquipment = structuredClone( reportRoster ); wrongHistoricalEquipment[0].equipment.weapon.itemId = 'training-sword'; const wrongHistoricalRecord = campaignModule.setCampaignState( createState({ reportUnits: wrongHistoricalEquipment }) ); assertContributionState( wrongHistoricalRecord, false, 'A deployed contribution must be removed when its own historical unit snapshot has the wrong item' ); const filteredRosterUnit = structuredClone( prepared.campaign.roster[0] ); filteredRosterUnit.id = 'jian-yong'; filteredRosterUnit.name = 'Jian Yong'; const filtered = campaignModule.setCampaignState( createState({ roster: [filteredRosterUnit] }) ); assertContributionState( filtered, false, 'Roster-reference filtering must remove a contribution whose prepared unit was filtered out' ); assert.equal( filtered.firstBattleReport.units.some( (unit) => unit.id === prepared.snapshot.unitId ), false, 'The stale report unit should be removed by the same current-roster filter pass.' ); const undeployed = campaignModule.setCampaignState( createState({ reportUnits: [], storedContribution: { ...contribution, deployed: false, offensiveActions: 7, defensiveHits: 8, bonusDamage: 90, preventedDamage: 80 } }) ); assertContributionState( undeployed, true, 'An undeployed result may remain without a historical unit snapshot' ); assert.deepEqual( { offensiveActions: undeployed.firstBattleReport .cityEquipmentContribution.offensiveActions, defensiveHits: undeployed.firstBattleReport .cityEquipmentContribution.defensiveHits, bonusDamage: undeployed.firstBattleReport .cityEquipmentContribution.bonusDamage, preventedDamage: undeployed.firstBattleReport .cityEquipmentContribution.preventedDamage }, { offensiveActions: 0, defensiveHits: 0, bonusDamage: 0, preventedDamage: 0 }, 'Undeployed historical counters must normalize to zero.' ); const legacy = campaignModule.setCampaignState({ ...createState(), firstBattleReport: { ...createState().firstBattleReport, cityEquipmentContribution: undefined }, battleHistory: Object.fromEntries( Object.entries(createState().battleHistory).map( ([battleId, settlement]) => [ battleId, { ...settlement, cityEquipmentContribution: undefined } ] ) ) }); assertContributionState( legacy, false, 'Legacy reports without contribution metadata must remain valid' ); } function verifyContributionNormalization(preparationModule, snapshot) { const normalized = preparationModule.normalizeCityEquipmentContributionSnapshot( { ...snapshot, deployed: true, offensiveActions: 2.9, defensiveHits: -4, bonusDamage: 9999999, preventedDamage: 8.8 }, snapshot.targetBattleId ); assert.deepEqual(normalized, { ...snapshot, deployed: true, offensiveActions: 2, defensiveHits: 0, bonusDamage: 999999, preventedDamage: 0 }); const impossibleOffense = preparationModule.normalizeCityEquipmentContributionSnapshot({ ...normalized, offensiveActions: 0, bonusDamage: 25 }); assert.equal( impossibleOffense.bonusDamage, 0, 'Bonus damage without an offensive action must be discarded.' ); assert.equal( preparationModule.normalizeCityEquipmentContributionSnapshot( normalized, 'ninth-battle-xuzhou-gate-night-raid' ), undefined ); const undeployed = preparationModule.normalizeCityEquipmentContributionSnapshot({ ...normalized, deployed: false, offensiveActions: 7, bonusDamage: 90 }); assert.equal(undeployed.offensiveActions, 0); assert.equal(undeployed.bonusDamage, 0); const clone = preparationModule.cloneCityEquipmentContributionSnapshot(normalized); clone.bonusDamage = 0; assert.equal(normalized.bonusDamage, 999999); } function verifyOldSaveCompatibility(campaignModule) { const oldSave = campaignModule.createInitialCampaignState(); delete oldSave.cityEquipmentPurchaseCounts; delete oldSave.cityEquipmentEquipIntent; delete oldSave.cityEquipmentPreparation; const normalized = campaignModule.setCampaignState(oldSave); assert.deepEqual(normalized.cityEquipmentPurchaseCounts, {}); assert.equal(normalized.cityEquipmentEquipIntent, undefined); assert.equal(normalized.cityEquipmentPreparation, undefined); } function assertStoredPurchase(raw, gold, amount, purchaseCount, label) { assert.equal(typeof raw, 'string'); const parsed = JSON.parse(raw); assert.equal(parsed.gold, gold); assert.equal(parsed.inventory[label], amount); assert.equal( parsed.cityEquipmentPurchaseCounts['city-xuzhou-iron-spear'], purchaseCount ); }