feat: connect Xuzhou equipment to battle outcomes
This commit is contained in:
@@ -16,6 +16,9 @@ try {
|
||||
thirdCampPreparationSelectionRecordId,
|
||||
thirdCampPreparationTargetBattleId
|
||||
} = await server.ssrLoadModule('/src/game/data/thirdCampPreparation.ts');
|
||||
const {
|
||||
cityEquipmentPreparationSelectionRecordId
|
||||
} = await server.ssrLoadModule('/src/game/data/cityEquipmentPreparation.ts');
|
||||
|
||||
const options = {
|
||||
expectedBattleId: 'first-battle-zhuo-commandery',
|
||||
@@ -406,6 +409,227 @@ try {
|
||||
parsedLegacyFourthBattleState?.thirdCampPreparation === undefined,
|
||||
'Expected legacy fourth-battle saves without preparation progress to remain valid.'
|
||||
);
|
||||
|
||||
const eighthBattleId = 'eighth-battle-xiaopei-supply-road';
|
||||
const eighthBattleOptions = {
|
||||
...options,
|
||||
expectedBattleId: eighthBattleId,
|
||||
validEquipmentItemIds: new Set([
|
||||
...options.validEquipmentItemIds,
|
||||
'iron-spear'
|
||||
]),
|
||||
validEquipmentSlotItems: {
|
||||
...options.validEquipmentSlotItems,
|
||||
weapon: new Set(['training-sword', 'iron-spear'])
|
||||
}
|
||||
};
|
||||
const eighthBattleUnits = validState.units.map((unit) => ({
|
||||
...unit,
|
||||
stats: unit.stats ? { ...unit.stats } : undefined,
|
||||
equipment: {
|
||||
weapon: {
|
||||
...(unit.id === 'liu-bei'
|
||||
? { itemId: 'iron-spear', level: 2, exp: 17 }
|
||||
: unit.equipment.weapon)
|
||||
},
|
||||
armor: { ...unit.equipment.armor },
|
||||
accessory: { ...unit.equipment.accessory }
|
||||
}
|
||||
}));
|
||||
const validCityEquipmentContribution = {
|
||||
version: 1,
|
||||
selectionRecordId: cityEquipmentPreparationSelectionRecordId,
|
||||
cityStayId: 'xuzhou',
|
||||
sourceBattleId: 'seventh-battle-xuzhou-rescue',
|
||||
targetBattleId: eighthBattleId,
|
||||
offerId: 'city-xuzhou-iron-spear',
|
||||
itemId: 'iron-spear',
|
||||
slot: 'weapon',
|
||||
price: 620,
|
||||
purchaseNumber: 2,
|
||||
unitId: 'liu-bei',
|
||||
previousItemId: 'training-sword',
|
||||
deployed: true,
|
||||
offensiveActions: 3,
|
||||
defensiveHits: 0,
|
||||
bonusDamage: 7,
|
||||
preventedDamage: 0
|
||||
};
|
||||
const eighthBattleBaseState = {
|
||||
...validState,
|
||||
battleId: eighthBattleId,
|
||||
campaignStep: 'eighth-battle',
|
||||
units: eighthBattleUnits,
|
||||
enemyUsableUseKeys: []
|
||||
};
|
||||
const validCityEquipmentState = {
|
||||
...eighthBattleBaseState,
|
||||
cityEquipmentContribution: validCityEquipmentContribution
|
||||
};
|
||||
const parsedCityEquipmentState = parseBattleSaveState(
|
||||
JSON.stringify(validCityEquipmentState),
|
||||
eighthBattleOptions
|
||||
);
|
||||
assert(
|
||||
isValidBattleSaveState(
|
||||
validCityEquipmentState,
|
||||
eighthBattleOptions
|
||||
) &&
|
||||
JSON.stringify(
|
||||
parsedCityEquipmentState?.cityEquipmentContribution
|
||||
) === JSON.stringify(validCityEquipmentContribution) &&
|
||||
parsedCityEquipmentState?.cityEquipmentContribution !==
|
||||
validCityEquipmentContribution,
|
||||
'Expected a deployed city-purchase contribution to round-trip as a deep-cloned battle-save field.'
|
||||
);
|
||||
|
||||
const unsafeCityEquipmentState = {
|
||||
...validCityEquipmentState,
|
||||
cityEquipmentContribution: {
|
||||
...validCityEquipmentContribution,
|
||||
offensiveActions: 2.9,
|
||||
defensiveHits: -4,
|
||||
bonusDamage: Number.MAX_SAFE_INTEGER,
|
||||
preventedDamage: 18
|
||||
}
|
||||
};
|
||||
const normalizedUnsafeCityEquipment = parseBattleSaveState(
|
||||
JSON.stringify(unsafeCityEquipmentState),
|
||||
eighthBattleOptions
|
||||
)?.cityEquipmentContribution;
|
||||
assert(
|
||||
normalizedUnsafeCityEquipment?.offensiveActions === 2 &&
|
||||
normalizedUnsafeCityEquipment.defensiveHits === 0 &&
|
||||
normalizedUnsafeCityEquipment.bonusDamage === 999999 &&
|
||||
normalizedUnsafeCityEquipment.preventedDamage === 0 &&
|
||||
!isValidBattleSaveState(
|
||||
unsafeCityEquipmentState,
|
||||
eighthBattleOptions
|
||||
),
|
||||
`Expected unsafe city-equipment counters to normalize safely and fail strict direct validation: ${JSON.stringify(normalizedUnsafeCityEquipment)}`
|
||||
);
|
||||
|
||||
const noRecordedUseCityEquipment = parseBattleSaveState(
|
||||
JSON.stringify({
|
||||
...validCityEquipmentState,
|
||||
cityEquipmentContribution: {
|
||||
...validCityEquipmentContribution,
|
||||
offensiveActions: 0,
|
||||
bonusDamage: 77,
|
||||
defensiveHits: 0,
|
||||
preventedDamage: 88
|
||||
}
|
||||
}),
|
||||
eighthBattleOptions
|
||||
)?.cityEquipmentContribution;
|
||||
assert(
|
||||
noRecordedUseCityEquipment?.bonusDamage === 0 &&
|
||||
noRecordedUseCityEquipment.preventedDamage === 0,
|
||||
'Expected contribution damage totals to clear when no matching actions or hits were recorded.'
|
||||
);
|
||||
|
||||
const wrongBattleCityEquipment = {
|
||||
...validState,
|
||||
cityEquipmentContribution: validCityEquipmentContribution
|
||||
};
|
||||
const normalizedWrongBattleCityEquipment =
|
||||
normalizeBattleSaveState(
|
||||
wrongBattleCityEquipment,
|
||||
options
|
||||
);
|
||||
assert(
|
||||
normalizedWrongBattleCityEquipment?.cityEquipmentContribution ===
|
||||
undefined &&
|
||||
!isValidBattleSaveState(
|
||||
wrongBattleCityEquipment,
|
||||
options
|
||||
),
|
||||
'Expected city-equipment progress attached to another battle to be discarded and rejected by direct validation.'
|
||||
);
|
||||
|
||||
const mismatchedEquipmentCityState = {
|
||||
...validCityEquipmentState,
|
||||
units: validState.units.map((unit) => ({
|
||||
...unit,
|
||||
equipment: {
|
||||
weapon: { ...unit.equipment.weapon },
|
||||
armor: { ...unit.equipment.armor },
|
||||
accessory: { ...unit.equipment.accessory }
|
||||
}
|
||||
}))
|
||||
};
|
||||
const normalizedMismatchedEquipment = normalizeBattleSaveState(
|
||||
mismatchedEquipmentCityState,
|
||||
eighthBattleOptions
|
||||
);
|
||||
assert(
|
||||
normalizedMismatchedEquipment?.cityEquipmentContribution ===
|
||||
undefined &&
|
||||
!isValidBattleSaveState(
|
||||
mismatchedEquipmentCityState,
|
||||
eighthBattleOptions
|
||||
),
|
||||
'Expected a save whose recorded unit no longer equips the purchased item to discard and reject the contribution.'
|
||||
);
|
||||
|
||||
const corruptedCityEquipmentCases = [
|
||||
{
|
||||
label: 'forged record id',
|
||||
contribution: {
|
||||
...validCityEquipmentContribution,
|
||||
selectionRecordId: 'forged-city-equipment'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'wrong-slot previous item',
|
||||
contribution: {
|
||||
...validCityEquipmentContribution,
|
||||
previousItemId: 'cloth-armor'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'undeployed runtime',
|
||||
contribution: {
|
||||
...validCityEquipmentContribution,
|
||||
deployed: false
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'unknown prepared unit',
|
||||
contribution: {
|
||||
...validCityEquipmentContribution,
|
||||
unitId: 'missing-unit'
|
||||
}
|
||||
}
|
||||
];
|
||||
corruptedCityEquipmentCases.forEach(({ label, contribution }) => {
|
||||
const candidate = {
|
||||
...eighthBattleBaseState,
|
||||
cityEquipmentContribution: contribution
|
||||
};
|
||||
const normalized = normalizeBattleSaveState(
|
||||
candidate,
|
||||
eighthBattleOptions
|
||||
);
|
||||
assert(
|
||||
normalized?.cityEquipmentContribution === undefined &&
|
||||
!isValidBattleSaveState(candidate, eighthBattleOptions),
|
||||
`Expected ${label} city-equipment progress to be discarded and rejected.`
|
||||
);
|
||||
});
|
||||
|
||||
assert(
|
||||
isValidBattleSaveState(
|
||||
eighthBattleBaseState,
|
||||
eighthBattleOptions
|
||||
) &&
|
||||
parseBattleSaveState(
|
||||
JSON.stringify(eighthBattleBaseState),
|
||||
eighthBattleOptions
|
||||
)?.cityEquipmentContribution === undefined,
|
||||
'Expected legacy eighth-battle saves without city-equipment contribution progress to remain valid.'
|
||||
);
|
||||
|
||||
const validSortieStatsState = {
|
||||
...validState,
|
||||
battleStats: {
|
||||
|
||||
910
scripts/verify-city-equipment-preparation.mjs
Normal file
910
scripts/verify-city-equipment-preparation.mjs
Normal file
@@ -0,0 +1,910 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ const checks = [
|
||||
'scripts/verify-narrative-continuity.mjs',
|
||||
'scripts/verify-camp-reward-data.mjs',
|
||||
'scripts/verify-city-stay-data.mjs',
|
||||
'scripts/verify-city-equipment-preparation.mjs',
|
||||
'scripts/verify-prologue-village-data.mjs',
|
||||
'scripts/verify-first-battle-camp-followup.mjs',
|
||||
'scripts/verify-first-battle-camaraderie-memory.mjs',
|
||||
|
||||
2350
scripts/verify-xuzhou-equipment-link-browser.mjs
Normal file
2350
scripts/verify-xuzhou-equipment-link-browser.mjs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user