Files
heros_web/scripts/verify-battle-save-normalization.mjs

1140 lines
52 KiB
JavaScript

import { createServer } from 'vite';
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true },
appType: 'custom'
});
try {
const { isValidBattleSaveState, normalizeBattleSaveSlot, normalizeBattleSaveState, parseBattleSaveState } = await server.ssrLoadModule(
'/src/game/state/battleSaveState.ts'
);
const {
getThirdCampPreparationPriority,
thirdCampPreparationInformationEnemyUnitId,
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',
mapWidth: 12,
mapHeight: 8,
validUnitIds: new Set(['liu-bei', 'guan-yu', 'rebel-1']),
validAllyUnitIds: new Set(['liu-bei', 'guan-yu']),
validEnemyUnitIds: new Set(['rebel-1']),
validUsableIds: new Set(['roar', 'fireTactic', 'aid', 'bean', 'salve', 'wine']),
validItemIds: new Set(['bean', 'salve', 'wine']),
validEquipmentItemIds: new Set(['training-sword', 'cloth-armor', 'grain-pouch']),
validEquipmentSlotItems: {
weapon: new Set(['training-sword']),
armor: new Set(['cloth-armor']),
accessory: new Set(['grain-pouch'])
},
validTriggeredBattleEventIds: new Set(['opening', 'leader-wavering', 'objective-village-approach', 'objective-village-achieved'])
};
const validState = createValidBattleSaveState();
const patchUnit = (index, patch) => validState.units.map((unit, unitIndex) => (unitIndex === index ? { ...unit, ...patch } : unit));
assert(normalizeBattleSaveSlot(0, 3) === 1, 'Expected slot 0 to normalize to slot 1.');
assert(normalizeBattleSaveSlot(Number.NaN, 3) === 1, 'Expected NaN slot to normalize to slot 1.');
assert(normalizeBattleSaveSlot(99, 3) === 3, 'Expected overflowing slot to clamp to slot count.');
assert(isValidBattleSaveState(validState, options), 'Expected valid battle save to pass validation.');
const parsedProgressedStats = parseBattleSaveState(JSON.stringify(validState), options)?.units[0]?.stats;
assert(
JSON.stringify(parsedProgressedStats) === JSON.stringify(validState.units[0].stats),
'Expected battle-earned unit attributes to round-trip through battle-save parsing.'
);
const legacyUnitsWithoutStats = validState.units.map(({ stats: _stats, ...unit }) => unit);
assert(
isValidBattleSaveState({ ...validState, units: legacyUnitsWithoutStats }, options),
'Expected legacy battle saves without unit attributes to remain valid.'
);
const validCompletedAttackHistoryState = {
...validState,
actedUnitIds: ['liu-bei', 'guan-yu'],
attackIntents: [
{ attackerId: 'liu-bei', targetId: 'rebel-1' },
{ attackerId: 'guan-yu', targetId: 'rebel-1' }
]
};
assert(
isValidBattleSaveState(validCompletedAttackHistoryState, options),
'Expected unique completed allied attacks against a living enemy to pass validation.'
);
assert(
JSON.stringify(parseBattleSaveState(JSON.stringify(validCompletedAttackHistoryState), options)?.attackIntents) ===
JSON.stringify(validCompletedAttackHistoryState.attackIntents),
'Expected completed allied attack history to round-trip through battle-save parsing.'
);
assert(
['elite', 'mobile', 'siege'].every((sortieOrderId) => isValidBattleSaveState({ ...validState, sortieOrderId }, options)),
'Expected every sortie order id to pass battle-save validation.'
);
const { sortieOrderId: _legacySortieOrderId, ...legacyStateWithoutSortieOrder } = validState;
assert(
isValidBattleSaveState(legacyStateWithoutSortieOrder, options),
'Expected legacy battle saves without a sortie order to remain valid.'
);
const validPendingBattleEventState = {
...validState,
pendingBattleEvents: [
{
key: 'leader-wavering',
title: '두령 동요',
lines: ['두령의 기세가 꺾였습니다.', '전열을 몰아붙이십시오.'],
priority: 'high',
playCue: true,
presented: true
},
{
key: 'objective-village-approach',
title: '마을 접근',
lines: ['마을 주변의 방어 병력을 확인했습니다.'],
priority: 'low',
playCue: true
}
]
};
const parsedPendingBattleEvents = parseBattleSaveState(JSON.stringify(validPendingBattleEventState), options)?.pendingBattleEvents;
assert(
isValidBattleSaveState(validPendingBattleEventState, options) &&
parsedPendingBattleEvents?.length === 2 &&
parsedPendingBattleEvents[0]?.presented === true &&
parsedPendingBattleEvents !== validPendingBattleEventState.pendingBattleEvents,
'Expected active and queued battle events to round-trip without changing save version 1.'
);
const validRecommendationState = {
...validState,
sortieRecommendation: {
version: 1,
battleId: 'first-battle-zhuo-commandery',
planId: 'order',
label: '군령 달성형',
summary: '군령 조건을 우선합니다.',
sortieOrderId: 'elite',
selectedUnitIds: ['liu-bei', 'guan-yu'],
formationAssignments: { 'liu-bei': 'support', 'guan-yu': 'front' },
unitReasons: { 'liu-bei': '지휘를 맡습니다.', 'guan-yu': '전열을 맡습니다.' }
}
};
const parsedRecommendationState = parseBattleSaveState(JSON.stringify(validRecommendationState), options);
assert(
parsedRecommendationState?.sortieRecommendation?.planId === 'order' &&
parsedRecommendationState.sortieRecommendation !== validRecommendationState.sortieRecommendation &&
JSON.stringify(parsedRecommendationState.sortieRecommendation.selectedUnitIds) === JSON.stringify(['liu-bei', 'guan-yu']),
`Expected valid recommendation provenance to round-trip through a battle save: ${JSON.stringify(parsedRecommendationState?.sortieRecommendation)}`
);
const corruptedRecommendationState = normalizeBattleSaveState({
...validRecommendationState,
sortieRecommendation: { ...validRecommendationState.sortieRecommendation, planId: 'unknown-plan' }
}, options);
assert(
corruptedRecommendationState && corruptedRecommendationState.sortieRecommendation === undefined,
'Expected corrupted optional recommendation metadata to be discarded without rejecting the battle save.'
);
const validCoreResonanceState = {
...validState,
sortieResonanceBondId: 'taoyuan',
bonds: validState.bonds.map((bond) => ({ ...bond, unitIds: [...bond.unitIds], level: 30 })),
coreResonanceStats: {
attempts: 3,
successes: 1,
failures: 2,
additionalDamage: 9
}
};
assert(
isValidBattleSaveState(validCoreResonanceState, options),
'Expected a deployed level-30 core resonance pair and its pursuit totals to pass validation.'
);
const parsedCoreResonanceState = parseBattleSaveState(JSON.stringify(validCoreResonanceState), options);
assert(
parsedCoreResonanceState?.sortieResonanceBondId === 'taoyuan' &&
JSON.stringify(parsedCoreResonanceState.coreResonanceStats) === JSON.stringify(validCoreResonanceState.coreResonanceStats),
'Expected core resonance selection and pursuit totals to round-trip.'
);
const deepClonedCoreResonanceState = normalizeBattleSaveState(validCoreResonanceState, options);
assert(
deepClonedCoreResonanceState &&
deepClonedCoreResonanceState !== validCoreResonanceState &&
deepClonedCoreResonanceState.bonds !== validCoreResonanceState.bonds &&
deepClonedCoreResonanceState.bonds[0].unitIds !== validCoreResonanceState.bonds[0].unitIds &&
deepClonedCoreResonanceState.coreResonanceStats !== validCoreResonanceState.coreResonanceStats,
'Expected battle-save normalization to return a deep clone.'
);
deepClonedCoreResonanceState.coreResonanceStats.additionalDamage = 99;
deepClonedCoreResonanceState.bonds[0].unitIds[0] = 'mutated-unit';
assert(
validCoreResonanceState.coreResonanceStats.additionalDamage === 9 &&
validCoreResonanceState.bonds[0].unitIds[0] === 'liu-bei',
'Expected normalized core resonance state mutations not to leak into the source state.'
);
const normalizedUnsafeCoreStats = parseBattleSaveState(JSON.stringify({
...validCoreResonanceState,
coreResonanceStats: {
attempts: 3.8,
successes: 1.9,
failures: -4,
additionalDamage: -9.5
}
}), options);
assert(
JSON.stringify(normalizedUnsafeCoreStats?.coreResonanceStats) === JSON.stringify({
attempts: 3,
successes: 1,
failures: 2,
additionalDamage: 0
}),
'Expected negative and fractional core resonance totals to normalize to safe integers.'
);
const normalizedUnknownCoreBond = parseBattleSaveState(JSON.stringify({
...validCoreResonanceState,
sortieResonanceBondId: 'missing-bond'
}), options);
assert(
normalizedUnknownCoreBond &&
normalizedUnknownCoreBond.sortieResonanceBondId === undefined &&
normalizedUnknownCoreBond.coreResonanceStats === undefined,
'Expected an unknown core resonance bond and its unrelated totals to be removed safely.'
);
const normalizedUnderleveledCoreBond = parseBattleSaveState(JSON.stringify({
...validCoreResonanceState,
bonds: validState.bonds.map((bond) => ({ ...bond, unitIds: [...bond.unitIds], level: 29 }))
}), options);
assert(
normalizedUnderleveledCoreBond &&
normalizedUnderleveledCoreBond.sortieResonanceBondId === undefined &&
normalizedUnderleveledCoreBond.coreResonanceStats === undefined,
'Expected an underleveled core resonance pair to be cleared during normalization.'
);
assert(
!isValidBattleSaveState({
...validCoreResonanceState,
coreResonanceStats: { attempts: 2, successes: 1.5, failures: 1, additionalDamage: -1 }
}, options),
'Expected direct validation to reject unsafe core resonance totals before normalization.'
);
const fourthBattleOptions = {
...options,
expectedBattleId: thirdCampPreparationTargetBattleId
};
const fourthBattleBaseState = {
...validState,
battleId: thirdCampPreparationTargetBattleId,
campaignStep: 'fourth-battle',
enemyUsableUseKeys: []
};
const thirdPreparationLabels = Object.fromEntries(
['information', 'equipment', 'companion'].map((priorityId) => [
priorityId,
getThirdCampPreparationPriority(priorityId)?.label
])
);
const validThirdPreparationStates = [
{
selectionRecordId: thirdCampPreparationSelectionRecordId,
priorityId: 'information',
label: thirdPreparationLabels.information,
markedEnemyUnitId: thirdCampPreparationInformationEnemyUnitId,
informationConsumed: true,
equipmentConsumed: false,
companionAttempts: 0,
companionSuccesses: 0,
usageCount: 1,
preventedDamage: 0
},
{
selectionRecordId: thirdCampPreparationSelectionRecordId,
priorityId: 'equipment',
label: thirdPreparationLabels.equipment,
informationConsumed: false,
equipmentConsumed: true,
companionAttempts: 0,
companionSuccesses: 0,
usageCount: 1,
preventedDamage: 8
},
{
selectionRecordId: thirdCampPreparationSelectionRecordId,
priorityId: 'companion',
label: thirdPreparationLabels.companion,
informationConsumed: false,
equipmentConsumed: false,
companionAttempts: 3,
companionSuccesses: 2,
usageCount: 3,
preventedDamage: 0
}
];
validThirdPreparationStates.forEach((thirdCampPreparation) => {
const state = { ...fourthBattleBaseState, thirdCampPreparation };
const parsed = parseBattleSaveState(JSON.stringify(state), fourthBattleOptions);
assert(
isValidBattleSaveState(state, fourthBattleOptions) &&
JSON.stringify(parsed?.thirdCampPreparation) === JSON.stringify(thirdCampPreparation) &&
parsed?.thirdCampPreparation !== thirdCampPreparation,
`Expected ${thirdCampPreparation.priorityId} preparation progress to round-trip as a deep-cloned save field.`
);
});
const normalizedWrongBattlePreparation = parseBattleSaveState(JSON.stringify({
...validState,
thirdCampPreparation: validThirdPreparationStates[0]
}), options);
assert(
normalizedWrongBattlePreparation &&
normalizedWrongBattlePreparation.thirdCampPreparation === undefined &&
!isValidBattleSaveState(
{ ...validState, thirdCampPreparation: validThirdPreparationStates[0] },
options
),
'Expected preparation progress attached to a different battle to be discarded and rejected by direct validation.'
);
const normalizedUnknownPreparationPriority = parseBattleSaveState(JSON.stringify({
...fourthBattleBaseState,
thirdCampPreparation: {
...validThirdPreparationStates[0],
priorityId: 'unknown-priority'
}
}), fourthBattleOptions);
const normalizedWrongPreparationRecord = parseBattleSaveState(JSON.stringify({
...fourthBattleBaseState,
thirdCampPreparation: {
...validThirdPreparationStates[0],
selectionRecordId: 'forged-preparation-record'
}
}), fourthBattleOptions);
assert(
normalizedUnknownPreparationPriority?.thirdCampPreparation === undefined &&
normalizedWrongPreparationRecord?.thirdCampPreparation === undefined,
'Expected unknown priorities and forged preparation record ids to be removed without rejecting the battle save.'
);
const normalizedInformationPreparation = parseBattleSaveState(JSON.stringify({
...fourthBattleBaseState,
thirdCampPreparation: {
...validThirdPreparationStates[0],
label: 'forged-label',
markedEnemyUnitId: 'forged-vanguard',
informationConsumed: false,
equipmentConsumed: true,
companionAttempts: 7,
companionSuccesses: 6,
usageCount: 999,
preventedDamage: 90
}
}), fourthBattleOptions)?.thirdCampPreparation;
assert(
normalizedInformationPreparation?.label === thirdPreparationLabels.information &&
normalizedInformationPreparation.markedEnemyUnitId === thirdCampPreparationInformationEnemyUnitId &&
normalizedInformationPreparation.informationConsumed === false &&
normalizedInformationPreparation.equipmentConsumed === false &&
normalizedInformationPreparation.companionAttempts === 0 &&
normalizedInformationPreparation.companionSuccesses === 0 &&
normalizedInformationPreparation.usageCount === 0 &&
normalizedInformationPreparation.preventedDamage === 0,
`Expected information preparation to restore the canonical mark and derive its unconsumed state: ${JSON.stringify(normalizedInformationPreparation)}`
);
const normalizedUnusedEquipmentPreparation = parseBattleSaveState(JSON.stringify({
...fourthBattleBaseState,
thirdCampPreparation: {
...validThirdPreparationStates[1],
markedEnemyUnitId: thirdCampPreparationInformationEnemyUnitId,
equipmentConsumed: false,
usageCount: 55,
preventedDamage: 55
}
}), fourthBattleOptions)?.thirdCampPreparation;
const normalizedConsumedEquipmentPreparation = parseBattleSaveState(JSON.stringify({
...fourthBattleBaseState,
thirdCampPreparation: {
...validThirdPreparationStates[1],
markedEnemyUnitId: thirdCampPreparationInformationEnemyUnitId,
usageCount: 0,
preventedDamage: Number.MAX_SAFE_INTEGER
}
}), fourthBattleOptions)?.thirdCampPreparation;
assert(
normalizedUnusedEquipmentPreparation?.markedEnemyUnitId === undefined &&
normalizedUnusedEquipmentPreparation?.equipmentConsumed === false &&
normalizedUnusedEquipmentPreparation?.usageCount === 0 &&
normalizedUnusedEquipmentPreparation?.preventedDamage === 0 &&
normalizedConsumedEquipmentPreparation?.markedEnemyUnitId === undefined &&
normalizedConsumedEquipmentPreparation?.equipmentConsumed === true &&
normalizedConsumedEquipmentPreparation?.usageCount === 1 &&
normalizedConsumedEquipmentPreparation?.preventedDamage === 999999,
'Expected equipment preparation to remove unrelated marks, derive consumption count, and clamp prevented damage.'
);
const normalizedOversizedCompanionPreparation = parseBattleSaveState(JSON.stringify({
...fourthBattleBaseState,
thirdCampPreparation: {
...validThirdPreparationStates[2],
markedEnemyUnitId: thirdCampPreparationInformationEnemyUnitId,
informationConsumed: true,
equipmentConsumed: true,
companionAttempts: Number.MAX_SAFE_INTEGER,
companionSuccesses: Number.MAX_SAFE_INTEGER,
usageCount: 1,
preventedDamage: 1234
}
}), fourthBattleOptions)?.thirdCampPreparation;
assert(
normalizedOversizedCompanionPreparation?.markedEnemyUnitId === undefined &&
normalizedOversizedCompanionPreparation?.informationConsumed === false &&
normalizedOversizedCompanionPreparation?.equipmentConsumed === false &&
normalizedOversizedCompanionPreparation?.companionAttempts === 999999 &&
normalizedOversizedCompanionPreparation?.companionSuccesses === 999999 &&
normalizedOversizedCompanionPreparation?.usageCount === 999999 &&
normalizedOversizedCompanionPreparation?.preventedDamage === 0,
`Expected companion preparation counters to clamp and unrelated consumption fields to clear: ${JSON.stringify(normalizedOversizedCompanionPreparation)}`
);
const { thirdCampPreparation: _legacyPreparation, ...legacyFourthBattleState } = {
...fourthBattleBaseState,
thirdCampPreparation: validThirdPreparationStates[0]
};
const parsedLegacyFourthBattleState = parseBattleSaveState(
JSON.stringify(legacyFourthBattleState),
fourthBattleOptions
);
assert(
isValidBattleSaveState(legacyFourthBattleState, fourthBattleOptions) &&
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: {
'liu-bei': {
...validState.battleStats['liu-bei'],
sortieBonusDamage: 4,
sortiePreventedDamage: 3,
sortieBonusHealing: 2,
sortieExtendedBondTriggers: 1,
intentDefeats: 1,
intentLineBreaks: 2,
intentGuardSuccesses: 3
}
}
};
assert(isValidBattleSaveState(validSortieStatsState, options), 'Expected sortie and counterplay contribution stats to pass validation.');
const tacticalOptions = {
...options,
expectedBattleId: 'second-battle-yellow-turban-pursuit',
allowTacticalCommand: true,
allowLegacyTacticalCommand: true
};
const validTacticalBaseState = {
...validState,
battleId: tacticalOptions.expectedBattleId,
campaignStep: 'second-battle',
enemyUsableUseKeys: [],
battleStats: {
...validState.battleStats,
'liu-bei': {
...validState.battleStats['liu-bei'],
intentDefeats: 3
}
}
};
const validFrontCommandState = {
...validTacticalBaseState,
tacticalCommand: {
role: 'front',
actorId: 'guan-yu',
usedTurn: 2,
effectPending: true,
effectValue: 0,
statusesCleared: 0,
effectLost: false
}
};
const validFlankCommandState = {
...validFrontCommandState,
tacticalCommand: { ...validFrontCommandState.tacticalCommand, role: 'flank', effectPending: false, effectValue: 4 }
};
const validSupportCommandState = {
...validFrontCommandState,
tacticalCommand: {
...validFrontCommandState.tacticalCommand,
role: 'support',
actorId: 'liu-bei',
effectPending: false,
effectValue: 6,
statusesCleared: 1
}
};
const validLostFrontCommandState = {
...validFrontCommandState,
units: validFrontCommandState.units.map((unit) => (unit.id === 'guan-yu' ? { ...unit, hp: 0 } : unit)),
tacticalCommand: {
...validFrontCommandState.tacticalCommand,
effectPending: false,
effectLost: true
}
};
const validInitiativeCommandState = {
...validFrontCommandState,
tacticalCommand: { ...validFrontCommandState.tacticalCommand, source: 'initiative' }
};
const validUnlockedOrderCommandState = {
...validTacticalBaseState,
battleStats: validState.battleStats,
sortieOrderCommandUnlocked: true,
sortieOrderCommandUnlockTurn: 2,
tacticalCommand: {
...validFrontCommandState.tacticalCommand,
source: 'sortie-order'
}
};
assert(isValidBattleSaveState(validFrontCommandState, tacticalOptions), 'Expected pending front tactical command to pass validation.');
assert(isValidBattleSaveState(validInitiativeCommandState, tacticalOptions), 'Expected an explicit initiative source to pass validation.');
assert(isValidBattleSaveState(validFlankCommandState, tacticalOptions), 'Expected resolved flank tactical command to pass validation.');
assert(isValidBattleSaveState(validSupportCommandState, tacticalOptions), 'Expected immediate support tactical command to pass validation.');
assert(isValidBattleSaveState(validLostFrontCommandState, tacticalOptions), 'Expected a lost tactical command to retain its result record.');
assert(
isValidBattleSaveState(validUnlockedOrderCommandState, tacticalOptions),
'Expected an unlocked sortie-order command to replace the counterplay requirement.'
);
assert(
isValidBattleSaveState({ ...validUnlockedOrderCommandState, tacticalCommand: undefined }, tacticalOptions),
'Expected an unlocked sortie-order command to remain unused in a valid save.'
);
assert(
isValidBattleSaveState({ ...validTacticalBaseState, sortieOrderCommandUnlocked: false }, tacticalOptions),
'Expected an explicitly locked sortie-order command without an unlock turn to pass validation.'
);
const otherSortieOptions = {
...options,
allowTacticalCommand: true,
allowLegacyTacticalCommand: false
};
const otherSortieLegacyCommandState = {
...validFrontCommandState,
battleId: options.expectedBattleId,
campaignStep: 'first-battle',
tacticalCommand: { ...validFrontCommandState.tacticalCommand }
};
const otherSortieOrderCommandState = {
...validUnlockedOrderCommandState,
battleId: options.expectedBattleId,
campaignStep: 'first-battle'
};
assert(
isValidBattleSaveState(otherSortieOrderCommandState, otherSortieOptions) &&
!isValidBattleSaveState(otherSortieLegacyCommandState, otherSortieOptions) &&
!isValidBattleSaveState({
...otherSortieLegacyCommandState,
tacticalCommand: { ...otherSortieLegacyCommandState.tacticalCommand, source: 'initiative' }
}, otherSortieOptions),
'Expected another sortie battle to allow sortie-order source while rejecting source-less and explicit legacy initiative commands.'
);
assert(!isValidBattleSaveState(validFrontCommandState, options), 'Expected tactical commands to be rejected when disabled for the battle.');
const tacticalRoleOptions = {
...tacticalOptions,
validTacticalCommandRolesByActor: { 'guan-yu': 'front', 'liu-bei': 'support' }
};
assert(isValidBattleSaveState(validFrontCommandState, tacticalRoleOptions), 'Expected the assigned front actor to pass validation.');
assert(isValidBattleSaveState(validSupportCommandState, tacticalRoleOptions), 'Expected the assigned support actor to pass validation.');
assert(!isValidBattleSaveState(validFlankCommandState, tacticalRoleOptions), 'Expected a role and actor assignment mismatch to be rejected.');
const parsedTacticalCommand = parseBattleSaveState(JSON.stringify(validFrontCommandState), tacticalOptions)?.tacticalCommand;
assert(
JSON.stringify(parsedTacticalCommand) === JSON.stringify(validFrontCommandState.tacticalCommand),
'Expected every tactical command field to round-trip.'
);
const parsedOrderCommand = parseBattleSaveState(JSON.stringify(validUnlockedOrderCommandState), tacticalOptions);
assert(
parsedOrderCommand?.sortieOrderCommandUnlocked === true &&
parsedOrderCommand.sortieOrderCommandUnlockTurn === 2 &&
parsedOrderCommand.tacticalCommand?.source === 'sortie-order',
'Expected sortie-order command unlock and source fields to round-trip.'
);
assert(parseBattleSaveState(JSON.stringify(validState), options)?.battleId === options.expectedBattleId, 'Expected valid JSON save to parse.');
assert(
parseBattleSaveState(JSON.stringify(validState), options)?.sortieOrderId === 'elite',
'Expected the selected sortie order to round-trip through battle-save parsing.'
);
assert(parseBattleSaveState('{', options) === undefined, 'Expected malformed JSON to be ignored.');
assert(parseBattleSaveState('', options) === undefined, 'Expected empty save payload to be ignored.');
const rejectedCases = [
['wrong battle id', { battleId: 'other-battle' }],
['missing battle id', { battleId: undefined }],
['invalid savedAt', { savedAt: 'not-a-date' }],
['invalid turnNumber', { turnNumber: 0 }],
['invalid activeFaction', { activeFaction: 'neutral' }],
['invalid rosterTab', { rosterTab: 'neutral' }],
['invalid campaign step', { campaignStep: 'lost-progress' }],
['invalid sortie order', { sortieOrderId: 'reserve' }],
['invalid actedUnitIds', { actedUnitIds: 'liu-bei' }],
['unknown acted unit id', { actedUnitIds: ['ghost-unit'] }],
['duplicate acted unit id', { actedUnitIds: ['liu-bei', 'liu-bei'] }],
['invalid battleLog', { battleLog: 'hit' }],
['too many battle log entries', { battleLog: Array.from({ length: 11 }, (_, index) => `Log ${index}`) }],
['too long battle log entry', { battleLog: ['x'.repeat(97)] }],
['invalid attackIntents', { attackIntents: [{ attackerId: 'liu-bei' }] }],
['unknown attack intent unit id', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'ghost-unit' }] }],
['self-targeting attack intent', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'liu-bei' }] }],
['duplicate attack intent attacker id', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'rebel-1' }, { attackerId: 'liu-bei', targetId: 'rebel-1' }] }],
['unacted attack intent attacker', { actedUnitIds: [] }],
['enemy attack intent attacker', { attackIntents: [{ attackerId: 'rebel-1', targetId: 'liu-bei' }] }],
['ally attack intent target', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'guan-yu' }] }],
['defeated attack intent attacker', { units: patchUnit(0, { hp: 0 }) }],
['defeated attack intent target', { units: patchUnit(2, { hp: 0 }) }],
['invalid units array', { units: {} }],
['too high unit level', { units: patchUnit(0, { level: 100, exp: 0 }) }],
['invalid unit exp threshold', { units: patchUnit(0, { exp: 100 }) }],
['invalid max-level unit exp threshold', { units: patchUnit(0, { level: 99, exp: 101 }) }],
['invalid unit hp', { units: patchUnit(0, { hp: 99, maxHp: 30 }) }],
['invalid unit hp precision', { units: patchUnit(0, { hp: 12.5 }) }],
['too high unit max hp', { units: patchUnit(0, { hp: 30, maxHp: 10000 }) }],
['invalid unit attack', { units: patchUnit(0, { attack: -1 }) }],
['invalid unit move precision', { units: patchUnit(0, { move: 3.5 }) }],
['too high unit move', { units: patchUnit(0, { move: 100 }) }],
['invalid unit stats shape', { units: patchUnit(0, { stats: { might: 80 } }) }],
['invalid unit stats precision', { units: patchUnit(0, { stats: { ...validState.units[0].stats, agility: 72.5 } }) }],
['too high unit stat', { units: patchUnit(0, { stats: { ...validState.units[0].stats, might: 1000 } }) }],
['duplicate live unit tile', { units: patchUnit(1, { x: validState.units[0].x, y: validState.units[0].y }) }],
['invalid unit x', { units: patchUnit(0, { x: 12 }) }],
['invalid unit direction', { units: patchUnit(0, { direction: 'down' }) }],
['missing equipment slot', { units: patchUnit(0, { equipment: { weapon: createEquipmentSet().weapon, armor: createEquipmentSet().armor } }) }],
['extra equipment slot', { units: patchUnit(0, { equipment: { ...createEquipmentSet(), charm: createEquipmentSet().accessory } }) }],
['unknown equipment item id', { units: patchUnit(0, { equipment: { ...createEquipmentSet(), weapon: { itemId: 'phantom-sword', level: 1, exp: 0 } } }) }],
['wrong equipment slot item id', { units: patchUnit(0, { equipment: { ...createEquipmentSet(), weapon: { itemId: 'cloth-armor', level: 1, exp: 0 } } }) }],
['invalid equipment level', { units: patchUnit(0, { equipment: { ...createEquipmentSet(), weapon: { itemId: 'training-sword', level: 0, exp: 0 } } }) }],
['too high equipment level', { units: patchUnit(0, { equipment: { ...createEquipmentSet(), weapon: { itemId: 'training-sword', level: 10, exp: 0 } } }) }],
['invalid equipment exp', { units: patchUnit(0, { equipment: { ...createEquipmentSet(), weapon: { itemId: 'training-sword', level: 1, exp: -1 } } }) }],
['invalid equipment exp threshold', { units: patchUnit(0, { equipment: { ...createEquipmentSet(), weapon: { itemId: 'training-sword', level: 1, exp: 70 } } }) }],
['invalid max-level equipment exp threshold', { units: patchUnit(0, { equipment: { ...createEquipmentSet(), weapon: { itemId: 'training-sword', level: 9, exp: 231 } } }) }],
['unknown unit id', { units: patchUnit(0, { id: 'ghost-unit' }) }],
['duplicate unit id', { units: [validState.units[0], { ...validState.units[0], x: 2 }, validState.units[2]] }],
['invalid bonds', { bonds: [{ ...validState.bonds[0], unitIds: ['liu-bei'] }] }],
['empty bond id', { bonds: [{ ...validState.bonds[0], id: '' }] }],
['duplicate bond id', { bonds: [validState.bonds[0], { ...validState.bonds[0], unitIds: ['guan-yu', 'liu-bei'] }] }],
['too many bonds', { bonds: Array.from({ length: 129 }, (_, index) => ({ ...validState.bonds[0], id: `bond-${index}` })) }],
['unknown bond unit id', { bonds: [{ ...validState.bonds[0], unitIds: ['liu-bei', 'ghost-unit'] }] }],
['self bond unit ids', { bonds: [{ ...validState.bonds[0], unitIds: ['liu-bei', 'liu-bei'] }] }],
['invalid bond level', { bonds: [{ ...validState.bonds[0], level: 0 }] }],
['too high bond level', { bonds: [{ ...validState.bonds[0], level: 101 }] }],
['invalid bond exp', { bonds: [{ ...validState.bonds[0], exp: -1 }] }],
['invalid bond exp threshold', { bonds: [{ ...validState.bonds[0], level: 1, exp: 100 }] }],
['invalid max-level bond exp threshold', { bonds: [{ ...validState.bonds[0], level: 100, exp: 101 }] }],
['invalid bond battle exp', { bonds: [{ ...validState.bonds[0], battleExp: 1.5 }] }],
['too high bond battle exp', { bonds: [{ ...validState.bonds[0], battleExp: 1000000 }] }],
['invalid item stock count', { itemStocks: { 'liu-bei': { bean: -1 } } }],
['too many item stock count', { itemStocks: { 'liu-bei': { bean: 4, wine: 2 } } }],
['unknown item stock unit id', { itemStocks: { 'ghost-unit': { bean: 1 } } }],
['enemy item stock unit id', { itemStocks: { 'rebel-1': { bean: 1 } } }],
['unknown item stock item id', { itemStocks: { 'liu-bei': { phantomItem: 1 } } }],
['invalid buff turns', { battleBuffs: [{ ...validState.battleBuffs[0], turns: 0 }] }],
['too many buff turns', { battleBuffs: [{ ...validState.battleBuffs[0], turns: 10 }] }],
['too long buff label', { battleBuffs: [{ ...validState.battleBuffs[0], label: 'x'.repeat(33) }] }],
['invalid buff bonus', { battleBuffs: [{ ...validState.battleBuffs[0], hitBonus: 1.5 }] }],
['too high buff bonus', { battleBuffs: [{ ...validState.battleBuffs[0], criticalBonus: 100 }] }],
['unknown buff unit id', { battleBuffs: [{ ...validState.battleBuffs[0], unitId: 'ghost-unit' }] }],
['duplicate buff unit id', { battleBuffs: [validState.battleBuffs[0], { ...validState.battleBuffs[0] }] }],
['defeated buff target', { units: patchUnit(0, { hp: 0 }), attackIntents: [], battleBuffs: [{ ...validState.battleBuffs[0], unitId: 'liu-bei' }] }],
['invalid status kind', { battleStatuses: [{ ...validState.battleStatuses[0], kind: 'poison' }] }],
['too many status turns', { battleStatuses: [{ ...validState.battleStatuses[0], turns: 10 }] }],
['too long status label', { battleStatuses: [{ ...validState.battleStatuses[0], label: 'x'.repeat(33) }] }],
['invalid status power', { battleStatuses: [{ ...validState.battleStatuses[0], power: 0 }] }],
['too high status power', { battleStatuses: [{ ...validState.battleStatuses[0], power: 100 }] }],
['unknown status unit id', { battleStatuses: [{ ...validState.battleStatuses[0], unitId: 'ghost-unit' }] }],
['duplicate status unit kind', { battleStatuses: [validState.battleStatuses[0], { ...validState.battleStatuses[0] }] }],
['defeated status target', { units: patchUnit(2, { hp: 0 }), attackIntents: [], battleStatuses: [{ ...validState.battleStatuses[0], unitId: 'rebel-1' }] }],
['invalid stats shape', { battleStats: { 'liu-bei': { damageDealt: 10 } } }],
['invalid stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], actions: 1.5 } } }],
['too high stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], damageDealt: 1000000 } } }],
['invalid sortie stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], sortieBonusDamage: -1 } } }],
['invalid strategy use stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], strategyUses: 1.5 } } }],
['invalid signature strategy use stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], signatureStrategyUses: -1 } } }],
['signature strategy uses exceed total strategy uses', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], strategyUses: 0, signatureStrategyUses: 1 } } }],
['too high sortie stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], sortieExtendedBondTriggers: 1000000 } } }],
['invalid counterplay stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], intentLineBreaks: -1 } } }],
['invalid counterplay stats precision', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], intentGuardSuccesses: 1.5 } } }],
['too high counterplay stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], intentDefeats: 1000000 } } }],
['unknown stats unit id', { battleStats: { 'ghost-unit': validState.battleStats['liu-bei'] } }],
['invalid enemy usable keys', { enemyUsableUseKeys: [1] }],
['malformed enemy usable key', { enemyUsableUseKeys: ['rebel-1:shout'] }],
['wrong battle enemy usable key', { enemyUsableUseKeys: ['other-battle:rebel-1:roar'] }],
['unknown enemy usable unit id', { enemyUsableUseKeys: ['first-battle-zhuo-commandery:ghost-unit:roar'] }],
['ally enemy usable unit id', { enemyUsableUseKeys: ['first-battle-zhuo-commandery:liu-bei:roar'] }],
['unknown enemy usable id', { enemyUsableUseKeys: ['first-battle-zhuo-commandery:rebel-1:phantom'] }],
['duplicate enemy usable key', { enemyUsableUseKeys: ['first-battle-zhuo-commandery:rebel-1:roar', 'first-battle-zhuo-commandery:rebel-1:roar'] }],
['invalid triggered events', { triggeredBattleEvents: [1] }],
['unknown triggered event', { triggeredBattleEvents: ['phantom-event'] }],
['duplicate triggered event', { triggeredBattleEvents: ['opening', 'opening'] }],
['too long triggered event', { triggeredBattleEvents: ['x'.repeat(97)] }],
['invalid pending events shape', { pendingBattleEvents: [1] }],
['unknown pending event', { pendingBattleEvents: [{ key: 'phantom-event', title: 'Unknown', lines: ['Unknown'], priority: 'normal', playCue: true }] }],
['duplicate pending event', { pendingBattleEvents: [
{ key: 'leader-wavering', title: 'First', lines: ['First'], priority: 'high', playCue: true },
{ key: 'leader-wavering', title: 'Second', lines: ['Second'], priority: 'normal', playCue: false }
] }],
['completed event cannot remain pending', { pendingBattleEvents: [{ key: 'opening', title: 'Opening', lines: ['Opening'], priority: 'critical', playCue: true }] }],
['invalid pending event priority', { pendingBattleEvents: [{ key: 'leader-wavering', title: 'Leader', lines: ['Leader'], priority: 'urgent', playCue: true }] }],
['invalid pending event cue', { pendingBattleEvents: [{ key: 'leader-wavering', title: 'Leader', lines: ['Leader'], priority: 'high', playCue: 'yes' }] }],
['empty pending event lines', { pendingBattleEvents: [{ key: 'leader-wavering', title: 'Leader', lines: [], priority: 'high', playCue: true }] }],
['presented pending event must be first', { pendingBattleEvents: [
{ key: 'leader-wavering', title: 'Leader', lines: ['Leader'], priority: 'high', playCue: true },
{ key: 'objective-village-approach', title: 'Village', lines: ['Village'], priority: 'low', playCue: true, presented: true }
] }]
];
rejectedCases.forEach(([label, patch]) => {
const candidate = { ...validState, ...patch };
assert(!isValidBattleSaveState(candidate, options), `Expected invalid battle save to be rejected: ${label}`);
});
const tacticalRejectedCases = [
['invalid tactical command shape', { tacticalCommand: 'front' }],
['invalid tactical command role', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, role: 'reserve' } }],
['invalid tactical command source', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, source: 'morale' } }],
['unknown tactical command actor', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, actorId: 'ghost-unit' } }],
['enemy tactical command actor', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, actorId: 'rebel-1' } }],
['invalid tactical command turn', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, usedTurn: 0 } }],
['future tactical command turn', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, usedTurn: 3 } }],
['fractional tactical command turn', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, usedTurn: 1.5 } }],
['invalid tactical command pending flag', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, effectPending: 'yes' } }],
['invalid tactical command lost flag', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, effectLost: 'yes' } }],
['support tactical command cannot remain pending', { tacticalCommand: { ...validSupportCommandState.tacticalCommand, effectPending: true } }],
['invalid tactical command effect value', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, effectValue: -1 } }],
['pending tactical command cannot have effect value', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, effectValue: 1 } }],
['pending tactical command cannot be lost', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, effectLost: true } }],
['front tactical command cannot clear statuses', { tacticalCommand: { ...validFlankCommandState.tacticalCommand, role: 'front', statusesCleared: 1 } }],
['lost tactical command cannot retain effect value', { tacticalCommand: { ...validFlankCommandState.tacticalCommand, effectLost: true } }],
['support tactical command heal exceeds limit', { tacticalCommand: { ...validSupportCommandState.tacticalCommand, effectValue: 7 } }],
['support tactical command statuses exceed limit', { tacticalCommand: { ...validSupportCommandState.tacticalCommand, statusesCleared: 3 } }],
['invalid tactical command cleared status count', { tacticalCommand: { ...validFrontCommandState.tacticalCommand, statusesCleared: 1.5 } }],
['pending tactical command actor defeated', { units: validFrontCommandState.units.map((unit) => (unit.id === 'guan-yu' ? { ...unit, hp: 0 } : unit)) }],
['tactical command without enough counterplay', { battleStats: validState.battleStats }]
];
tacticalRejectedCases.forEach(([label, patch]) => {
const candidate = { ...validFrontCommandState, ...patch };
assert(!isValidBattleSaveState(candidate, tacticalOptions), `Expected invalid tactical command save to be rejected: ${label}`);
});
const orderCommandRejectedCases = [
['unlocked command missing unlock turn', { sortieOrderCommandUnlockTurn: undefined }],
['zero unlock turn', { sortieOrderCommandUnlockTurn: 0 }],
['future unlock turn', { sortieOrderCommandUnlockTurn: 3 }],
['fractional unlock turn', { sortieOrderCommandUnlockTurn: 1.5 }],
['locked command retaining unlock turn', { sortieOrderCommandUnlocked: false }],
['unlocked command without allied actions', {
battleStats: {
'liu-bei': { ...validState.battleStats['liu-bei'], actions: 0 },
'rebel-1': { ...validState.battleStats['liu-bei'], actions: 3 }
}
}],
['sortie-order source without unlock', {
sortieOrderCommandUnlocked: undefined,
sortieOrderCommandUnlockTurn: undefined
}]
];
orderCommandRejectedCases.forEach(([label, patch]) => {
const candidate = { ...validUnlockedOrderCommandState, ...patch };
assert(!isValidBattleSaveState(candidate, tacticalOptions), `Expected invalid sortie-order command save to be rejected: ${label}`);
});
assert(
!isValidBattleSaveState(validUnlockedOrderCommandState, options),
'Expected sortie-order command unlock state to remain gated by allowTacticalCommand.'
);
assert(
!isValidBattleSaveState(
{
...validUnlockedOrderCommandState,
turnNumber: 3,
sortieOrderCommandUnlockTurn: 3,
tacticalCommand: { ...validUnlockedOrderCommandState.tacticalCommand, usedTurn: 2 }
},
tacticalOptions
),
'Expected a sortie-order tactical command used before its unlock turn to be rejected.'
);
assert(
!isValidBattleSaveState(
{
...validInitiativeCommandState,
battleStats: validState.battleStats
},
tacticalOptions
),
'Expected an explicit initiative command to retain the legacy counterplay threshold.'
);
const unlockedInitiativeState = {
...validTacticalBaseState,
sortieOrderCommandUnlocked: true,
sortieOrderCommandUnlockTurn: 2,
tacticalCommand: { ...validInitiativeCommandState.tacticalCommand }
};
assert(
!isValidBattleSaveState(unlockedInitiativeState, tacticalOptions) &&
!isValidBattleSaveState({
...unlockedInitiativeState,
tacticalCommand: { ...unlockedInitiativeState.tacticalCommand, source: undefined }
}, tacticalOptions),
'Expected unlocked sortie-order state to reject explicit and source-less legacy initiative commands.'
);
assert(
!isValidBattleSaveState(validState, { ...options, validUnitIds: new Set(['liu-bei', 'guan-yu', 'rebel-1', 'zhang-fei']) }),
'Expected save missing a current battle unit to be rejected.'
);
assert(
isValidBattleSaveState({ ...validState, units: patchUnit(1, { hp: 0, x: validState.units[0].x, y: validState.units[0].y }) }, options),
'Expected defeated units to tolerate overlapping saved coordinates.'
);
console.log('Verified battle save normalization and corrupted battle save rejection.');
} finally {
await server.close();
}
function createValidBattleSaveState() {
return {
version: 1,
battleId: 'first-battle-zhuo-commandery',
campaignStep: 'first-battle',
sortieOrderId: 'elite',
savedAt: '2026-07-05T00:00:00.000Z',
turnNumber: 2,
activeFaction: 'ally',
rosterTab: 'ally',
actedUnitIds: ['liu-bei'],
attackIntents: [{ attackerId: 'liu-bei', targetId: 'rebel-1' }],
battleLog: ['Liu Bei attacked.'],
units: [
{
id: 'liu-bei',
level: 2,
exp: 10,
hp: 28,
maxHp: 30,
attack: 10,
move: 4,
stats: { might: 78, intelligence: 82, leadership: 85, agility: 72, luck: 88 },
x: 1,
y: 2,
direction: 'south',
equipment: createEquipmentSet()
},
{
id: 'guan-yu',
level: 2,
exp: 5,
hp: 32,
maxHp: 34,
attack: 14,
move: 4,
stats: { might: 97, intelligence: 76, leadership: 92, agility: 73, luck: 69 },
x: 2,
y: 2,
direction: 'south',
equipment: createEquipmentSet()
},
{
id: 'rebel-1',
level: 1,
exp: 0,
hp: 20,
maxHp: 20,
attack: 8,
move: 3,
stats: { might: 52, intelligence: 34, leadership: 39, agility: 46, luck: 41 },
x: 8,
y: 4,
direction: 'west',
equipment: createEquipmentSet()
}
],
bonds: [
{
id: 'taoyuan',
unitIds: ['liu-bei', 'guan-yu'],
title: 'Oath',
level: 1,
exp: 0,
battleExp: 0,
description: 'Shared oath.'
}
],
itemStocks: { 'liu-bei': { bean: 2 } },
battleBuffs: [
{
unitId: 'liu-bei',
label: 'Focus',
turns: 2,
attackBonus: 1,
hitBonus: 10,
criticalBonus: 0
}
],
battleStatuses: [
{
unitId: 'rebel-1',
kind: 'burn',
label: 'Burn',
turns: 2,
power: 4
}
],
battleStats: {
'liu-bei': {
damageDealt: 12,
damageTaken: 3,
defeats: 1,
actions: 2,
support: 0,
strategyUses: 1,
signatureStrategyUses: 1
}
},
enemyUsableUseKeys: ['first-battle-zhuo-commandery:rebel-1:roar'],
triggeredBattleEvents: ['opening']
};
}
function createEquipmentSet() {
return {
weapon: { itemId: 'training-sword', level: 1, exp: 0 },
armor: { itemId: 'cloth-armor', level: 1, exp: 0 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
};
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}