911 lines
25 KiB
JavaScript
911 lines
25 KiB
JavaScript
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
|
|
);
|
|
}
|