feat: unlock tactical commands from sortie orders

This commit is contained in:
2026-07-11 02:42:04 +09:00
parent 9f49069db2
commit 2b9e33f4c0
8 changed files with 1060 additions and 70 deletions

View File

@@ -9,6 +9,7 @@ import {
sortieOrderDefinition,
sortieOrderIds,
sortieOrderRewardClaimId,
type CampaignSortieOrderCommandSnapshot,
type SortieOrderId,
type SortieOrderResultSnapshot
} from '../data/sortieOrders';
@@ -363,6 +364,7 @@ export type CampaignBattleSettlement = {
battleId: string;
battleTitle: string;
outcome: FirstBattleReport['outcome'];
turnNumber: number;
rewardGold: number;
itemRewards: string[];
campaignRewards?: CampaignRewardSnapshot;
@@ -791,7 +793,14 @@ export function setFirstBattleReport(report: FirstBattleReport) {
reportClone.completedCampVisits = completedCampVisits;
let pendingSortieOrderReward: ReturnType<typeof sortieOrderDefinition> | undefined;
let pendingSortieOrderClaimId: string | undefined;
const sortieOrder = normalizeCampaignSortieOrderResultSnapshot(reportClone.sortieOrder, reportClone.outcome);
const sortieOrder = normalizeCampaignSortieOrderResultSnapshot(
reportClone.sortieOrder,
reportClone.outcome,
{
performance: normalizeCampaignSortiePerformanceSnapshot(reportClone.sortiePerformance),
turnNumber: normalizeNonNegativeInteger(reportClone.turnNumber)
}
);
if (sortieOrder) {
if (reportClone.outcome !== 'victory') {
const victoryProgress = sortieOrder.progress.find((entry) => entry.id === 'victory');
@@ -820,7 +829,8 @@ export function setFirstBattleReport(report: FirstBattleReport) {
history[sortieOrder.orderId] = {
...bestOrderResult,
rewardGranted: Boolean(previousOrderResult?.rewardGranted || sortieOrder.rewardGranted),
progress: bestOrderResult.progress.map((entry) => ({ ...entry }))
progress: bestOrderResult.progress.map((entry) => ({ ...entry })),
...(bestOrderResult.command ? { command: { ...bestOrderResult.command } } : {})
};
state.sortieOrderHistory[battleId as BattleScenarioId] = history;
reportClone.sortieOrder = sortieOrder;
@@ -1099,6 +1109,11 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
if (normalized.firstBattleReport && sortieUnitFilter) {
normalized.firstBattleReport = filterFirstBattleReportRosterReferences(normalized.firstBattleReport, sortieUnitFilter);
}
normalized.sortieOrderHistory = normalizeCampaignSortieOrderHistory(
normalized.sortieOrderHistory,
normalized.battleHistory,
normalized.firstBattleReport
);
return normalized;
}
@@ -1253,7 +1268,8 @@ function normalizeCampaignSortieOrderSelection(value: unknown): CampaignSortieOr
export function normalizeCampaignSortieOrderResultSnapshot(
value: unknown,
outcome?: FirstBattleReport['outcome']
outcome?: FirstBattleReport['outcome'],
commandContext?: CampaignSortieOrderCommandNormalizationContext
): CampaignSortieOrderResultSnapshot | undefined {
if (!isPlainObject(value) || value.version !== 1 || !isSortieOrderId(value.orderId)) {
return undefined;
@@ -1284,24 +1300,85 @@ export function normalizeCampaignSortieOrderResultSnapshot(
}
}
const achieved = progress.every((entry) => entry.achieved);
const normalizedCommand = normalizeCampaignSortieOrderCommandSnapshot(value.command);
const command = normalizedCommand && (
!commandContext || campaignSortieOrderCommandMatchesContext(normalizedCommand, commandContext)
)
? normalizedCommand
: undefined;
return {
version: 1,
orderId,
score: Math.min(100, normalizeNonNegativeInteger(value.score)),
achieved,
progress,
rewardGranted: achieved && value.rewardGranted === true
rewardGranted: achieved && value.rewardGranted === true,
...(command ? { command } : {})
};
}
function normalizeCampaignSortieOrderHistory(value: unknown): CampaignSortieOrderHistory {
type CampaignSortieOrderCommandNormalizationContext = {
performance?: CampaignSortiePerformanceSnapshot;
turnNumber: number;
};
function campaignSortieOrderCommandMatchesContext(
command: CampaignSortieOrderCommandSnapshot,
context: CampaignSortieOrderCommandNormalizationContext
) {
const performance = context.performance;
return Boolean(
performance &&
command.usedTurn <= context.turnNumber &&
performance.selectedUnitIds.includes(command.actorId) &&
performance.formationAssignments[command.actorId] === command.role
);
}
function normalizeCampaignSortieOrderCommandSnapshot(value: unknown): CampaignSortieOrderCommandSnapshot | undefined {
if (
!isPlainObject(value) ||
(value.source !== 'sortie-order' && value.source !== 'initiative') ||
(value.role !== 'front' && value.role !== 'flank' && value.role !== 'support')
) {
return undefined;
}
const actorId = normalizeKeyString(value.actorId);
const usedTurn = normalizeNonNegativeInteger(value.usedTurn);
if (!actorId || usedTurn <= 0) {
return undefined;
}
return {
source: value.source,
role: value.role,
actorId,
usedTurn,
effectPending: value.effectPending === true,
effectValue: normalizeNonNegativeInteger(value.effectValue),
statusesCleared: normalizeNonNegativeInteger(value.statusesCleared),
effectLost: value.effectLost === true
};
}
function normalizeCampaignSortieOrderHistory(
value: unknown,
battleHistory: Record<string, CampaignBattleSettlement> = {},
latestReport?: FirstBattleReport
): CampaignSortieOrderHistory {
return Object.entries(recordOrEmpty(value)).reduce<CampaignSortieOrderHistory>((history, [battleId, orderResults]) => {
if (!(battleId in battleScenarios)) {
return history;
}
const normalizedByOrder = sortieOrderIds.reduce<Partial<Record<CampaignSortiePresetId, CampaignSortieOrderResultSnapshot>>>(
(results, orderId) => {
const result = normalizeCampaignSortieOrderResultSnapshot(recordOrEmpty(orderResults)[orderId]);
const rawResult = recordOrEmpty(orderResults)[orderId];
const standaloneResult = normalizeCampaignSortieOrderResultSnapshot(rawResult);
const commandContext = standaloneResult
? campaignSortieOrderHistoryCommandContext(battleId, standaloneResult, battleHistory, latestReport)
: undefined;
const result = commandContext
? normalizeCampaignSortieOrderResultSnapshot(rawResult, undefined, commandContext)
: standaloneResult;
if (result?.orderId === orderId) {
results[orderId] = result;
}
@@ -1316,6 +1393,59 @@ function normalizeCampaignSortieOrderHistory(value: unknown): CampaignSortieOrde
}, {});
}
function campaignSortieOrderHistoryCommandContext(
battleId: string,
result: CampaignSortieOrderResultSnapshot,
battleHistory: Record<string, CampaignBattleSettlement>,
latestReport?: FirstBattleReport
): CampaignSortieOrderCommandNormalizationContext | undefined {
const settlement = battleHistory[battleId];
const candidates = [
settlement?.sortiePerformance && settlement.sortieOrder
? {
performance: settlement.sortiePerformance,
turnNumber: settlement.turnNumber,
result: settlement.sortieOrder
}
: undefined,
latestReport?.battleId === battleId && latestReport.sortiePerformance && latestReport.sortieOrder
? {
performance: latestReport.sortiePerformance,
turnNumber: latestReport.turnNumber,
result: latestReport.sortieOrder
}
: undefined
];
const matching = candidates.find((candidate) => (
candidate && campaignSortieOrderResultsShareEvaluation(candidate.result, result)
));
return matching
? { performance: matching.performance, turnNumber: matching.turnNumber }
: undefined;
}
function campaignSortieOrderResultsShareEvaluation(
left: CampaignSortieOrderResultSnapshot,
right: CampaignSortieOrderResultSnapshot
) {
return (
left.orderId === right.orderId &&
left.score === right.score &&
left.achieved === right.achieved &&
left.progress.length === right.progress.length &&
left.progress.every((entry, index) => {
const other = right.progress[index];
return Boolean(
other &&
entry.id === other.id &&
entry.achieved === other.achieved &&
entry.value === other.value &&
entry.target === other.target
);
})
);
}
function normalizeCampaignSortieOrderRewardClaims(
value: unknown,
history: CampaignSortieOrderHistory
@@ -1370,7 +1500,10 @@ function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rost
? normalizeCampaignSortieReviewSnapshot(report.sortieReview, sortiePerformance)
: undefined;
const sortieOrder = sortieReview
? normalizeCampaignSortieOrderResultSnapshot(report.sortieOrder, report.outcome)
? normalizeCampaignSortieOrderResultSnapshot(report.sortieOrder, report.outcome, {
performance: sortiePerformance,
turnNumber: report.turnNumber
})
: undefined;
const filtered = {
...report,
@@ -1407,7 +1540,10 @@ function filterBattleHistoryRosterReferences(
? normalizeCampaignSortieReviewSnapshot(settlement.sortieReview, sortiePerformance)
: undefined;
const sortieOrder = sortieReview
? normalizeCampaignSortieOrderResultSnapshot(settlement.sortieOrder, settlement.outcome)
? normalizeCampaignSortieOrderResultSnapshot(settlement.sortieOrder, settlement.outcome, {
performance: sortiePerformance,
turnNumber: settlement.turnNumber
})
: undefined;
filteredHistory[battleId] = {
...settlement,
@@ -1452,14 +1588,19 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle
const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(settlement.sortiePerformance);
const sortieReview = normalizeCampaignSortieReviewSnapshot(settlement.sortieReview, sortiePerformance);
const turnNumber = normalizeNonNegativeInteger(settlement.turnNumber);
const sortieOrder = sortieReview
? normalizeCampaignSortieOrderResultSnapshot(settlement.sortieOrder, settlement.outcome)
? normalizeCampaignSortieOrderResultSnapshot(settlement.sortieOrder, settlement.outcome, {
performance: sortiePerformance,
turnNumber
})
: undefined;
return {
battleId,
battleTitle,
outcome: settlement.outcome,
turnNumber,
rewardGold: normalizeNonNegativeInteger(settlement.rewardGold),
itemRewards: normalizeRewardStrings(settlement.itemRewards),
campaignRewards: cloneCampaignRewardSnapshot(settlement.campaignRewards, battleId),
@@ -1791,15 +1932,19 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi
const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance);
const sortieReview = normalizeCampaignSortieReviewSnapshot(report.sortieReview, sortiePerformance);
const turnNumber = normalizeNonNegativeInteger(report.turnNumber);
const sortieOrder = sortieReview
? normalizeCampaignSortieOrderResultSnapshot(report.sortieOrder, report.outcome)
? normalizeCampaignSortieOrderResultSnapshot(report.sortieOrder, report.outcome, {
performance: sortiePerformance,
turnNumber
})
: undefined;
return {
battleId,
battleTitle,
outcome: report.outcome,
turnNumber: normalizeNonNegativeInteger(report.turnNumber),
turnNumber,
rewardGold: normalizeNonNegativeInteger(report.rewardGold),
defeatedEnemies: normalizeNonNegativeInteger(report.defeatedEnemies),
totalEnemies: normalizeNonNegativeInteger(report.totalEnemies),
@@ -2026,6 +2171,7 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp
battleId: report.battleId,
battleTitle: report.battleTitle,
outcome: report.outcome,
turnNumber: report.turnNumber,
rewardGold: report.rewardGold,
itemRewards: [...report.itemRewards],
campaignRewards: cloneCampaignRewardSnapshot(report.campaignRewards, report.battleId),
@@ -2070,7 +2216,8 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp
? {
sortieOrder: {
...report.sortieOrder,
progress: report.sortieOrder.progress.map((entry) => ({ ...entry }))
progress: report.sortieOrder.progress.map((entry) => ({ ...entry })),
...(report.sortieOrder.command ? { command: { ...report.sortieOrder.command } } : {})
}
}
: {}),