import type { BattleBond, UnitData } from '../data/scenario'; import type { CampaignStep } from './campaignState'; import type { UnitDirection } from '../data/unitAssets'; import { equipmentExpToNext, equipmentSlots, type EquipmentSlot } from '../data/battleItems'; export type BattleSaveFaction = 'ally' | 'enemy'; export type BattleSaveRosterTab = 'ally' | 'enemy'; export type BattleSaveAttackIntent = { attackerId: string; targetId: string; }; export type BattleSaveBondState = BattleBond & { battleExp: number; }; export type SavedBattleUnitState = Pick & { equipment: UnitData['equipment']; direction?: UnitDirection; }; export type BattleSaveBuffState = { unitId: string; label: string; turns: number; attackBonus: number; hitBonus: number; criticalBonus: number; }; export type BattleSaveStatusKind = 'burn' | 'confusion'; export type BattleSaveStatusState = { unitId: string; kind: BattleSaveStatusKind; label: string; turns: number; power: number; }; export type BattleSaveUnitStats = { damageDealt: number; damageTaken: number; defeats: number; actions: number; support: number; }; export type BattleSaveState = { version: 1; battleId: string; campaignStep?: CampaignStep; savedAt: string; turnNumber: number; activeFaction: BattleSaveFaction; rosterTab: BattleSaveRosterTab; actedUnitIds: string[]; attackIntents: BattleSaveAttackIntent[]; battleLog: string[]; units: SavedBattleUnitState[]; bonds: BattleSaveBondState[]; itemStocks?: Record>; battleBuffs?: BattleSaveBuffState[]; battleStatuses?: BattleSaveStatusState[]; battleStats?: Record; enemyUsableUseKeys?: string[]; triggeredBattleEvents?: string[]; }; type BattleSaveValidationOptions = { expectedBattleId?: string; mapWidth?: number; mapHeight?: number; validUnitIds?: ReadonlySet; validUsableIds?: ReadonlySet; validItemIds?: ReadonlySet; validEquipmentItemIds?: ReadonlySet; validEquipmentSlotItems?: Partial>>; validTriggeredBattleEventIds?: ReadonlySet; }; const unitDirections = new Set(['south', 'east', 'north', 'west']); const maxBattleLogEntries = 10; const maxBattleLogEntryLength = 96; const maxBattleBondEntries = 128; const maxTriggeredBattleEventLength = 96; const defaultBattleSaveArrayLimit = 128; const maxStatusKindsPerUnit = 2; export function normalizeBattleSaveSlot(slot: number, slotCount: number) { const safeSlot = Number.isFinite(slot) ? Math.floor(slot) : 1; return Math.min(Math.max(safeSlot, 1), slotCount); } export function parseBattleSaveState(raw: string | null | undefined, options: BattleSaveValidationOptions = {}) { if (!raw) { return undefined; } try { const state = JSON.parse(raw) as unknown; return isValidBattleSaveState(state, options) ? state : undefined; } catch { return undefined; } } export function isValidBattleSaveState(state: unknown, options: BattleSaveValidationOptions = {}): state is BattleSaveState { if (!isRecord(state) || state.version !== 1 || !Array.isArray(state.units)) { return false; } if (typeof state.battleId !== 'string' || state.battleId.length === 0) { return false; } if (options.expectedBattleId && state.battleId !== options.expectedBattleId) { return false; } if (!isValidIsoLikeDate(state.savedAt) || !isPositiveInteger(state.turnNumber)) { return false; } if (!isBattleFaction(state.activeFaction) || !isBattleFaction(state.rosterTab)) { return false; } if ( !isUnitIdArray(state.actedUnitIds, options) || !isBattleLog(state.battleLog) || !isAttackIntentArray(state.attackIntents, options) || !areSavedBattleUnitsValid(state.units, options) || !isBondArray(state.bonds, options) ) { return false; } if ( !isOptionalItemStockRecord(state.itemStocks, options) || !isOptionalBuffArray(state.battleBuffs, options) || !isOptionalStatusArray(state.battleStatuses, options) || !isOptionalStatsRecord(state.battleStats, options) || !isOptionalEnemyUsableUseKeyArray(state.enemyUsableUseKeys, state.battleId, options) || !isOptionalTriggeredBattleEventArray(state.triggeredBattleEvents, options) ) { return false; } return true; } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function isPositiveInteger(value: unknown) { return Number.isInteger(value) && Number(value) >= 1; } function isNonNegativeFiniteNumber(value: unknown) { return typeof value === 'number' && Number.isFinite(value) && value >= 0; } function isNonNegativeInteger(value: unknown) { return Number.isInteger(value) && Number(value) >= 0; } function isFiniteNumber(value: unknown) { return typeof value === 'number' && Number.isFinite(value); } function isValidIsoLikeDate(value: unknown) { return typeof value === 'string' && value.length > 0 && !Number.isNaN(Date.parse(value)); } function isBattleFaction(value: unknown): value is BattleSaveFaction { return value === 'ally' || value === 'enemy'; } function isBattleStatusKind(value: unknown): value is BattleSaveStatusKind { return value === 'burn' || value === 'confusion'; } function isUnitIdArray(value: unknown, options: BattleSaveValidationOptions) { return ( Array.isArray(value) && value.length <= unitReferenceLimit(options) && hasUniqueStrings(value) && value.every((entry) => typeof entry === 'string' && isKnownUnitId(entry, options)) ); } function isBattleLog(value: unknown) { 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) && typeof intent.attackerId === 'string' && typeof intent.targetId === 'string' && intent.attackerId !== intent.targetId && isKnownUnitId(intent.attackerId, options) && isKnownUnitId(intent.targetId, options) ) ); } function areSavedBattleUnitsValid(units: unknown[], options: BattleSaveValidationOptions) { const seenUnitIds = new Set(); const seenLiveTileKeys = new Set(); for (const unit of units) { if (!isSavedBattleUnitState(unit, options) || seenUnitIds.has(unit.id)) { return false; } seenUnitIds.add(unit.id); if (unit.hp > 0) { const tileKey = `${unit.x}:${unit.y}`; if (seenLiveTileKeys.has(tileKey)) { return false; } seenLiveTileKeys.add(tileKey); } } 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 || !isEquipmentSet(value.equipment, options)) { return false; } if ( !isValidCharacterProgress(value.level, value.exp) || !isNonNegativeFiniteNumber(value.hp) || !isPositiveInteger(value.maxHp) || Number(value.hp) > Number(value.maxHp) || !isFiniteNumber(value.attack) || !isNonNegativeFiniteNumber(value.move) || !Number.isInteger(value.x) || !Number.isInteger(value.y) ) { return false; } if (options.mapWidth !== undefined && (Number(value.x) < 0 || Number(value.x) >= options.mapWidth)) { return false; } if (options.mapHeight !== undefined && (Number(value.y) < 0 || Number(value.y) >= options.mapHeight)) { return false; } 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)) && isValidEquipmentProgress(level, exp) ); } function isValidCharacterProgress(level: unknown, exp: unknown) { return ( isPositiveInteger(level) && Number(level) <= 99 && Number.isInteger(exp) && Number(exp) >= 0 && (Number(level) >= 99 ? Number(exp) <= 100 : Number(exp) < 100) ); } function isValidEquipmentProgress(level: unknown, exp: unknown) { if (!Number.isInteger(level) || Number(level) < 1 || Number(level) > 9 || !Number.isInteger(exp) || Number(exp) < 0) { return false; } const next = equipmentExpToNext(Number(level)); return Number(level) >= 9 ? Number(exp) <= next : Number(exp) < next; } function isBondArray(value: unknown, options: BattleSaveValidationOptions) { return ( Array.isArray(value) && value.length <= maxBattleBondEntries && hasUniqueRecordStrings(value, 'id') && value.every( (bond) => isRecord(bond) && typeof bond.id === 'string' && bond.id.length > 0 && Array.isArray(bond.unitIds) && bond.unitIds.length === 2 && bond.unitIds.every((unitId) => typeof unitId === 'string' && isKnownUnitId(unitId, options)) && isValidBondProgress(bond.level, bond.exp) && isNonNegativeInteger(bond.battleExp) ) ); } function isValidBondProgress(level: unknown, exp: unknown) { return ( isPositiveInteger(level) && Number(level) <= 100 && Number.isInteger(exp) && Number(exp) >= 0 && (Number(level) >= 100 ? Number(exp) <= 100 : Number(exp) < 100) ); } function isOptionalItemStockRecord(value: unknown, options: BattleSaveValidationOptions) { if (value === undefined) { return true; } if (!isRecord(value)) { 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 ) ); } function isOptionalBuffArray(value: unknown, options: BattleSaveValidationOptions) { return ( value === undefined || (Array.isArray(value) && value.length <= unitReferenceLimit(options) && hasUniqueRecordStrings(value, 'unitId') && value.every( (buff) => isRecord(buff) && typeof buff.unitId === 'string' && isKnownUnitId(buff.unitId, options) && typeof buff.label === 'string' && isPositiveInteger(buff.turns) && isFiniteNumber(buff.attackBonus) && isFiniteNumber(buff.hitBonus) && isFiniteNumber(buff.criticalBonus) )) ); } function isOptionalStatusArray(value: unknown, options: BattleSaveValidationOptions) { return ( value === undefined || (Array.isArray(value) && value.length <= unitReferenceLimit(options) * maxStatusKindsPerUnit && hasUniqueStatusKeys(value) && value.every( (status) => isRecord(status) && typeof status.unitId === 'string' && isKnownUnitId(status.unitId, options) && isBattleStatusKind(status.kind) && typeof status.label === 'string' && isPositiveInteger(status.turns) && isFiniteNumber(status.power) )) ); } function isOptionalStatsRecord(value: unknown, options: BattleSaveValidationOptions) { if (value === undefined) { return true; } if (!isRecord(value)) { return false; } if (Object.keys(value).length > unitReferenceLimit(options)) { return false; } return Object.entries(value).every( ([unitId, stats]) => isKnownUnitId(unitId, options) && isRecord(stats) && isNonNegativeFiniteNumber(stats.damageDealt) && isNonNegativeFiniteNumber(stats.damageTaken) && isNonNegativeFiniteNumber(stats.defeats) && isNonNegativeFiniteNumber(stats.actions) && isNonNegativeFiniteNumber(stats.support) ); } function isOptionalEnemyUsableUseKeyArray(value: unknown, battleId: string, options: BattleSaveValidationOptions) { return ( value === undefined || (Array.isArray(value) && value.length <= enemyUsableUseKeyLimit(options) && hasUniqueStrings(value) && value.every((entry) => isEnemyUsableUseKey(entry, battleId, options))) ); } 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; } const [entryBattleId, unitId, usableId, extra] = value.split(':'); return ( extra === undefined && entryBattleId === battleId && typeof unitId === 'string' && unitId.length > 0 && typeof usableId === 'string' && usableId.length > 0 && isKnownUnitId(unitId, options) && (!options.validUsableIds || options.validUsableIds.has(usableId)) ); } 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 triggeredBattleEventLimit(options: BattleSaveValidationOptions) { return options.validTriggeredBattleEventIds?.size ?? 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; }