import type { BattleBond, UnitData } from '../data/scenario'; import { isCampaignStep, normalizeCampaignSortieRecommendationSnapshot, type CampaignSortieRecommendationSnapshot, type CampaignStep } from './campaignState'; import type { UnitDirection } from '../data/unitAssets'; import { equipmentExpToNext, equipmentSlots, type EquipmentSlot } from '../data/battleItems'; import type { SortieOrderId } from '../data/sortieOrders'; import { coreSortieResonanceMinLevel } from '../data/sortieSynergy'; import { getThirdCampPreparationPriority, isThirdCampPreparationPriorityId, thirdCampPreparationInformationEnemyUnitId, thirdCampPreparationSelectionRecordId, thirdCampPreparationTargetBattleId, type ThirdCampPreparationPriorityId } from '../data/thirdCampPreparation'; import { normalizeCityEquipmentContributionSnapshot, type CityEquipmentContributionSnapshot } from '../data/cityEquipmentPreparation'; 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 & { stats?: UnitData['stats']; 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; strategyUses?: number; signatureStrategyUses?: number; sortieBonusDamage?: number; sortiePreventedDamage?: number; sortieBonusHealing?: number; sortieExtendedBondTriggers?: number; intentDefeats?: number; intentLineBreaks?: number; intentGuardSuccesses?: number; }; export type BattleSaveTacticalCommandRole = 'front' | 'flank' | 'support'; export type BattleSaveTacticalCommandSource = 'sortie-order' | 'initiative'; export type BattleSaveTacticalCommandState = { source?: BattleSaveTacticalCommandSource; role: BattleSaveTacticalCommandRole; actorId: string; usedTurn: number; effectPending: boolean; effectValue: number; statusesCleared: number; effectLost?: boolean; }; export type BattleSaveCoreResonanceStats = { attempts: number; successes: number; failures: number; additionalDamage: number; }; export type BattleSaveCooperationStats = { attempts: number; successes: number; additionalDamage: number; }; export type BattleSaveThirdCampPreparationState = { selectionRecordId: typeof thirdCampPreparationSelectionRecordId; priorityId: ThirdCampPreparationPriorityId; label: string; markedEnemyUnitId?: typeof thirdCampPreparationInformationEnemyUnitId; informationConsumed: boolean; equipmentConsumed: boolean; companionAttempts: number; companionSuccesses: number; usageCount: number; preventedDamage: number; }; export type BattleSaveCityEquipmentContributionState = CityEquipmentContributionSnapshot; export type BattleSaveEventPriority = 'critical' | 'high' | 'normal' | 'low'; export type BattleSavePendingEvent = { key: string; title: string; lines: string[]; priority: BattleSaveEventPriority; playCue: boolean; presented?: boolean; }; export type BattleSaveState = { version: 1; battleId: string; campaignStep?: CampaignStep; sortieOrderId?: SortieOrderId; sortieResonanceBondId?: string; sortieRecommendation?: CampaignSortieRecommendationSnapshot; coreResonanceStats?: BattleSaveCoreResonanceStats; cooperationStatsByBond?: Record; thirdCampPreparation?: BattleSaveThirdCampPreparationState; cityEquipmentContribution?: BattleSaveCityEquipmentContributionState; 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; sortieOrderCommandUnlocked?: boolean; sortieOrderCommandUnlockTurn?: number; tacticalCommand?: BattleSaveTacticalCommandState; enemyUsableUseKeys?: string[]; triggeredBattleEvents?: string[]; pendingBattleEvents?: BattleSavePendingEvent[]; }; export type BattleSaveValidationOptions = { expectedBattleId?: string; allowTacticalCommand?: boolean; allowLegacyTacticalCommand?: boolean; mapWidth?: number; mapHeight?: number; validUnitIds?: ReadonlySet; validAllyUnitIds?: ReadonlySet; validEnemyUnitIds?: ReadonlySet; validTacticalCommandRolesByActor?: Readonly>; 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 maxPendingBattleEventTitleLength = 96; const maxPendingBattleEventLineLength = 256; const maxPendingBattleEventLines = 12; const defaultBattleSaveArrayLimit = 128; const defaultBattleItemStockLimit = 9; const maxBattleEffectLabelLength = 32; const maxBattleEffectTurns = 9; const maxBattleBuffBonus = 99; const maxBattleStatusPower = 99; const maxBattleUnitStatValue = 999999; const maxBattleUnitHp = 9999; const maxBattleUnitAttack = 9999; const maxBattleUnitMove = 99; const maxBattleUnitAttribute = 999; const maxBattleBondBattleExp = 999999; const maxStatusKindsPerUnit = 2; const tacticalCommandCounterplayThreshold = 3; const tacticalCommandSupportHealLimit = 6; const maxBattleItemStockById: Record = { bean: 3, salve: 2, wine: 1 }; 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 normalizeBattleSaveState(state, options); } catch { return undefined; } } export function normalizeBattleSaveState( state: unknown, options: BattleSaveValidationOptions = {} ): BattleSaveState | undefined { const cloned = cloneJsonValue(state); if (!isRecord(cloned)) { return undefined; } normalizeCoreResonanceSaveFields(cloned, options); normalizeCooperationSaveFields(cloned, options); normalizeThirdCampPreparationSaveField(cloned); normalizeCityEquipmentContributionSaveField(cloned, options); const recommendation = normalizeCampaignSortieRecommendationSnapshot(cloned.sortieRecommendation, { expectedBattleId: typeof cloned.battleId === 'string' ? cloned.battleId : options.expectedBattleId, allowedUnitIds: options.validAllyUnitIds }); if (recommendation) { cloned.sortieRecommendation = recommendation; } else { delete cloned.sortieRecommendation; } return isValidBattleSaveState(cloned, options) ? cloned : 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 (state.campaignStep !== undefined && !isCampaignStep(state.campaignStep)) { return false; } if (state.sortieOrderId !== undefined && !isSortieOrderId(state.sortieOrderId)) { return false; } if (!isOptionalCoreResonanceState(state, options)) { return false; } if (!isOptionalCooperationStatsByBond(state.cooperationStatsByBond, state, options)) { return false; } if (!isOptionalThirdCampPreparationState(state.thirdCampPreparation, state)) { return false; } if (!isOptionalCityEquipmentContributionState(state.cityEquipmentContribution, state, options)) { return false; } const attackIntents = state.attackIntents; const units = state.units; if ( !isUnitIdArray(state.actedUnitIds, options) || !isBattleLog(state.battleLog) || !isAttackIntentArray(attackIntents, options) || !areSavedBattleUnitsValid(units, options) || !isBondArray(state.bonds, options) ) { return false; } if (!areAttackIntentsTargetingLiveUnits(attackIntents, state.actedUnitIds, units, options)) { return false; } if (!areEffectsTargetingLiveUnits(state.battleBuffs, state.battleStatuses, units)) { return false; } if ( !isOptionalItemStockRecord(state.itemStocks, options) || !isOptionalBuffArray(state.battleBuffs, options) || !isOptionalStatusArray(state.battleStatuses, options) || !isOptionalStatsRecord(state.battleStats, options) || !isOptionalSortieOrderCommandUnlockState( state.sortieOrderCommandUnlocked, state.sortieOrderCommandUnlockTurn, Number(state.turnNumber), state.battleStats, options ) || !isOptionalTacticalCommandState( state.tacticalCommand, Number(state.turnNumber), units, state.battleStats, state.sortieOrderCommandUnlocked === true, state.sortieOrderCommandUnlockTurn, options ) || !isOptionalEnemyUsableUseKeyArray(state.enemyUsableUseKeys, state.battleId, options) || !isOptionalTriggeredBattleEventArray(state.triggeredBattleEvents, options) || !isOptionalPendingBattleEventArray(state.pendingBattleEvents, state.triggeredBattleEvents, options) ) { return false; } return true; } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } function cloneJsonValue(value: unknown): unknown { try { const serialized = JSON.stringify(value); return serialized === undefined ? undefined : JSON.parse(serialized) as unknown; } catch { return undefined; } } function normalizeCoreResonanceSaveFields( state: Record, options: BattleSaveValidationOptions ) { const requestedBondId = typeof state.sortieResonanceBondId === 'string' ? state.sortieResonanceBondId.trim() : ''; const bonds = Array.isArray(state.bonds) ? state.bonds : []; const units = Array.isArray(state.units) ? state.units : []; const deployedUnitIds = new Set( units.filter(isRecord).map((unit) => unit.id).filter((unitId): unitId is string => typeof unitId === 'string') ); const bond = bonds.find((candidate) => ( isRecord(candidate) && candidate.id === requestedBondId && Number.isInteger(candidate.level) && Number(candidate.level) >= coreSortieResonanceMinLevel && Array.isArray(candidate.unitIds) && candidate.unitIds.length === 2 && candidate.unitIds.every((unitId) => ( typeof unitId === 'string' && deployedUnitIds.has(unitId) && (!options.validAllyUnitIds || options.validAllyUnitIds.has(unitId)) )) )); if (!requestedBondId || !bond) { delete state.sortieResonanceBondId; delete state.coreResonanceStats; return; } state.sortieResonanceBondId = requestedBondId; if (state.coreResonanceStats === undefined) { return; } const rawStats = isRecord(state.coreResonanceStats) ? state.coreResonanceStats : {}; const rawSuccesses = normalizeCoreResonanceStatValue(rawStats.successes); const rawFailures = normalizeCoreResonanceStatValue(rawStats.failures); const attempts = Math.min( maxBattleUnitStatValue, Math.max(normalizeCoreResonanceStatValue(rawStats.attempts), rawSuccesses + rawFailures) ); const successes = Math.min(rawSuccesses, attempts); state.coreResonanceStats = { attempts, successes, failures: attempts - successes, additionalDamage: normalizeCoreResonanceStatValue(rawStats.additionalDamage) } satisfies BattleSaveCoreResonanceStats; } function normalizeCoreResonanceStatValue(value: unknown) { if (typeof value !== 'number' || !Number.isFinite(value)) { return 0; } return Math.min(maxBattleUnitStatValue, Math.max(0, Math.floor(value))); } function normalizeCooperationSaveFields( state: Record, options: BattleSaveValidationOptions ) { const validBondIds = validDeployedCooperationBondIds(state, options); const normalizedStats: Record = {}; for (const [bondId, rawValue] of Object.entries( isRecord(state.cooperationStatsByBond) ? state.cooperationStatsByBond : {} ).sort(([leftBondId], [rightBondId]) => leftBondId.localeCompare(rightBondId))) { if (Object.keys(normalizedStats).length >= maxBattleBondEntries) { break; } const normalizedBondId = bondId.trim(); if ( normalizedBondId !== bondId || !validBondIds.has(normalizedBondId) || !isRecord(rawValue) ) { continue; } const attempts = normalizeCoreResonanceStatValue(rawValue.attempts); if (attempts <= 0) { continue; } const successes = Math.min( attempts, normalizeCoreResonanceStatValue(rawValue.successes) ); normalizedStats[normalizedBondId] = { attempts, successes, additionalDamage: successes > 0 ? normalizeCoreResonanceStatValue(rawValue.additionalDamage) : 0 }; } if ( Object.keys(normalizedStats).length === 0 && typeof state.sortieResonanceBondId === 'string' && validBondIds.has(state.sortieResonanceBondId) && isRecord(state.coreResonanceStats) ) { const attempts = normalizeCoreResonanceStatValue(state.coreResonanceStats.attempts); if (attempts > 0) { const successes = Math.min( attempts, normalizeCoreResonanceStatValue(state.coreResonanceStats.successes) ); normalizedStats[state.sortieResonanceBondId] = { attempts, successes, additionalDamage: successes > 0 ? normalizeCoreResonanceStatValue(state.coreResonanceStats.additionalDamage) : 0 }; } } if (Object.keys(normalizedStats).length > 0) { state.cooperationStatsByBond = normalizedStats; } else { delete state.cooperationStatsByBond; } } function validDeployedCooperationBondIds( state: Record, options: BattleSaveValidationOptions ) { const deployedUnitIds = new Set( (Array.isArray(state.units) ? state.units : []) .filter(isRecord) .map((unit) => unit.id) .filter((unitId): unitId is string => typeof unitId === 'string') ); return new Set( (Array.isArray(state.bonds) ? state.bonds : []) .filter(isRecord) .filter((bond) => ( typeof bond.id === 'string' && bond.id.trim() === bond.id && bond.id.length > 0 && Array.isArray(bond.unitIds) && bond.unitIds.length === 2 && bond.unitIds[0] !== bond.unitIds[1] && bond.unitIds.every((unitId) => ( typeof unitId === 'string' && deployedUnitIds.has(unitId) && (!options.validAllyUnitIds || options.validAllyUnitIds.has(unitId)) )) )) .map((bond) => bond.id as string) ); } function isOptionalCooperationStatsByBond( value: unknown, state: Record, options: BattleSaveValidationOptions ) { if (value === undefined) { return true; } if (!isRecord(value)) { return false; } const entries = Object.entries(value); if (entries.length > maxBattleBondEntries) { return false; } const validBondIds = validDeployedCooperationBondIds(state, options); return entries.every(([bondId, stats]) => ( bondId.trim() === bondId && validBondIds.has(bondId) && isRecord(stats) && isPositiveInteger(stats.attempts) && isBattleUnitStatValue(stats.attempts) && isBattleUnitStatValue(stats.successes) && isBattleUnitStatValue(stats.additionalDamage) && Number(stats.successes) <= Number(stats.attempts) && (Number(stats.successes) > 0 || Number(stats.additionalDamage) === 0) )); } function normalizeThirdCampPreparationSaveField( state: Record ) { if ( state.battleId !== thirdCampPreparationTargetBattleId || !isRecord(state.thirdCampPreparation) ) { delete state.thirdCampPreparation; return; } const raw = state.thirdCampPreparation; const priorityId = isThirdCampPreparationPriorityId(raw.priorityId) ? raw.priorityId : undefined; const priority = priorityId ? getThirdCampPreparationPriority(priorityId) : undefined; if ( !priorityId || !priority || raw.selectionRecordId !== thirdCampPreparationSelectionRecordId ) { delete state.thirdCampPreparation; return; } const informationConsumed = priorityId === 'information' && raw.informationConsumed === true; const equipmentConsumed = priorityId === 'equipment' && raw.equipmentConsumed === true; const companionAttempts = priorityId === 'companion' ? normalizeCoreResonanceStatValue(raw.companionAttempts) : 0; const companionSuccesses = priorityId === 'companion' ? Math.min( companionAttempts, normalizeCoreResonanceStatValue(raw.companionSuccesses) ) : 0; const usageCount = priorityId === 'information' ? Number(informationConsumed) : priorityId === 'equipment' ? Number(equipmentConsumed) : companionAttempts; const preventedDamage = priorityId === 'equipment' && equipmentConsumed ? normalizeCoreResonanceStatValue(raw.preventedDamage) : 0; state.thirdCampPreparation = { selectionRecordId: thirdCampPreparationSelectionRecordId, priorityId, label: priority.label, ...(priorityId === 'information' ? { markedEnemyUnitId: thirdCampPreparationInformationEnemyUnitId } : {}), informationConsumed, equipmentConsumed, companionAttempts, companionSuccesses, usageCount, preventedDamage } satisfies BattleSaveThirdCampPreparationState; } function isOptionalThirdCampPreparationState( value: unknown, state: Record ) { if (value === undefined) { return true; } if ( state.battleId !== thirdCampPreparationTargetBattleId || !isRecord(value) || value.selectionRecordId !== thirdCampPreparationSelectionRecordId || !isThirdCampPreparationPriorityId(value.priorityId) ) { return false; } const priority = getThirdCampPreparationPriority(value.priorityId); if ( !priority || value.label !== priority.label || typeof value.informationConsumed !== 'boolean' || typeof value.equipmentConsumed !== 'boolean' || !isBattleUnitStatValue(value.companionAttempts) || !isBattleUnitStatValue(value.companionSuccesses) || !isBattleUnitStatValue(value.usageCount) || !isBattleUnitStatValue(value.preventedDamage) || Number(value.companionSuccesses) > Number(value.companionAttempts) ) { return false; } if (value.priorityId === 'information') { return ( value.markedEnemyUnitId === thirdCampPreparationInformationEnemyUnitId && value.equipmentConsumed === false && Number(value.companionAttempts) === 0 && Number(value.companionSuccesses) === 0 && Number(value.usageCount) === Number(value.informationConsumed) && Number(value.preventedDamage) === 0 ); } if (value.priorityId === 'equipment') { return ( value.markedEnemyUnitId === undefined && value.informationConsumed === false && Number(value.companionAttempts) === 0 && Number(value.companionSuccesses) === 0 && Number(value.usageCount) === Number(value.equipmentConsumed) && ( value.equipmentConsumed === true || Number(value.preventedDamage) === 0 ) ); } return ( value.markedEnemyUnitId === undefined && value.informationConsumed === false && value.equipmentConsumed === false && Number(value.usageCount) === Number(value.companionAttempts) && Number(value.preventedDamage) === 0 ); } function normalizeCityEquipmentContributionSaveField( state: Record, options: BattleSaveValidationOptions ) { const normalized = canonicalCityEquipmentContributionSaveState( state.cityEquipmentContribution, state, options ); if (normalized) { state.cityEquipmentContribution = normalized; } else { delete state.cityEquipmentContribution; } } function isOptionalCityEquipmentContributionState( value: unknown, state: Record, options: BattleSaveValidationOptions ) { if (value === undefined) { return true; } const normalized = canonicalCityEquipmentContributionSaveState( value, state, options ); if (!normalized || !isRecord(value)) { return false; } const fields: Array = [ 'version', 'selectionRecordId', 'cityStayId', 'sourceBattleId', 'targetBattleId', 'offerId', 'itemId', 'slot', 'price', 'purchaseNumber', 'unitId', 'previousItemId', 'deployed', 'offensiveActions', 'defensiveHits', 'bonusDamage', 'preventedDamage' ]; return ( Object.keys(value).length === fields.length && fields.every((field) => value[field] === normalized[field]) ); } function canonicalCityEquipmentContributionSaveState( value: unknown, state: Record, options: BattleSaveValidationOptions ): BattleSaveCityEquipmentContributionState | undefined { const battleId = typeof state.battleId === 'string' ? state.battleId : undefined; const normalized = normalizeCityEquipmentContributionSnapshot(value, battleId); if ( !battleId || !normalized || !normalized.deployed || normalized.targetBattleId !== battleId || !Array.isArray(state.units) ) { return undefined; } const unit = state.units.find( (candidate) => isRecord(candidate) && candidate.id === normalized.unitId ); const equipment = unit && isRecord(unit.equipment) ? unit.equipment : undefined; const equippedState = equipment && isRecord(equipment[normalized.slot]) ? equipment[normalized.slot] : undefined; if ( !unit || !equippedState || equippedState.itemId !== normalized.itemId || (options.validUnitIds && !options.validUnitIds.has(normalized.unitId)) || (options.validAllyUnitIds && !options.validAllyUnitIds.has(normalized.unitId)) || (options.validEquipmentItemIds && (!options.validEquipmentItemIds.has(normalized.itemId) || !options.validEquipmentItemIds.has( normalized.previousItemId ))) || (options.validEquipmentSlotItems?.[normalized.slot] && (!options.validEquipmentSlotItems[normalized.slot]?.has( normalized.itemId ) || !options.validEquipmentSlotItems[normalized.slot]?.has( normalized.previousItemId ))) ) { return undefined; } return { ...normalized, bonusDamage: normalized.offensiveActions > 0 ? normalized.bonusDamage : 0, preventedDamage: normalized.defensiveHits > 0 ? normalized.preventedDamage : 0 }; } function isOptionalCoreResonanceState( state: Record, options: BattleSaveValidationOptions ) { const bondId = state.sortieResonanceBondId; const stats = state.coreResonanceStats; if (bondId === undefined) { return stats === undefined; } if (typeof bondId !== 'string' || bondId.length === 0 || !Array.isArray(state.bonds) || !Array.isArray(state.units)) { return false; } const deployedUnitIds = new Set( state.units.filter(isRecord).map((unit) => unit.id).filter((unitId): unitId is string => typeof unitId === 'string') ); const bond = state.bonds.find((candidate) => ( isRecord(candidate) && candidate.id === bondId && Number.isInteger(candidate.level) && Number(candidate.level) >= coreSortieResonanceMinLevel && Array.isArray(candidate.unitIds) && candidate.unitIds.length === 2 && candidate.unitIds.every((unitId) => ( typeof unitId === 'string' && deployedUnitIds.has(unitId) && (!options.validAllyUnitIds || options.validAllyUnitIds.has(unitId)) )) )); if (!bond) { return false; } if (stats === undefined) { return true; } return ( isRecord(stats) && isBattleUnitStatValue(stats.attempts) && isBattleUnitStatValue(stats.successes) && isBattleUnitStatValue(stats.failures) && isBattleUnitStatValue(stats.additionalDamage) && Number(stats.attempts) === Number(stats.successes) + Number(stats.failures) ); } function isPositiveInteger(value: unknown) { return Number.isInteger(value) && Number(value) >= 1; } function isPositiveIntegerAtMost(value: unknown, max: number) { return isPositiveInteger(value) && Number(value) <= max; } 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 isIntegerInRange(value: unknown, min: number, max: number) { return Number.isInteger(value) && Number(value) >= min && Number(value) <= max; } 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 isTacticalCommandRole(value: unknown): value is BattleSaveTacticalCommandRole { return value === 'front' || value === 'flank' || value === 'support'; } function isTacticalCommandSource(value: unknown): value is BattleSaveTacticalCommandSource { return value === 'sortie-order' || value === 'initiative'; } function isSortieOrderId(value: unknown): value is SortieOrderId { return value === 'elite' || value === 'mobile' || value === 'siege'; } function isUnitIdArray(value: unknown, options: BattleSaveValidationOptions): value is string[] { 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): value is BattleSaveAttackIntent[] { 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): units is SavedBattleUnitState[] { 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 areAttackIntentsTargetingLiveUnits( attackIntents: BattleSaveAttackIntent[], actedUnitIds: string[], units: SavedBattleUnitState[], options: BattleSaveValidationOptions ) { const liveUnitIds = new Set(units.filter((unit) => unit.hp > 0).map((unit) => unit.id)); const actedUnitIdSet = new Set(actedUnitIds); return attackIntents.every( (intent) => liveUnitIds.has(intent.attackerId) && liveUnitIds.has(intent.targetId) && actedUnitIdSet.has(intent.attackerId) && (!options.validAllyUnitIds || options.validAllyUnitIds.has(intent.attackerId)) && (!options.validEnemyUnitIds || options.validEnemyUnitIds.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; } if ( !isValidCharacterProgress(value.level, value.exp) || !isIntegerInRange(value.hp, 0, maxBattleUnitHp) || !isIntegerInRange(value.maxHp, 1, maxBattleUnitHp) || Number(value.hp) > Number(value.maxHp) || !isIntegerInRange(value.attack, 0, maxBattleUnitAttack) || !isIntegerInRange(value.move, 0, maxBattleUnitMove) || !isOptionalUnitAttributes(value.stats) || !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 isOptionalUnitAttributes(value: unknown): value is UnitData['stats'] | undefined { if (value === undefined) { return true; } if (!isRecord(value)) { return false; } const keys: Array = ['might', 'intelligence', 'leadership', 'agility', 'luck']; return ( Object.keys(value).length === keys.length && keys.every((key) => isIntegerInRange(value[key], 0, maxBattleUnitAttribute)) ); } 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[0] !== bond.unitIds[1] && bond.unitIds.every((unitId) => typeof unitId === 'string' && isKnownUnitId(unitId, options)) && isValidBondProgress(bond.level, bond.exp) && isIntegerInRange(bond.battleExp, 0, maxBattleBondBattleExp) ) ); } 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) && (!options.validAllyUnitIds || options.validAllyUnitIds.has(unitId)) && isRecord(stocks) && Object.keys(stocks).length <= itemReferenceLimit(options) && Object.entries(stocks).every( ([itemId, count]) => (!options.validItemIds || options.validItemIds.has(itemId)) && isValidItemStockCount(itemId, count) ) ); } function isValidItemStockCount(itemId: string, count: unknown) { return Number.isInteger(count) && Number(count) >= 0 && Number(count) <= (maxBattleItemStockById[itemId] ?? defaultBattleItemStockLimit); } function isValidEffectLabel(value: unknown) { return typeof value === 'string' && value.length > 0 && value.length <= maxBattleEffectLabelLength; } function isBattleUnitStatValue(value: unknown) { return isIntegerInRange(value, 0, maxBattleUnitStatValue); } 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) && isValidEffectLabel(buff.label) && isPositiveIntegerAtMost(buff.turns, maxBattleEffectTurns) && isIntegerInRange(buff.attackBonus, 0, maxBattleBuffBonus) && isIntegerInRange(buff.hitBonus, 0, maxBattleBuffBonus) && isIntegerInRange(buff.criticalBonus, 0, maxBattleBuffBonus) )) ); } 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) && isValidEffectLabel(status.label) && isPositiveIntegerAtMost(status.turns, maxBattleEffectTurns) && isPositiveIntegerAtMost(status.power, maxBattleStatusPower) )) ); } 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]) => { if (!isKnownUnitId(unitId, options) || !isRecord(stats)) { return false; } const strategyUses = stats.strategyUses === undefined ? 0 : stats.strategyUses; const signatureStrategyUses = stats.signatureStrategyUses === undefined ? 0 : stats.signatureStrategyUses; return ( isBattleUnitStatValue(stats.damageDealt) && isBattleUnitStatValue(stats.damageTaken) && isBattleUnitStatValue(stats.defeats) && isBattleUnitStatValue(stats.actions) && isBattleUnitStatValue(stats.support) && isOptionalBattleUnitStatValue(stats.strategyUses) && isOptionalBattleUnitStatValue(stats.signatureStrategyUses) && isOptionalBattleUnitStatValue(stats.sortieBonusDamage) && isOptionalBattleUnitStatValue(stats.sortiePreventedDamage) && isOptionalBattleUnitStatValue(stats.sortieBonusHealing) && isOptionalBattleUnitStatValue(stats.sortieExtendedBondTriggers) && isOptionalBattleUnitStatValue(stats.intentDefeats) && isOptionalBattleUnitStatValue(stats.intentLineBreaks) && isOptionalBattleUnitStatValue(stats.intentGuardSuccesses) && typeof strategyUses === 'number' && typeof signatureStrategyUses === 'number' && signatureStrategyUses <= strategyUses ); }); } function isOptionalBattleUnitStatValue(value: unknown) { return value === undefined || isBattleUnitStatValue(value); } function isOptionalSortieOrderCommandUnlockState( unlocked: unknown, unlockTurn: unknown, turnNumber: number, battleStats: unknown, options: BattleSaveValidationOptions ) { if (unlocked === undefined && unlockTurn === undefined) { return true; } if (!options.allowTacticalCommand || typeof unlocked !== 'boolean') { return false; } if (!unlocked) { return unlockTurn === undefined; } return ( isIntegerInRange(unlockTurn, 1, turnNumber) && alliedActionTotal(battleStats, options) >= 1 ); } function isOptionalTacticalCommandState( value: unknown, turnNumber: number, units: unknown[], battleStats: unknown, sortieOrderCommandUnlocked: boolean, sortieOrderCommandUnlockTurn: unknown, options: BattleSaveValidationOptions ) { if (value === undefined) { return true; } if (!options.allowTacticalCommand) { return false; } if ( !isRecord(value) || (value.source !== undefined && !isTacticalCommandSource(value.source)) || !isTacticalCommandRole(value.role) || typeof value.actorId !== 'string' || !isKnownUnitId(value.actorId, options) || (options.validAllyUnitIds && !options.validAllyUnitIds.has(value.actorId)) || (options.validTacticalCommandRolesByActor && options.validTacticalCommandRolesByActor[value.actorId] !== value.role) || !isIntegerInRange(value.usedTurn, 1, turnNumber) || typeof value.effectPending !== 'boolean' || !isBattleUnitStatValue(value.effectValue) || !isBattleUnitStatValue(value.statusesCleared) || (value.effectLost !== undefined && typeof value.effectLost !== 'boolean') ) { return false; } if (value.source === 'sortie-order') { if ( !sortieOrderCommandUnlocked || !isIntegerInRange(sortieOrderCommandUnlockTurn, 1, Number(value.usedTurn)) ) { return false; } } else { if ( !options.allowLegacyTacticalCommand || sortieOrderCommandUnlocked || tacticalCommandCounterplayTotal(battleStats, options) < tacticalCommandCounterplayThreshold ) { return false; } } if (value.role === 'support') { return ( !value.effectPending && !value.effectLost && Number(value.effectValue) <= tacticalCommandSupportHealLimit && Number(value.statusesCleared) <= maxStatusKindsPerUnit ); } if (Number(value.statusesCleared) !== 0) { return false; } if (value.effectPending && (Number(value.effectValue) !== 0 || value.effectLost)) { return false; } if (value.effectLost && Number(value.effectValue) !== 0) { return false; } if (!value.effectPending) { return true; } return units.some((unit) => isRecord(unit) && unit.id === value.actorId && Number(unit.hp) > 0); } function tacticalCommandCounterplayTotal(value: unknown, options: BattleSaveValidationOptions) { if (!isRecord(value)) { return 0; } return Object.entries(value).reduce((total, [unitId, stats]) => { if ((options.validAllyUnitIds && !options.validAllyUnitIds.has(unitId)) || !isRecord(stats)) { return total; } return ( total + Number(stats.intentDefeats ?? 0) + Number(stats.intentLineBreaks ?? 0) + Number(stats.intentGuardSuccesses ?? 0) ); }, 0); } function alliedActionTotal(value: unknown, options: BattleSaveValidationOptions) { if (!isRecord(value)) { return 0; } return Object.entries(value).reduce((total, [unitId, stats]) => { if ((options.validAllyUnitIds && !options.validAllyUnitIds.has(unitId)) || !isRecord(stats)) { return total; } return total + Number(stats.actions ?? 0); }, 0); } 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 isOptionalPendingBattleEventArray( value: unknown, triggeredEvents: unknown, options: BattleSaveValidationOptions ) { if (value === undefined) { return true; } if ( !Array.isArray(value) || value.length > triggeredBattleEventLimit(options) || !hasUniqueRecordStrings(value, 'key') ) { return false; } const presentedIndices = value .map((event, index) => (isRecord(event) && event.presented === true ? index : -1)) .filter((index) => index >= 0); if (presentedIndices.length > 1 || (presentedIndices.length === 1 && presentedIndices[0] !== 0)) { return false; } const completedKeys = new Set(Array.isArray(triggeredEvents) ? triggeredEvents : []); return value.every((event) => ( isRecord(event) && typeof event.key === 'string' && event.key.length > 0 && event.key.length <= maxTriggeredBattleEventLength && !completedKeys.has(event.key) && (!options.validTriggeredBattleEventIds || options.validTriggeredBattleEventIds.has(event.key)) && typeof event.title === 'string' && event.title.trim().length > 0 && event.title.length <= maxPendingBattleEventTitleLength && Array.isArray(event.lines) && event.lines.length > 0 && event.lines.length <= maxPendingBattleEventLines && event.lines.every((line) => ( typeof line === 'string' && line.trim().length > 0 && line.length <= maxPendingBattleEventLineLength )) && isBattleSaveEventPriority(event.priority) && typeof event.playCue === 'boolean' && (event.presented === undefined || typeof event.presented === 'boolean') )); } function isBattleSaveEventPriority(value: unknown): value is BattleSaveEventPriority { return value === 'critical' || value === 'high' || value === 'normal' || value === 'low'; } 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.validEnemyUnitIds || options.validEnemyUnitIds.has(unitId)) && (!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; }