feat: connect sortie recommendations to battle results

This commit is contained in:
2026-07-13 09:50:36 +09:00
parent 7b58317f67
commit 0cadcb22b9
9 changed files with 1958 additions and 104 deletions

View File

@@ -4,6 +4,10 @@ import { battleScenarios, type BattleScenarioId } from '../data/battles';
import { campaignRecruitUnits, type BattleBond, type UnitData } from '../data/scenario';
import { normalizeSortieFormationAssignments, type SortieFormationAssignments } from '../data/sortieDeployment';
import { coreSortieResonanceMinLevel } from '../data/sortieSynergy';
import {
sortieRecommendationPlanIds,
type SortieRecommendationPlanId
} from '../data/sortieRecommendations';
import {
isSortieOrderId,
sortieOrderCheckIdsByOrder,
@@ -64,6 +68,8 @@ export type CampaignSortieUnitPerformanceSnapshot = {
defeats: number;
actions: number;
support: number;
strategyUses: number;
signatureStrategyUses: number;
sortieBonusDamage: number;
sortiePreventedDamage: number;
sortieBonusHealing: number;
@@ -90,6 +96,19 @@ export type CampaignSortieReviewSnapshot = {
sourcePresetIds: CampaignSortiePresetId[];
};
export type CampaignSortieRecommendationSnapshot = {
version: 1;
battleId: BattleScenarioId;
planId: SortieRecommendationPlanId;
label: string;
summary: string;
sortieOrderId?: SortieOrderId;
sortieResonanceBondId?: string;
selectedUnitIds: string[];
formationAssignments: SortieFormationAssignments;
unitReasons: Record<string, string>;
};
export type CampaignSortieOrderResultSnapshot = SortieOrderResultSnapshot;
export type FirstBattleReport = {
@@ -109,6 +128,7 @@ export type FirstBattleReport = {
sortiePerformance?: CampaignSortiePerformanceSnapshot;
sortieReview?: CampaignSortieReviewSnapshot;
sortieOrder?: CampaignSortieOrderResultSnapshot;
sortieRecommendation?: CampaignSortieRecommendationSnapshot;
completedCampDialogues: string[];
completedCampVisits: string[];
createdAt: string;
@@ -376,6 +396,7 @@ export type CampaignBattleSettlement = {
sortiePerformance?: CampaignSortiePerformanceSnapshot;
sortieReview?: CampaignSortieReviewSnapshot;
sortieOrder?: CampaignSortieOrderResultSnapshot;
sortieRecommendation?: CampaignSortieRecommendationSnapshot;
completedAt: string;
};
@@ -420,6 +441,7 @@ export type CampaignState = {
sortieFormationPresets: CampaignSortieFormationPresets;
sortieOrderSelection?: CampaignSortieOrderSelection;
sortieResonanceSelection?: CampaignSortieResonanceSelection;
sortieRecommendationSelection?: CampaignSortieRecommendationSnapshot;
sortieOrderHistory: CampaignSortieOrderHistory;
claimedSortieOrderRewardIds: string[];
reserveTrainingFocus: CampaignReserveTrainingFocusId;
@@ -816,6 +838,14 @@ export function setFirstBattleReport(report: FirstBattleReport) {
const state = ensureCampaignState();
const reportClone = cloneReport(report);
const battleId = reportClone.battleId;
const normalizedRecommendation = normalizeCampaignSortieRecommendationSnapshot(reportClone.sortieRecommendation, {
expectedBattleId: battleId
});
if (normalizedRecommendation) {
reportClone.sortieRecommendation = normalizedRecommendation;
} else {
delete reportClone.sortieRecommendation;
}
const previousSettlement = state.battleHistory[battleId];
const completedCampDialogues =
battleId === 'first-battle-zhuo-commandery' ? [...reportClone.completedCampDialogues] : [...state.completedCampDialogues];
@@ -881,6 +911,21 @@ export function setFirstBattleReport(report: FirstBattleReport) {
orderId: reportClone.sortieOrder.orderId
};
}
if (reportClone.sortieRecommendation) {
state.sortieRecommendationSelection = cloneCampaignSortieRecommendationSnapshot(reportClone.sortieRecommendation);
const resonanceBondId = reportClone.sortieRecommendation.sortieResonanceBondId;
const resonanceBond = resonanceBondId
? resolveCampaignSortieResonanceBond(battleId, resonanceBondId, state.bonds)
: undefined;
const selectedUnitIds = new Set(state.selectedSortieUnitIds);
if (
resonanceBond &&
resonanceBond.level >= coreSortieResonanceMinLevel &&
resonanceBond.unitIds.every((unitId) => selectedUnitIds.has(unitId))
) {
state.sortieResonanceSelection = { battleId: battleId as BattleScenarioId, bondId: resonanceBond.id };
}
}
state.step = retryStep;
state.latestBattleId = battleId;
state.updatedAt = new Date().toISOString();
@@ -926,6 +971,9 @@ export function setFirstBattleReport(report: FirstBattleReport) {
if (state.sortieResonanceSelection?.battleId === battleId) {
delete state.sortieResonanceSelection;
}
if (state.sortieRecommendationSelection?.battleId === battleId) {
delete state.sortieRecommendationSelection;
}
state.latestBattleId = battleId;
state.updatedAt = new Date().toISOString();
@@ -1133,6 +1181,21 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
if (!normalized.sortieResonanceSelection) {
delete normalized.sortieResonanceSelection;
}
normalized.sortieRecommendationSelection = normalizeCampaignSortieRecommendationSnapshot(
normalized.sortieRecommendationSelection,
{ allowedUnitIds: sortieUnitFilter }
);
if (
!normalized.sortieRecommendationSelection ||
JSON.stringify(normalized.sortieRecommendationSelection.selectedUnitIds) !== JSON.stringify(normalized.selectedSortieUnitIds) ||
normalized.sortieRecommendationSelection.selectedUnitIds.some((unitId) => (
normalized.sortieRecommendationSelection?.formationAssignments[unitId] !== normalized.sortieFormationAssignments[unitId]
)) ||
normalized.sortieRecommendationSelection.sortieOrderId !== normalized.sortieOrderSelection?.orderId ||
normalized.sortieRecommendationSelection.sortieResonanceBondId !== normalized.sortieResonanceSelection?.bondId
) {
delete normalized.sortieRecommendationSelection;
}
normalized.sortieOrderHistory = normalizeCampaignSortieOrderHistory(normalized.sortieOrderHistory);
normalized.claimedSortieOrderRewardIds = normalizeCampaignSortieOrderRewardClaims(
normalized.claimedSortieOrderRewardIds,
@@ -1335,6 +1398,67 @@ function normalizeCampaignSortieResonanceSelection(
return { battleId: battleId as BattleScenarioId, bondId };
}
type CampaignSortieRecommendationNormalizationOptions = {
expectedBattleId?: string;
allowedUnitIds?: ReadonlySet<string>;
};
export function normalizeCampaignSortieRecommendationSnapshot(
value: unknown,
options: CampaignSortieRecommendationNormalizationOptions = {}
): CampaignSortieRecommendationSnapshot | undefined {
if (!isPlainObject(value) || value.version !== 1) {
return undefined;
}
const battleId = typeof value.battleId === 'string' ? value.battleId : '';
const planId = value.planId;
if (
!hasCampaignBattleScenario(battleId) ||
(options.expectedBattleId && options.expectedBattleId !== battleId) ||
!sortieRecommendationPlanIds.includes(planId as SortieRecommendationPlanId)
) {
return undefined;
}
const label = normalizeDisplayString(value.label).slice(0, 48);
const summary = normalizeDisplayString(value.summary).slice(0, 180);
const selectedUnitIds = uniqueStrings(value.selectedUnitIds)
.filter((unitId) => !options.allowedUnitIds || options.allowedUnitIds.has(unitId))
.slice(0, maxCampaignSortieAssignmentUnits);
if (!label || !summary || selectedUnitIds.length === 0) {
return undefined;
}
const selectedUnitIdSet = new Set(selectedUnitIds);
const formationAssignments = normalizeSortieFormationAssignments(
recordOrEmpty(value.formationAssignments),
selectedUnitIdSet
);
if (selectedUnitIds.some((unitId) => !formationAssignments[unitId])) {
return undefined;
}
const reasonRecord = recordOrEmpty(value.unitReasons);
const unitReasons = Object.fromEntries(selectedUnitIds.map((unitId) => [
unitId,
typeof reasonRecord[unitId] === 'string' ? reasonRecord[unitId].trim().slice(0, 180) : ''
]));
if (Object.values(unitReasons).some((reason) => reason.length === 0)) {
return undefined;
}
const sortieOrderId = isSortieOrderId(value.sortieOrderId) ? value.sortieOrderId : undefined;
const sortieResonanceBondId = normalizeKeyString(value.sortieResonanceBondId);
return {
version: 1,
battleId,
planId: planId as SortieRecommendationPlanId,
label,
summary,
...(sortieOrderId ? { sortieOrderId } : {}),
...(sortieResonanceBondId ? { sortieResonanceBondId } : {}),
selectedUnitIds,
formationAssignments,
unitReasons
};
}
function resolveCampaignSortieResonanceBond(
battleId: string,
bondId: string,
@@ -1596,13 +1720,20 @@ function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rost
turnNumber: report.turnNumber
})
: undefined;
const sortieRecommendation = sortiePerformance
? normalizeCampaignSortieRecommendationSnapshot(report.sortieRecommendation, {
expectedBattleId: report.battleId,
allowedUnitIds: rosterUnitIds
})
: undefined;
const filtered = {
...report,
units: report.units.filter((unit) => unit.faction !== 'ally' || rosterUnitIds.has(unit.id)),
bonds: report.bonds.filter((bond) => bond.unitIds.every((unitId) => rosterUnitIds.has(unitId))),
...(sortiePerformance ? { sortiePerformance } : {}),
...(sortieReview ? { sortieReview } : {}),
...(sortieOrder ? { sortieOrder } : {})
...(sortieOrder ? { sortieOrder } : {}),
...(sortieRecommendation ? { sortieRecommendation } : {})
};
if (!sortiePerformance) {
delete filtered.sortiePerformance;
@@ -1613,6 +1744,9 @@ function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rost
if (!sortieOrder) {
delete filtered.sortieOrder;
}
if (!sortieRecommendation) {
delete filtered.sortieRecommendation;
}
if (filtered.mvp && !rosterUnitIds.has(filtered.mvp.unitId)) {
delete filtered.mvp;
}
@@ -1636,6 +1770,12 @@ function filterBattleHistoryRosterReferences(
turnNumber: settlement.turnNumber
})
: undefined;
const sortieRecommendation = sortiePerformance
? normalizeCampaignSortieRecommendationSnapshot(settlement.sortieRecommendation, {
expectedBattleId: settlement.battleId,
allowedUnitIds: rosterUnitIds
})
: undefined;
filteredHistory[battleId] = {
...settlement,
units: settlement.units.filter((unit) => rosterUnitIds.has(unit.unitId)),
@@ -1643,7 +1783,8 @@ function filterBattleHistoryRosterReferences(
reserveTraining: settlement.reserveTraining?.filter((entry) => rosterUnitIds.has(entry.unitId)),
...(sortiePerformance ? { sortiePerformance } : {}),
...(sortieReview ? { sortieReview } : {}),
...(sortieOrder ? { sortieOrder } : {})
...(sortieOrder ? { sortieOrder } : {}),
...(sortieRecommendation ? { sortieRecommendation } : {})
};
if (!sortiePerformance) {
delete filteredHistory[battleId].sortiePerformance;
@@ -1654,6 +1795,9 @@ function filterBattleHistoryRosterReferences(
if (!sortieOrder) {
delete filteredHistory[battleId].sortieOrder;
}
if (!sortieRecommendation) {
delete filteredHistory[battleId].sortieRecommendation;
}
return filteredHistory;
}, {});
}
@@ -1679,6 +1823,9 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle
const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(settlement.sortiePerformance);
const sortieReview = normalizeCampaignSortieReviewSnapshot(settlement.sortieReview, sortiePerformance);
const sortieRecommendation = normalizeCampaignSortieRecommendationSnapshot(settlement.sortieRecommendation, {
expectedBattleId: battleId
});
const turnNumber = normalizeNonNegativeInteger(settlement.turnNumber);
const sortieOrder = sortieReview
? normalizeCampaignSortieOrderResultSnapshot(settlement.sortieOrder, settlement.outcome, {
@@ -1702,6 +1849,7 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle
...(sortiePerformance ? { sortiePerformance } : {}),
...(sortieReview ? { sortieReview } : {}),
...(sortieOrder ? { sortieOrder } : {}),
...(sortieRecommendation ? { sortieRecommendation } : {}),
completedAt
};
}
@@ -1909,6 +2057,7 @@ export function normalizeCampaignSortiePerformanceSnapshot(
return undefined;
}
const hpBefore = normalizeNonNegativeInteger(entry.hpBefore);
const strategyUses = normalizeNonNegativeInteger(entry.strategyUses);
return {
unitId,
hpBefore,
@@ -1918,6 +2067,8 @@ export function normalizeCampaignSortiePerformanceSnapshot(
defeats: normalizeNonNegativeInteger(entry.defeats),
actions: normalizeNonNegativeInteger(entry.actions),
support: normalizeNonNegativeInteger(entry.support),
strategyUses,
signatureStrategyUses: Math.min(strategyUses, normalizeNonNegativeInteger(entry.signatureStrategyUses)),
sortieBonusDamage: normalizeNonNegativeInteger(entry.sortieBonusDamage),
sortiePreventedDamage: normalizeNonNegativeInteger(entry.sortiePreventedDamage),
sortieBonusHealing: normalizeNonNegativeInteger(entry.sortieBonusHealing),
@@ -2023,6 +2174,9 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi
const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance);
const sortieReview = normalizeCampaignSortieReviewSnapshot(report.sortieReview, sortiePerformance);
const sortieRecommendation = normalizeCampaignSortieRecommendationSnapshot(report.sortieRecommendation, {
expectedBattleId: battleId
});
const turnNumber = normalizeNonNegativeInteger(report.turnNumber);
const sortieOrder = sortieReview
? normalizeCampaignSortieOrderResultSnapshot(report.sortieOrder, report.outcome, {
@@ -2051,6 +2205,7 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi
...(sortiePerformance ? { sortiePerformance } : {}),
...(sortieReview ? { sortieReview } : {}),
...(sortieOrder ? { sortieOrder } : {}),
...(sortieRecommendation ? { sortieRecommendation } : {}),
completedCampDialogues: uniqueStrings(report.completedCampDialogues),
completedCampVisits: uniqueStrings(report.completedCampVisits),
createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString()
@@ -2310,7 +2465,10 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp
progress: report.sortieOrder.progress.map((entry) => ({ ...entry })),
...(report.sortieOrder.command ? { command: { ...report.sortieOrder.command } } : {})
}
}
}
: {}),
...(report.sortieRecommendation
? { sortieRecommendation: cloneCampaignSortieRecommendationSnapshot(report.sortieRecommendation) }
: {}),
completedAt: report.createdAt
};
@@ -2454,6 +2612,15 @@ function cloneReport(report: FirstBattleReport): FirstBattleReport {
return cloned;
}
export function cloneCampaignSortieRecommendationSnapshot(snapshot: CampaignSortieRecommendationSnapshot) {
return {
...snapshot,
selectedUnitIds: [...snapshot.selectedUnitIds],
formationAssignments: { ...snapshot.formationAssignments },
unitReasons: { ...snapshot.unitReasons }
} satisfies CampaignSortieRecommendationSnapshot;
}
function cloneCampaignRewardSnapshot(rewards?: CampaignRewardSnapshot, battleId?: string): CampaignRewardSnapshot | undefined {
if (!rewards) {
return undefined;