546 lines
28 KiB
JavaScript
546 lines
28 KiB
JavaScript
import { createServer } from 'vite';
|
|
|
|
const server = await createServer({
|
|
logLevel: 'error',
|
|
server: { middlewareMode: true },
|
|
appType: 'custom'
|
|
});
|
|
|
|
try {
|
|
const { isValidBattleSaveState, normalizeBattleSaveSlot, parseBattleSaveState } = await server.ssrLoadModule(
|
|
'/src/game/state/battleSaveState.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 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 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 }) }],
|
|
['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 } } }],
|
|
['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)] }]
|
|
];
|
|
|
|
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,
|
|
x: 1,
|
|
y: 2,
|
|
direction: 'south',
|
|
equipment: createEquipmentSet()
|
|
},
|
|
{
|
|
id: 'guan-yu',
|
|
level: 2,
|
|
exp: 5,
|
|
hp: 32,
|
|
maxHp: 34,
|
|
attack: 14,
|
|
move: 4,
|
|
x: 2,
|
|
y: 2,
|
|
direction: 'south',
|
|
equipment: createEquipmentSet()
|
|
},
|
|
{
|
|
id: 'rebel-1',
|
|
level: 1,
|
|
exp: 0,
|
|
hp: 20,
|
|
maxHp: 20,
|
|
attack: 8,
|
|
move: 3,
|
|
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
|
|
}
|
|
},
|
|
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);
|
|
}
|
|
}
|