feat: add selectable core resonance pairs

This commit is contained in:
2026-07-11 22:05:33 +09:00
parent 7d3c49ac0b
commit 51529e51c2
9 changed files with 1693 additions and 100 deletions

View File

@@ -3,6 +3,7 @@ import { isCampaignStep, 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';
@@ -69,11 +70,20 @@ export type BattleSaveTacticalCommandState = {
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;
coreResonanceStats?: BattleSaveCoreResonanceStats;
savedAt: string;
turnNumber: number;
activeFaction: BattleSaveFaction;
@@ -148,12 +158,25 @@ export function parseBattleSaveState(raw: string | null | undefined, options: Ba
try {
const state = JSON.parse(raw) as unknown;
return isValidBattleSaveState(state, options) ? state : undefined;
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);
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;
@@ -183,6 +206,10 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida
return false;
}
if (!isOptionalCoreResonanceState(state, options)) {
return false;
}
const attackIntents = state.attackIntents;
const units = state.units;
@@ -238,6 +265,118 @@ 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;
}