From e4ff46e6e0300d7626cdf21f17db6609a6c59692 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 5 Jul 2026 14:17:23 +0900 Subject: [PATCH] Reject stale battle save effects --- scripts/verify-battle-save-normalization.mjs | 2 ++ src/game/state/battleSaveState.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs index 12b4535..66283f6 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -105,6 +105,7 @@ try { ['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) }] }], @@ -112,6 +113,7 @@ try { ['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 } } }], diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index a8063c3..41e5e74 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -163,6 +163,10 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida return false; } + if (!areEffectsTargetingLiveUnits(state.battleBuffs, state.battleStatuses, units)) { + return false; + } + if ( !isOptionalItemStockRecord(state.itemStocks, options) || !isOptionalBuffArray(state.battleBuffs, options) || @@ -282,6 +286,21 @@ function areAttackIntentsTargetingLiveUnits(attackIntents: BattleSaveAttackInten return attackIntents.every((intent) => liveUnitIds.has(intent.attackerId) && liveUnitIds.has(intent.targetId)); } +function areEffectsTargetingLiveUnits( + battleBuffs: unknown, + battleStatuses: unknown, + units: SavedBattleUnitState[] +) { + const liveUnitIds = new Set(units.filter((unit) => unit.hp > 0).map((unit) => unit.id)); + const buffTargetsAreLive = + battleBuffs === undefined || + (Array.isArray(battleBuffs) && battleBuffs.every((buff) => isRecord(buff) && liveUnitIds.has(String(buff.unitId)))); + const statusTargetsAreLive = + battleStatuses === undefined || + (Array.isArray(battleStatuses) && battleStatuses.every((status) => isRecord(status) && liveUnitIds.has(String(status.unitId)))); + return buffTargetsAreLive && statusTargetsAreLive; +} + function isSavedBattleUnitState(value: unknown, options: BattleSaveValidationOptions): value is SavedBattleUnitState { if (!isRecord(value) || typeof value.id !== 'string' || value.id.length === 0 || !isEquipmentSet(value.equipment, options)) { return false;