feat: connect Xuzhou equipment to battle outcomes

This commit is contained in:
2026-07-27 15:39:11 +09:00
parent adf9e65668
commit 2737354e30
13 changed files with 5664 additions and 68 deletions

View File

@@ -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: {

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

View File

@@ -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',

File diff suppressed because it is too large Load Diff