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

235 lines
11 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']),
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.');
assert(parseBattleSaveState(JSON.stringify(validState), options)?.battleId === options.expectedBattleId, 'Expected valid JSON save to parse.');
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 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' }] }],
['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 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'] }] }],
['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 }] }],
['invalid item stock count', { itemStocks: { 'liu-bei': { bean: -1 } } }],
['unknown item stock unit id', { itemStocks: { 'ghost-unit': { bean: 1 } } }],
['unknown item stock item id', { itemStocks: { 'liu-bei': { phantomItem: 1 } } }],
['invalid buff turns', { battleBuffs: [{ ...validState.battleBuffs[0], turns: 0 }] }],
['unknown buff unit id', { battleBuffs: [{ ...validState.battleBuffs[0], unitId: 'ghost-unit' }] }],
['duplicate buff unit id', { battleBuffs: [validState.battleBuffs[0], { ...validState.battleBuffs[0] }] }],
['invalid status kind', { battleStatuses: [{ ...validState.battleStatuses[0], kind: 'poison' }] }],
['unknown status unit id', { battleStatuses: [{ ...validState.battleStatuses[0], unitId: 'ghost-unit' }] }],
['duplicate status unit kind', { battleStatuses: [validState.battleStatuses[0], { ...validState.battleStatuses[0] }] }],
['invalid stats shape', { battleStats: { 'liu-bei': { damageDealt: 10 } } }],
['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'] }],
['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}`);
});
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.'
);
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',
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);
}
}