983 lines
32 KiB
TypeScript
983 lines
32 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';
|
|
|
|
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 BattleSaveState = {
|
|
version: 1;
|
|
battleId: string;
|
|
campaignStep?: CampaignStep;
|
|
sortieOrderId?: SortieOrderId;
|
|
sortieResonanceBondId?: string;
|
|
sortieRecommendation?: CampaignSortieRecommendationSnapshot;
|
|
coreResonanceStats?: BattleSaveCoreResonanceStats;
|
|
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[];
|
|
};
|
|
|
|
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 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);
|
|
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;
|
|
}
|
|
|
|
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)
|
|
) {
|
|
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 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 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;
|
|
}
|