From 288fb839d13a8e601d8e117cb38a3d717f3701b7 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 5 Jul 2026 11:46:17 +0900 Subject: [PATCH] Validate battle save equipment state --- scripts/verify-battle-save-normalization.mjs | 28 ++++++++++++++++--- src/game/scenes/BattleScene.ts | 8 ++++++ src/game/state/battleSaveState.ts | 29 +++++++++++++++++++- 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs index b4e06e2..0bc2129 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -17,7 +17,13 @@ try { 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']) + 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']) + } }; const validState = createValidBattleSaveState(); const patchUnit = (index, patch) => validState.units.map((unit, unitIndex) => (unitIndex === index ? { ...unit, ...patch } : unit)); @@ -50,6 +56,12 @@ try { ['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 } } }) }], + ['invalid equipment exp', { units: patchUnit(0, { equipment: { ...createEquipmentSet(), weapon: { itemId: 'training-sword', level: 1, exp: -1 } } }) }], ['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'] }] }], @@ -113,7 +125,7 @@ function createValidBattleSaveState() { x: 1, y: 2, direction: 'south', - equipment: {} + equipment: createEquipmentSet() }, { id: 'guan-yu', @@ -126,7 +138,7 @@ function createValidBattleSaveState() { x: 2, y: 2, direction: 'south', - equipment: {} + equipment: createEquipmentSet() }, { id: 'rebel-1', @@ -139,7 +151,7 @@ function createValidBattleSaveState() { x: 8, y: 4, direction: 'west', - equipment: {} + equipment: createEquipmentSet() } ], bonds: [ @@ -187,6 +199,14 @@ function createValidBattleSaveState() { }; } +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); diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 319687b..2a4c9b3 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -30,6 +30,7 @@ import { equipmentSlotLabels, equipmentSlots, getItem, + itemCatalog, type EquipmentSlot, type ItemDefinition } from '../data/battleItems'; @@ -11355,6 +11356,13 @@ export class BattleScene extends Phaser.Scene { Object.values(usableCatalog) .filter((usable) => usable.command === 'item') .map((usable) => usable.id) + ), + validEquipmentItemIds: new Set(Object.keys(itemCatalog)), + validEquipmentSlotItems: Object.fromEntries( + equipmentSlots.map((slot) => [ + slot, + new Set(Object.values(itemCatalog).filter((item) => item.slot === slot).map((item) => item.id)) + ]) ) }); } diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index dc91d8e..be35dbb 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -1,6 +1,7 @@ import type { BattleBond, UnitData } from '../data/scenario'; import type { CampaignStep } from './campaignState'; import type { UnitDirection } from '../data/unitAssets'; +import { equipmentSlots, type EquipmentSlot } from '../data/battleItems'; export type BattleSaveFaction = 'ally' | 'enemy'; export type BattleSaveRosterTab = 'ally' | 'enemy'; @@ -74,6 +75,8 @@ type BattleSaveValidationOptions = { validUnitIds?: ReadonlySet; validUsableIds?: ReadonlySet; validItemIds?: ReadonlySet; + validEquipmentItemIds?: ReadonlySet; + validEquipmentSlotItems?: Partial>>; }; const unitDirections = new Set(['south', 'east', 'north', 'west']); @@ -237,7 +240,7 @@ function areSavedBattleUnitsValid(units: unknown[], options: BattleSaveValidatio } function isSavedBattleUnitState(value: unknown, options: BattleSaveValidationOptions): value is SavedBattleUnitState { - if (!isRecord(value) || typeof value.id !== 'string' || value.id.length === 0 || !isRecord(value.equipment)) { + if (!isRecord(value) || typeof value.id !== 'string' || value.id.length === 0 || !isEquipmentSet(value.equipment, options)) { return false; } @@ -266,6 +269,30 @@ function isSavedBattleUnitState(value: unknown, options: BattleSaveValidationOpt return value.direction === undefined || unitDirections.has(value.direction as UnitDirection); } +function isEquipmentSet(value: unknown, options: BattleSaveValidationOptions): value is UnitData['equipment'] { + if (!isRecord(value) || Object.keys(value).length !== equipmentSlots.length) { + return false; + } + + return equipmentSlots.every((slot) => isEquipmentStateForSlot(value[slot], slot, options)); +} + +function isEquipmentStateForSlot(value: unknown, slot: EquipmentSlot, options: BattleSaveValidationOptions) { + if (!isRecord(value)) { + return false; + } + + const { itemId, level, exp } = value; + return ( + typeof itemId === 'string' && + itemId.length > 0 && + (!options.validEquipmentItemIds || options.validEquipmentItemIds.has(itemId)) && + (!options.validEquipmentSlotItems?.[slot] || options.validEquipmentSlotItems[slot].has(itemId)) && + isPositiveInteger(level) && + isNonNegativeFiniteNumber(exp) + ); +} + function isBondArray(value: unknown, options: BattleSaveValidationOptions) { return ( Array.isArray(value) &&