From c02a29e9cc79ea6a4f7a2a23a54bc97aca638204 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 5 Jul 2026 11:50:56 +0900 Subject: [PATCH] Validate battle save event keys --- scripts/verify-battle-save-normalization.mjs | 10 ++++-- src/game/scenes/BattleScene.ts | 14 ++++++++- src/game/state/battleSaveState.ts | 32 ++++++++++++++------ 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs index 0bc2129..9d873c0 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -23,7 +23,8 @@ try { 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)); @@ -83,7 +84,10 @@ try { ['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] }] + ['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]) => { @@ -195,7 +199,7 @@ function createValidBattleSaveState() { } }, enemyUsableUseKeys: ['first-battle-zhuo-commandery:rebel-1:roar'], - triggeredBattleEvents: ['first-objective-near'] + triggeredBattleEvents: ['opening'] }; } diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 2a4c9b3..44b4c24 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -11363,10 +11363,22 @@ export class BattleScene extends Phaser.Scene { slot, new Set(Object.values(itemCatalog).filter((item) => item.slot === slot).map((item) => item.id)) ]) - ) + ), + validTriggeredBattleEventIds: this.validTriggeredBattleEventIds() }); } + private validTriggeredBattleEventIds() { + const eventIds = new Set(['opening']); + Object.keys(battleScenario.tacticalGuide?.events ?? {}).forEach((eventId) => eventIds.add(eventId)); + battleScenario.objectives.forEach((objective) => { + eventIds.add(this.objectiveEventKey(objective.id, 'approach')); + eventIds.add(this.objectiveEventKey(objective.id, 'achieved')); + eventIds.add(this.objectiveEventKey(objective.id, 'failed')); + }); + return eventIds; + } + private saveBattleState(slot = 1) { try { const state = this.createBattleSaveState(); diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index be35dbb..b258ec2 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -77,11 +77,13 @@ type BattleSaveValidationOptions = { validItemIds?: ReadonlySet; validEquipmentItemIds?: ReadonlySet; validEquipmentSlotItems?: Partial>>; + validTriggeredBattleEventIds?: ReadonlySet; }; const unitDirections = new Set(['south', 'east', 'north', 'west']); const maxBattleLogEntries = 10; const maxBattleLogEntryLength = 96; +const maxTriggeredBattleEventLength = 96; const defaultBattleSaveArrayLimit = 128; const maxStatusKindsPerUnit = 2; @@ -140,7 +142,7 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida !isOptionalStatusArray(state.battleStatuses, options) || !isOptionalStatsRecord(state.battleStats, options) || !isOptionalEnemyUsableUseKeyArray(state.enemyUsableUseKeys, state.battleId, options) || - !isOptionalStringArray(state.triggeredBattleEvents) + !isOptionalTriggeredBattleEventArray(state.triggeredBattleEvents, options) ) { return false; } @@ -176,10 +178,6 @@ function isBattleStatusKind(value: unknown): value is BattleSaveStatusKind { return value === 'burn' || value === 'confusion'; } -function isStringArray(value: unknown) { - return Array.isArray(value) && value.every((entry) => typeof entry === 'string'); -} - function isUnitIdArray(value: unknown, options: BattleSaveValidationOptions) { return ( Array.isArray(value) && @@ -189,10 +187,6 @@ function isUnitIdArray(value: unknown, options: BattleSaveValidationOptions) { ); } -function isOptionalStringArray(value: unknown) { - return value === undefined || isStringArray(value); -} - function isBattleLog(value: unknown) { return ( Array.isArray(value) && @@ -409,6 +403,22 @@ function isOptionalEnemyUsableUseKeyArray(value: unknown, battleId: string, opti ); } +function isOptionalTriggeredBattleEventArray(value: unknown, options: BattleSaveValidationOptions) { + return ( + value === undefined || + (Array.isArray(value) && + value.length <= triggeredBattleEventLimit(options) && + hasUniqueStrings(value) && + value.every( + (eventId) => + typeof eventId === 'string' && + eventId.length > 0 && + eventId.length <= maxTriggeredBattleEventLength && + (!options.validTriggeredBattleEventIds || options.validTriggeredBattleEventIds.has(eventId)) + )) + ); +} + function isEnemyUsableUseKey(value: unknown, battleId: string, options: BattleSaveValidationOptions) { if (typeof value !== 'string') { return false; @@ -447,6 +457,10 @@ function enemyUsableUseKeyLimit(options: BattleSaveValidationOptions) { return defaultBattleSaveArrayLimit; } +function triggeredBattleEventLimit(options: BattleSaveValidationOptions) { + return options.validTriggeredBattleEventIds?.size ?? defaultBattleSaveArrayLimit; +} + function hasUniqueStrings(values: unknown[]) { return new Set(values.filter((entry) => typeof entry === 'string')).size === values.length; }