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

372 lines
11 KiB
TypeScript

import type { BattleBond, UnitData } from '../data/scenario';
import type { CampaignStep } from './campaignState';
import type { UnitDirection } from '../data/unitAssets';
export type BattleSaveFaction = 'ally' | 'enemy';
export type BattleSaveRosterTab = 'ally' | 'enemy';
export type BattleSaveAttackIntent = {
attackerId: string;
targetId: string;
};
export type BattleSaveBondState = BattleBond & {
battleExp: number;
};
export type SavedBattleUnitState = Pick<UnitData, 'id' | 'level' | 'exp' | 'hp' | 'maxHp' | 'attack' | 'move' | 'x' | 'y'> & {
equipment: UnitData['equipment'];
direction?: UnitDirection;
};
export type BattleSaveBuffState = {
unitId: string;
label: string;
turns: number;
attackBonus: number;
hitBonus: number;
criticalBonus: number;
};
export type BattleSaveStatusKind = 'burn' | 'confusion';
export type BattleSaveStatusState = {
unitId: string;
kind: BattleSaveStatusKind;
label: string;
turns: number;
power: number;
};
export type BattleSaveUnitStats = {
damageDealt: number;
damageTaken: number;
defeats: number;
actions: number;
support: number;
};
export type BattleSaveState = {
version: 1;
battleId: string;
campaignStep?: CampaignStep;
savedAt: string;
turnNumber: number;
activeFaction: BattleSaveFaction;
rosterTab: BattleSaveRosterTab;
actedUnitIds: string[];
attackIntents: BattleSaveAttackIntent[];
battleLog: string[];
units: SavedBattleUnitState[];
bonds: BattleSaveBondState[];
itemStocks?: Record<string, Record<string, number>>;
battleBuffs?: BattleSaveBuffState[];
battleStatuses?: BattleSaveStatusState[];
battleStats?: Record<string, BattleSaveUnitStats>;
enemyUsableUseKeys?: string[];
triggeredBattleEvents?: string[];
};
type BattleSaveValidationOptions = {
expectedBattleId?: string;
mapWidth?: number;
mapHeight?: number;
validUnitIds?: ReadonlySet<string>;
validUsableIds?: ReadonlySet<string>;
validItemIds?: ReadonlySet<string>;
};
const unitDirections = new Set<UnitDirection>(['south', 'east', 'north', 'west']);
export function normalizeBattleSaveSlot(slot: number, slotCount: number) {
const safeSlot = Number.isFinite(slot) ? Math.floor(slot) : 1;
return Math.min(Math.max(safeSlot, 1), slotCount);
}
export function parseBattleSaveState(raw: string | null | undefined, options: BattleSaveValidationOptions = {}) {
if (!raw) {
return undefined;
}
try {
const state = JSON.parse(raw) as unknown;
return isValidBattleSaveState(state, options) ? state : undefined;
} catch {
return undefined;
}
}
export function isValidBattleSaveState(state: unknown, options: BattleSaveValidationOptions = {}): state is BattleSaveState {
if (!isRecord(state) || state.version !== 1 || !Array.isArray(state.units)) {
return false;
}
if (typeof state.battleId !== 'string' || state.battleId.length === 0) {
return false;
}
if (options.expectedBattleId && state.battleId !== options.expectedBattleId) {
return false;
}
if (!isValidIsoLikeDate(state.savedAt) || !isPositiveInteger(state.turnNumber)) {
return false;
}
if (!isBattleFaction(state.activeFaction) || !isBattleFaction(state.rosterTab)) {
return false;
}
if (
!isUnitIdArray(state.actedUnitIds, options) ||
!isBattleLog(state.battleLog) ||
!isAttackIntentArray(state.attackIntents, options) ||
!areSavedBattleUnitsValid(state.units, options) ||
!isBondArray(state.bonds, options)
) {
return false;
}
if (
!isOptionalItemStockRecord(state.itemStocks, options) ||
!isOptionalBuffArray(state.battleBuffs, options) ||
!isOptionalStatusArray(state.battleStatuses, options) ||
!isOptionalStatsRecord(state.battleStats, options) ||
!isOptionalEnemyUsableUseKeyArray(state.enemyUsableUseKeys, state.battleId, options) ||
!isOptionalStringArray(state.triggeredBattleEvents)
) {
return false;
}
return true;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isPositiveInteger(value: unknown) {
return Number.isInteger(value) && Number(value) >= 1;
}
function isNonNegativeFiniteNumber(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
}
function isFiniteNumber(value: unknown) {
return typeof value === 'number' && Number.isFinite(value);
}
function isValidIsoLikeDate(value: unknown) {
return typeof value === 'string' && value.length > 0 && !Number.isNaN(Date.parse(value));
}
function isBattleFaction(value: unknown): value is BattleSaveFaction {
return value === 'ally' || value === 'enemy';
}
function isBattleStatusKind(value: unknown): value is BattleSaveStatusKind {
return value === 'burn' || value === 'confusion';
}
function isStringArray(value: unknown) {
return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
}
function isUnitIdArray(value: unknown, options: BattleSaveValidationOptions) {
return Array.isArray(value) && value.every((entry) => typeof entry === 'string' && isKnownUnitId(entry, options));
}
function isOptionalStringArray(value: unknown) {
return value === undefined || isStringArray(value);
}
function isBattleLog(value: unknown) {
return Array.isArray(value) && value.every((entry) => typeof entry === 'string');
}
function isAttackIntentArray(value: unknown, options: BattleSaveValidationOptions) {
return (
Array.isArray(value) &&
value.every(
(intent) =>
isRecord(intent) &&
typeof intent.attackerId === 'string' &&
typeof intent.targetId === 'string' &&
isKnownUnitId(intent.attackerId, options) &&
isKnownUnitId(intent.targetId, options)
)
);
}
function areSavedBattleUnitsValid(units: unknown[], options: BattleSaveValidationOptions) {
const seenUnitIds = new Set<string>();
for (const unit of units) {
if (!isSavedBattleUnitState(unit, options) || seenUnitIds.has(unit.id)) {
return false;
}
seenUnitIds.add(unit.id);
}
if (!options.validUnitIds) {
return true;
}
if (seenUnitIds.size !== options.validUnitIds.size) {
return false;
}
return [...seenUnitIds].every((unitId) => options.validUnitIds?.has(unitId));
}
function isSavedBattleUnitState(value: unknown, options: BattleSaveValidationOptions): value is SavedBattleUnitState {
if (!isRecord(value) || typeof value.id !== 'string' || value.id.length === 0 || !isRecord(value.equipment)) {
return false;
}
if (
!isPositiveInteger(value.level) ||
!isNonNegativeFiniteNumber(value.exp) ||
!isNonNegativeFiniteNumber(value.hp) ||
!isPositiveInteger(value.maxHp) ||
Number(value.hp) > Number(value.maxHp) ||
!isFiniteNumber(value.attack) ||
!isNonNegativeFiniteNumber(value.move) ||
!Number.isInteger(value.x) ||
!Number.isInteger(value.y)
) {
return false;
}
if (options.mapWidth !== undefined && (Number(value.x) < 0 || Number(value.x) >= options.mapWidth)) {
return false;
}
if (options.mapHeight !== undefined && (Number(value.y) < 0 || Number(value.y) >= options.mapHeight)) {
return false;
}
return value.direction === undefined || unitDirections.has(value.direction as UnitDirection);
}
function isBondArray(value: unknown, options: BattleSaveValidationOptions) {
return (
Array.isArray(value) &&
value.every(
(bond) =>
isRecord(bond) &&
typeof bond.id === 'string' &&
Array.isArray(bond.unitIds) &&
bond.unitIds.length === 2 &&
bond.unitIds.every((unitId) => typeof unitId === 'string' && isKnownUnitId(unitId, options)) &&
isPositiveInteger(bond.level) &&
isNonNegativeFiniteNumber(bond.exp) &&
isNonNegativeFiniteNumber(bond.battleExp)
)
);
}
function isOptionalItemStockRecord(value: unknown, options: BattleSaveValidationOptions) {
if (value === undefined) {
return true;
}
if (!isRecord(value)) {
return false;
}
return Object.entries(value).every(
([unitId, stocks]) =>
isKnownUnitId(unitId, options) &&
isRecord(stocks) &&
Object.entries(stocks).every(
([itemId, count]) =>
(!options.validItemIds || options.validItemIds.has(itemId)) && Number.isInteger(count) && Number(count) >= 0
)
);
}
function isOptionalBuffArray(value: unknown, options: BattleSaveValidationOptions) {
return (
value === undefined ||
(Array.isArray(value) &&
value.every(
(buff) =>
isRecord(buff) &&
typeof buff.unitId === 'string' &&
isKnownUnitId(buff.unitId, options) &&
typeof buff.label === 'string' &&
isPositiveInteger(buff.turns) &&
isFiniteNumber(buff.attackBonus) &&
isFiniteNumber(buff.hitBonus) &&
isFiniteNumber(buff.criticalBonus)
))
);
}
function isOptionalStatusArray(value: unknown, options: BattleSaveValidationOptions) {
return (
value === undefined ||
(Array.isArray(value) &&
value.every(
(status) =>
isRecord(status) &&
typeof status.unitId === 'string' &&
isKnownUnitId(status.unitId, options) &&
isBattleStatusKind(status.kind) &&
typeof status.label === 'string' &&
isPositiveInteger(status.turns) &&
isFiniteNumber(status.power)
))
);
}
function isOptionalStatsRecord(value: unknown, options: BattleSaveValidationOptions) {
if (value === undefined) {
return true;
}
if (!isRecord(value)) {
return false;
}
return Object.entries(value).every(
([unitId, stats]) =>
isKnownUnitId(unitId, options) &&
isRecord(stats) &&
isNonNegativeFiniteNumber(stats.damageDealt) &&
isNonNegativeFiniteNumber(stats.damageTaken) &&
isNonNegativeFiniteNumber(stats.defeats) &&
isNonNegativeFiniteNumber(stats.actions) &&
isNonNegativeFiniteNumber(stats.support)
);
}
function isOptionalEnemyUsableUseKeyArray(value: unknown, battleId: string, options: BattleSaveValidationOptions) {
return value === undefined || (Array.isArray(value) && value.every((entry) => isEnemyUsableUseKey(entry, battleId, options)));
}
function isEnemyUsableUseKey(value: unknown, battleId: string, options: BattleSaveValidationOptions) {
if (typeof value !== 'string') {
return false;
}
const [entryBattleId, unitId, usableId, extra] = value.split(':');
return (
extra === undefined &&
entryBattleId === battleId &&
typeof unitId === 'string' &&
unitId.length > 0 &&
typeof usableId === 'string' &&
usableId.length > 0 &&
isKnownUnitId(unitId, options) &&
(!options.validUsableIds || options.validUsableIds.has(usableId))
);
}
function isKnownUnitId(unitId: string, options: BattleSaveValidationOptions) {
return !options.validUnitIds || options.validUnitIds.has(unitId);
}