diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs index 388b467..b4e06e2 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -39,9 +39,13 @@ try { ['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' }] }], + ['duplicate attack intent attacker id', { attackIntents: [{ attackerId: 'liu-bei', targetId: 'rebel-1' }, { attackerId: 'liu-bei', targetId: 'rebel-1' }] }], ['invalid units array', { units: {} }], ['invalid unit hp', { units: patchUnit(0, { hp: 99, maxHp: 30 }) }], ['invalid unit x', { units: patchUnit(0, { x: 12 }) }], @@ -55,8 +59,10 @@ try { ['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] }], @@ -64,6 +70,7 @@ try { ['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] }] ]; diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index c053a67..dc91d8e 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -77,6 +77,10 @@ type BattleSaveValidationOptions = { }; const unitDirections = new Set(['south', 'east', 'north', 'west']); +const maxBattleLogEntries = 10; +const maxBattleLogEntryLength = 96; +const defaultBattleSaveArrayLimit = 128; +const maxStatusKindsPerUnit = 2; export function normalizeBattleSaveSlot(slot: number, slotCount: number) { const safeSlot = Number.isFinite(slot) ? Math.floor(slot) : 1; @@ -174,7 +178,12 @@ function isStringArray(value: unknown) { } function isUnitIdArray(value: unknown, options: BattleSaveValidationOptions) { - return Array.isArray(value) && value.every((entry) => typeof entry === 'string' && isKnownUnitId(entry, options)); + return ( + Array.isArray(value) && + value.length <= unitReferenceLimit(options) && + hasUniqueStrings(value) && + value.every((entry) => typeof entry === 'string' && isKnownUnitId(entry, options)) + ); } function isOptionalStringArray(value: unknown) { @@ -182,12 +191,18 @@ function isOptionalStringArray(value: unknown) { } function isBattleLog(value: unknown) { - return Array.isArray(value) && value.every((entry) => typeof entry === 'string'); + return ( + Array.isArray(value) && + value.length <= maxBattleLogEntries && + value.every((entry) => typeof entry === 'string' && entry.length <= maxBattleLogEntryLength) + ); } function isAttackIntentArray(value: unknown, options: BattleSaveValidationOptions) { return ( Array.isArray(value) && + value.length <= unitReferenceLimit(options) && + hasUniqueRecordStrings(value, 'attackerId') && value.every( (intent) => isRecord(intent) && @@ -277,10 +292,15 @@ function isOptionalItemStockRecord(value: unknown, options: BattleSaveValidation return false; } + if (Object.keys(value).length > unitReferenceLimit(options)) { + return false; + } + return Object.entries(value).every( ([unitId, stocks]) => isKnownUnitId(unitId, options) && isRecord(stocks) && + Object.keys(stocks).length <= itemReferenceLimit(options) && Object.entries(stocks).every( ([itemId, count]) => (!options.validItemIds || options.validItemIds.has(itemId)) && Number.isInteger(count) && Number(count) >= 0 @@ -292,6 +312,8 @@ function isOptionalBuffArray(value: unknown, options: BattleSaveValidationOption return ( value === undefined || (Array.isArray(value) && + value.length <= unitReferenceLimit(options) && + hasUniqueRecordStrings(value, 'unitId') && value.every( (buff) => isRecord(buff) && @@ -310,6 +332,8 @@ function isOptionalStatusArray(value: unknown, options: BattleSaveValidationOpti return ( value === undefined || (Array.isArray(value) && + value.length <= unitReferenceLimit(options) * maxStatusKindsPerUnit && + hasUniqueStatusKeys(value) && value.every( (status) => isRecord(status) && @@ -332,6 +356,10 @@ function isOptionalStatsRecord(value: unknown, options: BattleSaveValidationOpti return false; } + if (Object.keys(value).length > unitReferenceLimit(options)) { + return false; + } + return Object.entries(value).every( ([unitId, stats]) => isKnownUnitId(unitId, options) && @@ -345,7 +373,13 @@ function isOptionalStatsRecord(value: unknown, options: BattleSaveValidationOpti } function isOptionalEnemyUsableUseKeyArray(value: unknown, battleId: string, options: BattleSaveValidationOptions) { - return value === undefined || (Array.isArray(value) && value.every((entry) => isEnemyUsableUseKey(entry, battleId, options))); + return ( + value === undefined || + (Array.isArray(value) && + value.length <= enemyUsableUseKeyLimit(options) && + hasUniqueStrings(value) && + value.every((entry) => isEnemyUsableUseKey(entry, battleId, options))) + ); } function isEnemyUsableUseKey(value: unknown, battleId: string, options: BattleSaveValidationOptions) { @@ -369,3 +403,35 @@ function isEnemyUsableUseKey(value: unknown, battleId: string, options: BattleSa function isKnownUnitId(unitId: string, options: BattleSaveValidationOptions) { return !options.validUnitIds || options.validUnitIds.has(unitId); } + +function unitReferenceLimit(options: BattleSaveValidationOptions) { + return options.validUnitIds?.size ?? defaultBattleSaveArrayLimit; +} + +function itemReferenceLimit(options: BattleSaveValidationOptions) { + return options.validItemIds?.size ?? defaultBattleSaveArrayLimit; +} + +function enemyUsableUseKeyLimit(options: BattleSaveValidationOptions) { + if (options.validUnitIds && options.validUsableIds) { + return options.validUnitIds.size * options.validUsableIds.size; + } + + return defaultBattleSaveArrayLimit; +} + +function hasUniqueStrings(values: unknown[]) { + return new Set(values.filter((entry) => typeof entry === 'string')).size === values.length; +} + +function hasUniqueRecordStrings(values: unknown[], field: string) { + const strings = values.map((value) => (isRecord(value) && typeof value[field] === 'string' ? value[field] : undefined)); + return strings.every((value) => value !== undefined) && new Set(strings).size === strings.length; +} + +function hasUniqueStatusKeys(values: unknown[]) { + const keys = values.map((value) => + isRecord(value) && typeof value.unitId === 'string' && typeof value.kind === 'string' ? `${value.unitId}:${value.kind}` : undefined + ); + return keys.every((key) => key !== undefined) && new Set(keys).size === keys.length; +}