import type { BattleBond, UnitData } from '../data/scenario'; import type { CampaignStep } from './campaignState'; import type { UnitDirection } from '../data/unitAssets'; 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; }; const unitDirections = new Set(['south', 'east', 'north', 'west']); 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) || !isOptionalStringArray(state.triggeredBattleEvents) ) { 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 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 isStringArray(value: unknown) { return Array.isArray(value) && value.every((entry) => typeof entry === 'string'); } function isUnitIdArray(value: unknown, options: BattleSaveValidationOptions) { return Array.isArray(value) && value.every((entry) => typeof entry === 'string' && isKnownUnitId(entry, options)); } function isOptionalStringArray(value: unknown) { return value === undefined || isStringArray(value); } function isBattleLog(value: unknown) { return Array.isArray(value) && value.every((entry) => typeof entry === 'string'); } function isAttackIntentArray(value: unknown, options: BattleSaveValidationOptions) { return ( Array.isArray(value) && value.every( (intent) => isRecord(intent) && typeof intent.attackerId === 'string' && typeof intent.targetId === 'string' && isKnownUnitId(intent.attackerId, options) && isKnownUnitId(intent.targetId, options) ) ); } 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; } if ( !isPositiveInteger(value.level) || !isNonNegativeFiniteNumber(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 isBondArray(value: unknown, options: BattleSaveValidationOptions) { return ( Array.isArray(value) && value.every( (bond) => isRecord(bond) && typeof bond.id === 'string' && Array.isArray(bond.unitIds) && bond.unitIds.length === 2 && bond.unitIds.every((unitId) => typeof unitId === 'string' && isKnownUnitId(unitId, options)) && isPositiveInteger(bond.level) && isNonNegativeFiniteNumber(bond.exp) && isNonNegativeFiniteNumber(bond.battleExp) ) ); } function isOptionalItemStockRecord(value: unknown, options: BattleSaveValidationOptions) { if (value === undefined) { return true; } if (!isRecord(value)) { return false; } return Object.entries(value).every( ([unitId, stocks]) => isKnownUnitId(unitId, options) && isRecord(stocks) && 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.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.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; } 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.every((entry) => isEnemyUsableUseKey(entry, battleId, options))); } 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); }