Files
heros_web/src/game/state/battleSaveState.ts

1486 lines
46 KiB
TypeScript

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<UnitData, 'id' | 'level' | 'exp' | 'hp' | 'maxHp' | 'attack' | 'move' | 'x' | 'y'> & {
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<string, BattleSaveCooperationStats>;
thirdCampPreparation?: BattleSaveThirdCampPreparationState;
cityEquipmentContribution?: BattleSaveCityEquipmentContributionState;
savedAt: string;
turnNumber: number;
activeFaction: BattleSaveFaction;
rosterTab: BattleSaveRosterTab;
actedUnitIds: string[];
attackIntents: BattleSaveAttackIntent[];
battleLog: string[];
units: SavedBattleUnitState[];
bonds: BattleSaveBondState[];
itemStocks?: Record<string, Record<string, number>>;
battleBuffs?: BattleSaveBuffState[];
battleStatuses?: BattleSaveStatusState[];
battleStats?: Record<string, BattleSaveUnitStats>;
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<string>;
validAllyUnitIds?: ReadonlySet<string>;
validEnemyUnitIds?: ReadonlySet<string>;
validTacticalCommandRolesByActor?: Readonly<Record<string, BattleSaveTacticalCommandRole>>;
validUsableIds?: ReadonlySet<string>;
validItemIds?: ReadonlySet<string>;
validEquipmentItemIds?: ReadonlySet<string>;
validEquipmentSlotItems?: Partial<Record<EquipmentSlot, ReadonlySet<string>>>;
validTriggeredBattleEventIds?: ReadonlySet<string>;
};
const unitDirections = new Set<UnitDirection>(['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<string, number> = {
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<string, unknown> {
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<string, unknown>,
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<string, unknown>,
options: BattleSaveValidationOptions
) {
const validBondIds = validDeployedCooperationBondIds(state, options);
const normalizedStats: Record<string, BattleSaveCooperationStats> = {};
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<string, unknown>,
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<string, unknown>,
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<string, unknown>
) {
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<string, unknown>
) {
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<string, unknown>,
options: BattleSaveValidationOptions
) {
const normalized = canonicalCityEquipmentContributionSaveState(
state.cityEquipmentContribution,
state,
options
);
if (normalized) {
state.cityEquipmentContribution = normalized;
} else {
delete state.cityEquipmentContribution;
}
}
function isOptionalCityEquipmentContributionState(
value: unknown,
state: Record<string, unknown>,
options: BattleSaveValidationOptions
) {
if (value === undefined) {
return true;
}
const normalized = canonicalCityEquipmentContributionSaveState(
value,
state,
options
);
if (!normalized || !isRecord(value)) {
return false;
}
const fields: Array<keyof BattleSaveCityEquipmentContributionState> = [
'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<string, unknown>,
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<string, unknown>,
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<string>();
const seenLiveTileKeys = new Set<string>();
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<keyof UnitData['stats']> = ['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;
}