From 64efc3d488dfc41c9fd97715060fa060cb10162c Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 5 Jul 2026 11:22:55 +0900 Subject: [PATCH] Validate battle save unit roster --- scripts/verify-battle-save-normalization.mjs | 10 +++++++- src/game/scenes/BattleScene.ts | 3 ++- src/game/state/battleSaveState.ts | 27 ++++++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs index 6d25a52..bf79005 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -14,7 +14,8 @@ try { const options = { expectedBattleId: 'first-battle-zhuo-commandery', mapWidth: 12, - mapHeight: 8 + mapHeight: 8, + validUnitIds: new Set(['liu-bei']) }; const validState = createValidBattleSaveState(); @@ -40,6 +41,8 @@ try { ['invalid unit hp', { units: [{ ...validState.units[0], hp: 99, maxHp: 30 }] }], ['invalid unit x', { units: [{ ...validState.units[0], x: 12 }] }], ['invalid unit direction', { units: [{ ...validState.units[0], direction: 'down' }] }], + ['unknown unit id', { units: [{ ...validState.units[0], id: 'ghost-unit' }] }], + ['duplicate unit id', { units: [{ ...validState.units[0] }, { ...validState.units[0], x: 2 }] }], ['invalid bonds', { bonds: [{ ...validState.bonds[0], unitIds: ['liu-bei'] }] }], ['invalid item stock count', { itemStocks: { 'liu-bei': { bean: -1 } } }], ['invalid buff turns', { battleBuffs: [{ ...validState.battleBuffs[0], turns: 0 }] }], @@ -54,6 +57,11 @@ try { assert(!isValidBattleSaveState(candidate, options), `Expected invalid battle save to be rejected: ${label}`); }); + assert( + !isValidBattleSaveState(validState, { ...options, validUnitIds: new Set(['liu-bei', 'guan-yu']) }), + '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(); diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index fe64ef8..217e1a4 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -11348,7 +11348,8 @@ export class BattleScene extends Phaser.Scene { return parseBattleSaveState(raw, { expectedBattleId: battleScenario.id, mapWidth: battleMap.width, - mapHeight: battleMap.height + mapHeight: battleMap.height, + validUnitIds: new Set(battleUnits.map((unit) => unit.id)) }); } diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index 378dc11..9db9b11 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -71,6 +71,7 @@ type BattleSaveValidationOptions = { expectedBattleId?: string; mapWidth?: number; mapHeight?: number; + validUnitIds?: ReadonlySet; }; const unitDirections = new Set(['south', 'east', 'north', 'west']); @@ -118,7 +119,7 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida !isStringArray(state.actedUnitIds) || !isBattleLog(state.battleLog) || !isAttackIntentArray(state.attackIntents) || - !state.units.every((unit) => isSavedBattleUnitState(unit, options)) || + !areSavedBattleUnitsValid(state.units, options) || !isBondArray(state.bonds) ) { return false; @@ -185,7 +186,29 @@ function isAttackIntentArray(value: unknown) { ); } -function isSavedBattleUnitState(value: unknown, options: BattleSaveValidationOptions) { +function areSavedBattleUnitsValid(units: unknown[], options: BattleSaveValidationOptions) { + const seenUnitIds = new Set(); + + for (const unit of units) { + if (!isSavedBattleUnitState(unit, options) || seenUnitIds.has(unit.id)) { + return false; + } + + seenUnitIds.add(unit.id); + } + + if (!options.validUnitIds) { + return true; + } + + if (seenUnitIds.size !== options.validUnitIds.size) { + return false; + } + + return [...seenUnitIds].every((unitId) => options.validUnitIds?.has(unitId)); +} + +function isSavedBattleUnitState(value: unknown, options: BattleSaveValidationOptions): value is SavedBattleUnitState { if (!isRecord(value) || typeof value.id !== 'string' || value.id.length === 0 || !isRecord(value.equipment)) { return false; }