feat: carry first-battle camaraderie into sortie prep

This commit is contained in:
2026-07-27 06:57:33 +09:00
parent b963ef34cb
commit 17486efa8b
10 changed files with 2207 additions and 89 deletions

View File

@@ -85,6 +85,12 @@ export type BattleSaveCoreResonanceStats = {
additionalDamage: number;
};
export type BattleSaveCooperationStats = {
attempts: number;
successes: number;
additionalDamage: number;
};
export type BattleSaveEventPriority = 'critical' | 'high' | 'normal' | 'low';
export type BattleSavePendingEvent = {
@@ -104,6 +110,7 @@ export type BattleSaveState = {
sortieResonanceBondId?: string;
sortieRecommendation?: CampaignSortieRecommendationSnapshot;
coreResonanceStats?: BattleSaveCoreResonanceStats;
cooperationStatsByBond?: Record<string, BattleSaveCooperationStats>;
savedAt: string;
turnNumber: number;
activeFaction: BattleSaveFaction;
@@ -199,6 +206,7 @@ export function normalizeBattleSaveState(
}
normalizeCoreResonanceSaveFields(cloned, options);
normalizeCooperationSaveFields(cloned, options);
const recommendation = normalizeCampaignSortieRecommendationSnapshot(cloned.sortieRecommendation, {
expectedBattleId: typeof cloned.battleId === 'string' ? cloned.battleId : options.expectedBattleId,
allowedUnitIds: options.validAllyUnitIds
@@ -244,6 +252,10 @@ export function isValidBattleSaveState(state: unknown, options: BattleSaveValida
return false;
}
if (!isOptionalCooperationStatsByBond(state.cooperationStatsByBond, state, options)) {
return false;
}
const attackIntents = state.attackIntents;
const units = state.units;
@@ -368,6 +380,131 @@ function normalizeCoreResonanceStatValue(value: unknown) {
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 isOptionalCoreResonanceState(
state: Record<string, unknown>,
options: BattleSaveValidationOptions