feat: add sortie operation orders
This commit is contained in:
@@ -3,6 +3,15 @@ import { unitClasses } from '../data/battleRules';
|
||||
import { battleScenarios, type BattleScenarioId } from '../data/battles';
|
||||
import { campaignRecruitUnits, type BattleBond, type UnitData } from '../data/scenario';
|
||||
import { normalizeSortieFormationAssignments, type SortieFormationAssignments } from '../data/sortieDeployment';
|
||||
import {
|
||||
isSortieOrderId,
|
||||
sortieOrderCheckIdsByOrder,
|
||||
sortieOrderDefinition,
|
||||
sortieOrderIds,
|
||||
sortieOrderRewardClaimId,
|
||||
type SortieOrderId,
|
||||
type SortieOrderResultSnapshot
|
||||
} from '../data/sortieOrders';
|
||||
import { battleIdForCampaignStep, isCampCampaignStep } from './campaignRouting';
|
||||
|
||||
export type BattleObjectiveSnapshot = {
|
||||
@@ -79,6 +88,8 @@ export type CampaignSortieReviewSnapshot = {
|
||||
sourcePresetIds: CampaignSortiePresetId[];
|
||||
};
|
||||
|
||||
export type CampaignSortieOrderResultSnapshot = SortieOrderResultSnapshot;
|
||||
|
||||
export type FirstBattleReport = {
|
||||
battleId: string;
|
||||
battleTitle: string;
|
||||
@@ -95,6 +106,7 @@ export type FirstBattleReport = {
|
||||
campaignRewards?: CampaignRewardSnapshot;
|
||||
sortiePerformance?: CampaignSortiePerformanceSnapshot;
|
||||
sortieReview?: CampaignSortieReviewSnapshot;
|
||||
sortieOrder?: CampaignSortieOrderResultSnapshot;
|
||||
completedCampDialogues: string[];
|
||||
completedCampVisits: string[];
|
||||
createdAt: string;
|
||||
@@ -315,6 +327,7 @@ const maxCampaignBattleBondEntries = 96;
|
||||
const maxCampaignReserveTrainingEntries = 96;
|
||||
const maxCampaignReserveTrainingAssignmentUnits = 96;
|
||||
const maxCampaignSortieAssignmentUnits = 96;
|
||||
const maxCampaignSortieOrderRewardClaims = 256;
|
||||
const maxCampaignDisplayTextLength = 180;
|
||||
const maxCampaignNumericValue = 999999;
|
||||
const maxCampaignObjectiveTargetRadius = 99;
|
||||
@@ -359,13 +372,14 @@ export type CampaignBattleSettlement = {
|
||||
reserveTraining?: CampaignReserveTrainingSnapshot[];
|
||||
sortiePerformance?: CampaignSortiePerformanceSnapshot;
|
||||
sortieReview?: CampaignSortieReviewSnapshot;
|
||||
sortieOrder?: CampaignSortieOrderResultSnapshot;
|
||||
completedAt: string;
|
||||
};
|
||||
|
||||
export type CampaignSortieItemAssignments = Record<string, Record<string, number>>;
|
||||
|
||||
export const campaignSortiePresetIds = ['elite', 'mobile', 'siege'] as const;
|
||||
export type CampaignSortiePresetId = (typeof campaignSortiePresetIds)[number];
|
||||
export const campaignSortiePresetIds = sortieOrderIds;
|
||||
export type CampaignSortiePresetId = SortieOrderId;
|
||||
|
||||
export type CampaignSortiePreset = {
|
||||
selectedUnitIds: string[];
|
||||
@@ -374,6 +388,15 @@ export type CampaignSortiePreset = {
|
||||
|
||||
export type CampaignSortieFormationPresets = Partial<Record<CampaignSortiePresetId, CampaignSortiePreset>>;
|
||||
|
||||
export type CampaignSortieOrderSelection = {
|
||||
battleId: BattleScenarioId;
|
||||
orderId: CampaignSortiePresetId;
|
||||
};
|
||||
|
||||
export type CampaignSortieOrderHistory = Partial<
|
||||
Record<BattleScenarioId, Partial<Record<CampaignSortiePresetId, CampaignSortieOrderResultSnapshot>>>
|
||||
>;
|
||||
|
||||
export type CampaignState = {
|
||||
version: 1;
|
||||
updatedAt: string;
|
||||
@@ -387,6 +410,9 @@ export type CampaignState = {
|
||||
sortieFormationAssignments: SortieFormationAssignments;
|
||||
sortieItemAssignments: CampaignSortieItemAssignments;
|
||||
sortieFormationPresets: CampaignSortieFormationPresets;
|
||||
sortieOrderSelection?: CampaignSortieOrderSelection;
|
||||
sortieOrderHistory: CampaignSortieOrderHistory;
|
||||
claimedSortieOrderRewardIds: string[];
|
||||
reserveTrainingFocus: CampaignReserveTrainingFocusId;
|
||||
reserveTrainingAssignments: CampaignReserveTrainingAssignments;
|
||||
completedCampDialogues: string[];
|
||||
@@ -589,6 +615,8 @@ export function createInitialCampaignState(): CampaignState {
|
||||
sortieFormationAssignments: {},
|
||||
sortieItemAssignments: {},
|
||||
sortieFormationPresets: {},
|
||||
sortieOrderHistory: {},
|
||||
claimedSortieOrderRewardIds: [],
|
||||
reserveTrainingFocus: defaultCampaignReserveTrainingFocusId,
|
||||
reserveTrainingAssignments: {},
|
||||
completedCampDialogues: [],
|
||||
@@ -676,6 +704,22 @@ export function markCampaignStep(step: CampaignStep) {
|
||||
return cloneCampaignState(state);
|
||||
}
|
||||
|
||||
export function setCampaignSortieOrderSelection(battleId: BattleScenarioId, orderId?: CampaignSortiePresetId) {
|
||||
const state = ensureCampaignState();
|
||||
if (!(battleId in battleScenarios)) {
|
||||
return cloneCampaignState(state);
|
||||
}
|
||||
|
||||
if (orderId && isSortieOrderId(orderId)) {
|
||||
state.sortieOrderSelection = { battleId, orderId };
|
||||
} else if (!state.sortieOrderSelection || state.sortieOrderSelection.battleId === battleId) {
|
||||
delete state.sortieOrderSelection;
|
||||
}
|
||||
state.updatedAt = new Date().toISOString();
|
||||
persistCampaignState(state);
|
||||
return cloneCampaignState(state);
|
||||
}
|
||||
|
||||
export function setCampaignReserveTrainingFocus(focusId: CampaignReserveTrainingFocusId) {
|
||||
const state = ensureCampaignState();
|
||||
const normalizedFocusId = normalizeReserveTrainingFocusId(focusId);
|
||||
@@ -745,16 +789,60 @@ export function setFirstBattleReport(report: FirstBattleReport) {
|
||||
|
||||
reportClone.completedCampDialogues = completedCampDialogues;
|
||||
reportClone.completedCampVisits = completedCampVisits;
|
||||
let pendingSortieOrderReward: ReturnType<typeof sortieOrderDefinition> | undefined;
|
||||
let pendingSortieOrderClaimId: string | undefined;
|
||||
const sortieOrder = normalizeCampaignSortieOrderResultSnapshot(reportClone.sortieOrder, reportClone.outcome);
|
||||
if (sortieOrder) {
|
||||
if (reportClone.outcome !== 'victory') {
|
||||
const victoryProgress = sortieOrder.progress.find((entry) => entry.id === 'victory');
|
||||
if (victoryProgress) {
|
||||
victoryProgress.value = 0;
|
||||
victoryProgress.achieved = false;
|
||||
}
|
||||
sortieOrder.achieved = false;
|
||||
}
|
||||
sortieOrder.rewardGranted = false;
|
||||
const claimId = sortieOrderRewardClaimId(battleId, sortieOrder.orderId);
|
||||
if (sortieOrder.achieved && !state.claimedSortieOrderRewardIds.includes(claimId)) {
|
||||
pendingSortieOrderReward = sortieOrderDefinition(sortieOrder.orderId);
|
||||
pendingSortieOrderClaimId = claimId;
|
||||
sortieOrder.rewardGranted = true;
|
||||
}
|
||||
const history = state.sortieOrderHistory[battleId as BattleScenarioId] ?? {};
|
||||
const previousOrderResult = history[sortieOrder.orderId];
|
||||
const keepPreviousBest = Boolean(
|
||||
previousOrderResult && (
|
||||
(previousOrderResult.achieved && !sortieOrder.achieved) ||
|
||||
(previousOrderResult.achieved === sortieOrder.achieved && previousOrderResult.score > sortieOrder.score)
|
||||
)
|
||||
);
|
||||
const bestOrderResult = keepPreviousBest ? previousOrderResult! : sortieOrder;
|
||||
history[sortieOrder.orderId] = {
|
||||
...bestOrderResult,
|
||||
rewardGranted: Boolean(previousOrderResult?.rewardGranted || sortieOrder.rewardGranted),
|
||||
progress: bestOrderResult.progress.map((entry) => ({ ...entry }))
|
||||
};
|
||||
state.sortieOrderHistory[battleId as BattleScenarioId] = history;
|
||||
reportClone.sortieOrder = sortieOrder;
|
||||
} else {
|
||||
delete reportClone.sortieOrder;
|
||||
}
|
||||
state.firstBattleReport = reportClone;
|
||||
const stepRule = campaignBattleSteps[battleId] ?? campaignBattleSteps['first-battle-zhuo-commandery'];
|
||||
const victoryStep: CampaignStep = stepRule.victory;
|
||||
const retryStep: CampaignStep = stepRule.retry;
|
||||
if (reportClone.outcome !== 'victory') {
|
||||
if (reportClone.sortieOrder) {
|
||||
state.sortieOrderSelection = {
|
||||
battleId: battleId as BattleScenarioId,
|
||||
orderId: reportClone.sortieOrder.orderId
|
||||
};
|
||||
}
|
||||
state.step = retryStep;
|
||||
state.latestBattleId = battleId;
|
||||
state.updatedAt = new Date().toISOString();
|
||||
persistCampaignState(state);
|
||||
return;
|
||||
return cloneReport(reportClone);
|
||||
}
|
||||
|
||||
state.step = victoryStep;
|
||||
@@ -783,11 +871,20 @@ export function setFirstBattleReport(report: FirstBattleReport) {
|
||||
state.completedCampVisits = completedCampVisits;
|
||||
state.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1);
|
||||
state.inventory = applyRewardDelta(state.inventory, reportClone.itemRewards, 1);
|
||||
if (pendingSortieOrderReward && pendingSortieOrderClaimId) {
|
||||
state.gold += pendingSortieOrderReward.rewardGold;
|
||||
state.inventory = applyRewardDelta(state.inventory, pendingSortieOrderReward.rewardItems, 1);
|
||||
state.claimedSortieOrderRewardIds.push(pendingSortieOrderClaimId);
|
||||
}
|
||||
state.battleHistory[battleId] = createBattleSettlement(reportClone, reserveTraining);
|
||||
if (state.sortieOrderSelection?.battleId === battleId) {
|
||||
delete state.sortieOrderSelection;
|
||||
}
|
||||
state.latestBattleId = battleId;
|
||||
state.updatedAt = new Date().toISOString();
|
||||
|
||||
persistCampaignState(state);
|
||||
return cloneReport(reportClone);
|
||||
}
|
||||
|
||||
export function getFirstBattleReport() {
|
||||
@@ -978,6 +1075,15 @@ function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
|
||||
normalized.sortieFormationAssignments = normalizeSortieFormationAssignments(recordOrEmpty(normalized.sortieFormationAssignments), sortieUnitFilter);
|
||||
normalized.sortieItemAssignments = normalizeCampaignSortieItemAssignments(recordOrEmpty(normalized.sortieItemAssignments), sortieUnitFilter);
|
||||
normalized.sortieFormationPresets = normalizeCampaignSortieFormationPresets(normalized.sortieFormationPresets, sortieUnitFilter);
|
||||
normalized.sortieOrderSelection = normalizeCampaignSortieOrderSelection(normalized.sortieOrderSelection);
|
||||
if (!normalized.sortieOrderSelection) {
|
||||
delete normalized.sortieOrderSelection;
|
||||
}
|
||||
normalized.sortieOrderHistory = normalizeCampaignSortieOrderHistory(normalized.sortieOrderHistory);
|
||||
normalized.claimedSortieOrderRewardIds = normalizeCampaignSortieOrderRewardClaims(
|
||||
normalized.claimedSortieOrderRewardIds,
|
||||
normalized.sortieOrderHistory
|
||||
);
|
||||
normalized.reserveTrainingFocus = normalizeReserveTrainingFocusId(normalized.reserveTrainingFocus);
|
||||
normalized.reserveTrainingAssignments = normalizeCampaignReserveTrainingAssignments(recordOrEmpty(normalized.reserveTrainingAssignments), sortieUnitFilter);
|
||||
normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory);
|
||||
@@ -1134,6 +1240,108 @@ function normalizeInventory(value: unknown) {
|
||||
}, {});
|
||||
}
|
||||
|
||||
function normalizeCampaignSortieOrderSelection(value: unknown): CampaignSortieOrderSelection | undefined {
|
||||
if (!isPlainObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const battleId = typeof value.battleId === 'string' ? value.battleId : '';
|
||||
if (!(battleId in battleScenarios) || !isSortieOrderId(value.orderId)) {
|
||||
return undefined;
|
||||
}
|
||||
return { battleId: battleId as BattleScenarioId, orderId: value.orderId };
|
||||
}
|
||||
|
||||
export function normalizeCampaignSortieOrderResultSnapshot(
|
||||
value: unknown,
|
||||
outcome?: FirstBattleReport['outcome']
|
||||
): CampaignSortieOrderResultSnapshot | undefined {
|
||||
if (!isPlainObject(value) || value.version !== 1 || !isSortieOrderId(value.orderId)) {
|
||||
return undefined;
|
||||
}
|
||||
const orderId = value.orderId;
|
||||
const progressById = new Map(
|
||||
arrayOrEmpty<unknown>(value.progress)
|
||||
.filter(isPlainObject)
|
||||
.map((entry) => [entry.id, entry] as const)
|
||||
);
|
||||
const progress = sortieOrderCheckIdsByOrder[orderId].map((id) => {
|
||||
const entry = progressById.get(id);
|
||||
if (!entry) {
|
||||
return { id, achieved: false, value: 0, target: 1 };
|
||||
}
|
||||
return {
|
||||
id,
|
||||
achieved: entry.achieved === true,
|
||||
value: normalizeNonNegativeInteger(entry.value),
|
||||
target: Math.max(1, normalizeNonNegativeInteger(entry.target))
|
||||
};
|
||||
});
|
||||
if (outcome === 'defeat') {
|
||||
const victoryProgress = progress.find((entry) => entry.id === 'victory');
|
||||
if (victoryProgress) {
|
||||
victoryProgress.value = 0;
|
||||
victoryProgress.achieved = false;
|
||||
}
|
||||
}
|
||||
const achieved = progress.every((entry) => entry.achieved);
|
||||
return {
|
||||
version: 1,
|
||||
orderId,
|
||||
score: Math.min(100, normalizeNonNegativeInteger(value.score)),
|
||||
achieved,
|
||||
progress,
|
||||
rewardGranted: achieved && value.rewardGranted === true
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCampaignSortieOrderHistory(value: unknown): 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]);
|
||||
if (result?.orderId === orderId) {
|
||||
results[orderId] = result;
|
||||
}
|
||||
return results;
|
||||
},
|
||||
{}
|
||||
);
|
||||
if (Object.keys(normalizedByOrder).length > 0) {
|
||||
history[battleId as BattleScenarioId] = normalizedByOrder;
|
||||
}
|
||||
return history;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function normalizeCampaignSortieOrderRewardClaims(
|
||||
value: unknown,
|
||||
history: CampaignSortieOrderHistory
|
||||
) {
|
||||
const validClaims = new Set<string>();
|
||||
arrayOrEmpty<unknown>(value).forEach((claimId) => {
|
||||
if (typeof claimId !== 'string') {
|
||||
return;
|
||||
}
|
||||
const separatorIndex = claimId.lastIndexOf(':');
|
||||
const battleId = claimId.slice(0, separatorIndex);
|
||||
const orderId = claimId.slice(separatorIndex + 1);
|
||||
if (separatorIndex > 0 && battleId in battleScenarios && isSortieOrderId(orderId)) {
|
||||
validClaims.add(sortieOrderRewardClaimId(battleId, orderId));
|
||||
}
|
||||
});
|
||||
Object.entries(history).forEach(([battleId, results]) => {
|
||||
sortieOrderIds.forEach((orderId) => {
|
||||
if (results?.[orderId]?.rewardGranted) {
|
||||
validClaims.add(sortieOrderRewardClaimId(battleId, orderId));
|
||||
}
|
||||
});
|
||||
});
|
||||
return [...validClaims].slice(0, maxCampaignSortieOrderRewardClaims);
|
||||
}
|
||||
|
||||
function normalizeBattleHistory(value: unknown) {
|
||||
return Object.entries(recordOrEmpty<unknown>(value)).reduce<Record<string, CampaignBattleSettlement>>((history, [, settlement]) => {
|
||||
const normalizedSettlement = normalizeCampaignBattleSettlement(settlement);
|
||||
@@ -1161,12 +1369,16 @@ function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rost
|
||||
const sortieReview = sortiePerformanceUnchanged
|
||||
? normalizeCampaignSortieReviewSnapshot(report.sortieReview, sortiePerformance)
|
||||
: undefined;
|
||||
const sortieOrder = sortieReview
|
||||
? normalizeCampaignSortieOrderResultSnapshot(report.sortieOrder, report.outcome)
|
||||
: 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 } : {})
|
||||
...(sortieReview ? { sortieReview } : {}),
|
||||
...(sortieOrder ? { sortieOrder } : {})
|
||||
};
|
||||
if (!sortiePerformance) {
|
||||
delete filtered.sortiePerformance;
|
||||
@@ -1174,6 +1386,9 @@ function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rost
|
||||
if (!sortieReview) {
|
||||
delete filtered.sortieReview;
|
||||
}
|
||||
if (!sortieOrder) {
|
||||
delete filtered.sortieOrder;
|
||||
}
|
||||
if (filtered.mvp && !rosterUnitIds.has(filtered.mvp.unitId)) {
|
||||
delete filtered.mvp;
|
||||
}
|
||||
@@ -1191,13 +1406,17 @@ function filterBattleHistoryRosterReferences(
|
||||
const sortieReview = sortiePerformanceUnchanged
|
||||
? normalizeCampaignSortieReviewSnapshot(settlement.sortieReview, sortiePerformance)
|
||||
: undefined;
|
||||
const sortieOrder = sortieReview
|
||||
? normalizeCampaignSortieOrderResultSnapshot(settlement.sortieOrder, settlement.outcome)
|
||||
: undefined;
|
||||
filteredHistory[battleId] = {
|
||||
...settlement,
|
||||
units: settlement.units.filter((unit) => rosterUnitIds.has(unit.unitId)),
|
||||
bonds: bondIds.size > 0 ? settlement.bonds.filter((bond) => bondIds.has(bond.id)) : settlement.bonds,
|
||||
reserveTraining: settlement.reserveTraining?.filter((entry) => rosterUnitIds.has(entry.unitId)),
|
||||
...(sortiePerformance ? { sortiePerformance } : {}),
|
||||
...(sortieReview ? { sortieReview } : {})
|
||||
...(sortieReview ? { sortieReview } : {}),
|
||||
...(sortieOrder ? { sortieOrder } : {})
|
||||
};
|
||||
if (!sortiePerformance) {
|
||||
delete filteredHistory[battleId].sortiePerformance;
|
||||
@@ -1205,6 +1424,9 @@ function filterBattleHistoryRosterReferences(
|
||||
if (!sortieReview) {
|
||||
delete filteredHistory[battleId].sortieReview;
|
||||
}
|
||||
if (!sortieOrder) {
|
||||
delete filteredHistory[battleId].sortieOrder;
|
||||
}
|
||||
return filteredHistory;
|
||||
}, {});
|
||||
}
|
||||
@@ -1230,6 +1452,9 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle
|
||||
|
||||
const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(settlement.sortiePerformance);
|
||||
const sortieReview = normalizeCampaignSortieReviewSnapshot(settlement.sortieReview, sortiePerformance);
|
||||
const sortieOrder = sortieReview
|
||||
? normalizeCampaignSortieOrderResultSnapshot(settlement.sortieOrder, settlement.outcome)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
battleId,
|
||||
@@ -1244,6 +1469,7 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle
|
||||
reserveTraining: normalizeLimitedArray(settlement.reserveTraining, normalizeReserveTrainingSnapshot, maxCampaignReserveTrainingEntries),
|
||||
...(sortiePerformance ? { sortiePerformance } : {}),
|
||||
...(sortieReview ? { sortieReview } : {}),
|
||||
...(sortieOrder ? { sortieOrder } : {}),
|
||||
completedAt
|
||||
};
|
||||
}
|
||||
@@ -1565,6 +1791,9 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi
|
||||
|
||||
const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance);
|
||||
const sortieReview = normalizeCampaignSortieReviewSnapshot(report.sortieReview, sortiePerformance);
|
||||
const sortieOrder = sortieReview
|
||||
? normalizeCampaignSortieOrderResultSnapshot(report.sortieOrder, report.outcome)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
battleId,
|
||||
@@ -1585,6 +1814,7 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi
|
||||
),
|
||||
...(sortiePerformance ? { sortiePerformance } : {}),
|
||||
...(sortieReview ? { sortieReview } : {}),
|
||||
...(sortieOrder ? { sortieOrder } : {}),
|
||||
completedCampDialogues: uniqueStrings(report.completedCampDialogues),
|
||||
completedCampVisits: uniqueStrings(report.completedCampVisits),
|
||||
createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString()
|
||||
@@ -1836,6 +2066,14 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(report.sortieOrder
|
||||
? {
|
||||
sortieOrder: {
|
||||
...report.sortieOrder,
|
||||
progress: report.sortieOrder.progress.map((entry) => ({ ...entry }))
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
completedAt: report.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user