3527 lines
128 KiB
TypeScript
3527 lines
128 KiB
TypeScript
import { equipmentExpToNext, equipmentSlots, itemCatalog, type EquipmentSlot } from '../data/battleItems';
|
|
import { unitClasses } from '../data/battleRules';
|
|
import { battleScenarios, type BattleScenarioId } from '../data/battles';
|
|
import { findCityStayAfterBattle, type CityStayId } from '../data/cityStays';
|
|
import {
|
|
campaignRecruitUnits,
|
|
jianYongRecruitBond,
|
|
jianYongRecruitUnit,
|
|
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,
|
|
sortieOrderDefinition,
|
|
sortieOrderDisplayLabel,
|
|
sortieOrderIds,
|
|
sortieOrderRewardClaimId,
|
|
type CampaignSortieOrderCommandSnapshot,
|
|
type SortieOrderId,
|
|
type SortieOrderResultSnapshot
|
|
} from '../data/sortieOrders';
|
|
import { battleIdForCampaignStep, isCampCampaignStep } from './campaignRouting';
|
|
import { clearAllCampaignBattleSaves, clearCampaignBattleSavesForSlot } from './battleSaveKeys';
|
|
|
|
export type BattleObjectiveSnapshot = {
|
|
id: string;
|
|
label: string;
|
|
achieved: boolean;
|
|
status?: 'active' | 'done' | 'failed';
|
|
detail: string;
|
|
category?: 'primary' | 'required' | 'bonus';
|
|
summary?: string;
|
|
failureReason?: string;
|
|
targetTile?: { x: number; y: number; radius?: number };
|
|
rewardGold: number;
|
|
};
|
|
|
|
export type CampBondSnapshot = BattleBond & {
|
|
battleExp: number;
|
|
};
|
|
|
|
export type CampMvpSnapshot = {
|
|
unitId: string;
|
|
name: string;
|
|
damageDealt: number;
|
|
defeats: number;
|
|
};
|
|
|
|
export type CampaignRewardSnapshot = {
|
|
supplies: string[];
|
|
equipment: string[];
|
|
reputation: string[];
|
|
recruits: {
|
|
unitId: string;
|
|
name: string;
|
|
}[];
|
|
unlocks: {
|
|
battleId: string;
|
|
title: string;
|
|
}[];
|
|
note?: string;
|
|
};
|
|
|
|
export const campaignVictoryRewardCategoryIds = [
|
|
'gold',
|
|
'equipment',
|
|
'supplies',
|
|
'reputation',
|
|
'recruits',
|
|
'unlocks'
|
|
] as const;
|
|
export type CampaignVictoryRewardCategoryId = (typeof campaignVictoryRewardCategoryIds)[number];
|
|
export type CampaignVictoryRewardAcknowledgements = Partial<Record<string, CampaignVictoryRewardCategoryId[]>>;
|
|
|
|
export type CampaignCampChoiceHistory = Record<string, string>;
|
|
|
|
const campaignVictoryRewardCategoryIdSet = new Set<string>(campaignVictoryRewardCategoryIds);
|
|
|
|
export type CampaignSortieUnitPerformanceSnapshot = {
|
|
unitId: string;
|
|
hpBefore: number;
|
|
maxHpBefore: number;
|
|
damageDealt: number;
|
|
damageTaken: number;
|
|
defeats: number;
|
|
actions: number;
|
|
support: number;
|
|
strategyUses: number;
|
|
signatureStrategyUses: number;
|
|
sortieBonusDamage: number;
|
|
sortiePreventedDamage: number;
|
|
sortieBonusHealing: number;
|
|
sortieExtendedBondTriggers: number;
|
|
intentDefeats: number;
|
|
intentLineBreaks: number;
|
|
intentGuardSuccesses: number;
|
|
};
|
|
|
|
export type CampaignSortiePerformanceSnapshot = {
|
|
selectedUnitIds: string[];
|
|
formationAssignments: SortieFormationAssignments;
|
|
unitStats: CampaignSortieUnitPerformanceSnapshot[];
|
|
};
|
|
|
|
export type CampaignSortieReviewGrade = 'S' | 'A' | 'B' | 'C' | 'D';
|
|
|
|
export type CampaignSortieReviewSnapshot = {
|
|
version: 1;
|
|
score: number;
|
|
grade: CampaignSortieReviewGrade;
|
|
activeBondCount: number;
|
|
enemyCount: number;
|
|
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 CampaignSortieCooperationSnapshot = {
|
|
version: 1;
|
|
bondId: string;
|
|
unitIds: [string, string];
|
|
attempts: number;
|
|
successes: number;
|
|
additionalDamage: number;
|
|
};
|
|
|
|
export type CampaignSortieOrderResultSnapshot = SortieOrderResultSnapshot;
|
|
|
|
export type FirstBattleReport = {
|
|
battleId: string;
|
|
battleTitle: string;
|
|
outcome: 'victory' | 'defeat';
|
|
turnNumber: number;
|
|
rewardGold: number;
|
|
defeatedEnemies: number;
|
|
totalEnemies: number;
|
|
objectives: BattleObjectiveSnapshot[];
|
|
units: UnitData[];
|
|
bonds: CampBondSnapshot[];
|
|
mvp?: CampMvpSnapshot;
|
|
itemRewards: string[];
|
|
campaignRewards?: CampaignRewardSnapshot;
|
|
sortiePerformance?: CampaignSortiePerformanceSnapshot;
|
|
sortieReview?: CampaignSortieReviewSnapshot;
|
|
sortieOrder?: CampaignSortieOrderResultSnapshot;
|
|
sortieRecommendation?: CampaignSortieRecommendationSnapshot;
|
|
sortieCooperation?: CampaignSortieCooperationSnapshot;
|
|
completedCampDialogues: string[];
|
|
completedCampVisits: string[];
|
|
createdAt: string;
|
|
};
|
|
|
|
export type CampaignVictoryRewardPresentation = {
|
|
baseRewardGold: number;
|
|
rewardGold: number;
|
|
itemRewards: string[];
|
|
sortieOrderBonus?: {
|
|
orderId: SortieOrderId;
|
|
label: string;
|
|
rewardGold: number;
|
|
rewardItems: string[];
|
|
};
|
|
};
|
|
|
|
export type CampaignStep =
|
|
| 'new'
|
|
| 'prologue'
|
|
| 'prologue-town'
|
|
| 'prologue-camp'
|
|
| 'first-battle'
|
|
| 'first-camp'
|
|
| 'first-victory-story'
|
|
| 'second-battle'
|
|
| 'second-camp'
|
|
| 'third-battle'
|
|
| 'third-camp'
|
|
| 'fourth-battle'
|
|
| 'fourth-camp'
|
|
| 'fifth-battle'
|
|
| 'fifth-camp'
|
|
| 'sixth-battle'
|
|
| 'sixth-camp'
|
|
| 'seventh-battle'
|
|
| 'seventh-camp'
|
|
| 'eighth-battle'
|
|
| 'eighth-camp'
|
|
| 'ninth-battle'
|
|
| 'ninth-camp'
|
|
| 'tenth-battle'
|
|
| 'tenth-camp'
|
|
| 'eleventh-battle'
|
|
| 'eleventh-camp'
|
|
| 'twelfth-battle'
|
|
| 'twelfth-camp'
|
|
| 'thirteenth-battle'
|
|
| 'thirteenth-camp'
|
|
| 'fourteenth-battle'
|
|
| 'fourteenth-camp'
|
|
| 'fifteenth-battle'
|
|
| 'fifteenth-camp'
|
|
| 'sixteenth-battle'
|
|
| 'sixteenth-camp'
|
|
| 'seventeenth-battle'
|
|
| 'seventeenth-camp'
|
|
| 'eighteenth-battle'
|
|
| 'eighteenth-camp'
|
|
| 'nineteenth-battle'
|
|
| 'nineteenth-camp'
|
|
| 'twentieth-battle'
|
|
| 'twentieth-camp'
|
|
| 'twenty-first-battle'
|
|
| 'twenty-first-camp'
|
|
| 'twenty-second-battle'
|
|
| 'twenty-second-camp'
|
|
| 'twenty-third-battle'
|
|
| 'twenty-third-camp'
|
|
| 'twenty-fourth-battle'
|
|
| 'twenty-fourth-camp'
|
|
| 'twenty-fifth-battle'
|
|
| 'twenty-fifth-camp'
|
|
| 'twenty-sixth-battle'
|
|
| 'twenty-sixth-camp'
|
|
| 'twenty-seventh-battle'
|
|
| 'twenty-seventh-camp'
|
|
| 'twenty-eighth-battle'
|
|
| 'twenty-eighth-camp'
|
|
| 'twenty-ninth-battle'
|
|
| 'twenty-ninth-camp'
|
|
| 'thirtieth-battle'
|
|
| 'thirtieth-camp'
|
|
| 'thirty-first-battle'
|
|
| 'thirty-first-camp'
|
|
| 'thirty-second-battle'
|
|
| 'thirty-second-camp'
|
|
| 'thirty-third-battle'
|
|
| 'thirty-third-camp'
|
|
| 'thirty-fourth-battle'
|
|
| 'thirty-fourth-camp'
|
|
| 'thirty-fifth-battle'
|
|
| 'thirty-fifth-camp'
|
|
| 'thirty-sixth-battle'
|
|
| 'thirty-sixth-camp'
|
|
| 'thirty-seventh-battle'
|
|
| 'thirty-seventh-camp'
|
|
| 'thirty-eighth-battle'
|
|
| 'thirty-eighth-camp'
|
|
| 'thirty-ninth-battle'
|
|
| 'thirty-ninth-camp'
|
|
| 'fortieth-battle'
|
|
| 'fortieth-camp'
|
|
| 'forty-first-battle'
|
|
| 'forty-first-camp'
|
|
| 'forty-second-battle'
|
|
| 'forty-second-camp'
|
|
| 'forty-third-battle'
|
|
| 'forty-third-camp'
|
|
| 'forty-fourth-battle'
|
|
| 'forty-fourth-camp'
|
|
| 'forty-fifth-battle'
|
|
| 'forty-fifth-camp'
|
|
| 'forty-sixth-battle'
|
|
| 'forty-sixth-camp'
|
|
| 'forty-seventh-battle'
|
|
| 'forty-seventh-camp'
|
|
| 'forty-eighth-battle'
|
|
| 'forty-eighth-camp'
|
|
| 'forty-ninth-battle'
|
|
| 'forty-ninth-camp'
|
|
| 'fiftieth-battle'
|
|
| 'fiftieth-camp'
|
|
| 'fifty-first-battle'
|
|
| 'fifty-first-camp'
|
|
| 'fifty-second-battle'
|
|
| 'fifty-second-camp'
|
|
| 'fifty-third-battle'
|
|
| 'fifty-third-camp'
|
|
| 'fifty-fourth-battle'
|
|
| 'fifty-fourth-camp'
|
|
| 'fifty-fifth-battle'
|
|
| 'fifty-fifth-camp'
|
|
| 'fifty-sixth-battle'
|
|
| 'fifty-sixth-camp'
|
|
| 'fifty-seventh-battle'
|
|
| 'fifty-seventh-camp'
|
|
| 'fifty-eighth-battle'
|
|
| 'fifty-eighth-camp'
|
|
| 'fifty-ninth-battle'
|
|
| 'fifty-ninth-camp'
|
|
| 'sixtieth-battle'
|
|
| 'sixtieth-camp'
|
|
| 'sixty-first-battle'
|
|
| 'sixty-first-camp'
|
|
| 'sixty-second-battle'
|
|
| 'sixty-second-camp'
|
|
| 'sixty-third-battle'
|
|
| 'sixty-third-camp'
|
|
| 'sixty-fourth-battle'
|
|
| 'sixty-fourth-camp'
|
|
| 'sixty-fifth-battle'
|
|
| 'sixty-fifth-camp'
|
|
| 'sixty-sixth-battle'
|
|
| 'sixty-sixth-camp'
|
|
| 'ending-complete'
|
|
| 'hanzhong-king-camp'
|
|
| 'shu-han-foundation-camp'
|
|
| 'baidi-entrustment-camp'
|
|
| 'northern-campaign-prep-camp';
|
|
|
|
export type CampaignUnitProgressSnapshot = {
|
|
unitId: string;
|
|
name: string;
|
|
level: number;
|
|
exp: number;
|
|
hp: number;
|
|
maxHp: number;
|
|
equipment: UnitData['equipment'];
|
|
};
|
|
|
|
export type CampaignBondProgressSnapshot = {
|
|
id: string;
|
|
title: string;
|
|
level: number;
|
|
exp: number;
|
|
battleExp: number;
|
|
};
|
|
|
|
export type CampaignReserveTrainingSnapshot = {
|
|
unitId: string;
|
|
name: string;
|
|
expGained: number;
|
|
equipmentExpGained: number;
|
|
bondExpGained?: number;
|
|
focusId?: CampaignReserveTrainingFocusId;
|
|
focusLabel?: string;
|
|
level: number;
|
|
exp: number;
|
|
};
|
|
|
|
export type CampaignReserveTrainingFocusId = 'balanced' | 'class-practice' | 'bond-practice';
|
|
|
|
export type CampaignReserveTrainingFocusDefinition = {
|
|
id: CampaignReserveTrainingFocusId;
|
|
label: string;
|
|
summary: string;
|
|
expGained: number;
|
|
equipmentExpGained: number;
|
|
bondExpGained: number;
|
|
};
|
|
|
|
export type CampaignReserveTrainingAssignments = Partial<Record<string, CampaignReserveTrainingFocusId>>;
|
|
|
|
export const defaultCampaignReserveTrainingFocusId: CampaignReserveTrainingFocusId = 'balanced';
|
|
|
|
const defaultEquipmentBySlot: Record<EquipmentSlot, string> = {
|
|
weapon: 'training-sword',
|
|
armor: 'cloth-armor',
|
|
accessory: 'grain-pouch'
|
|
};
|
|
|
|
const sortieSupplyMaxByUsableId: Record<string, number> = {
|
|
bean: 2,
|
|
wine: 1,
|
|
salve: 1
|
|
};
|
|
|
|
const campaignInventoryCaps: Record<string, number> = {
|
|
['\uCF69']: 120,
|
|
['\uC0C1\uCC98\uC57D']: 80,
|
|
['\uD0C1\uC8FC']: 80
|
|
};
|
|
|
|
const maxCampaignStringListEntries = 128;
|
|
const maxCampaignStringListLength = 96;
|
|
const maxCampaignCampHistoryEntries = 256;
|
|
const maxCampaignUnknownInventoryAmount = 999;
|
|
const maxCampaignRewardNoteLength = 180;
|
|
const maxCampaignBattleObjectiveEntries = 24;
|
|
const maxCampaignBattleUnitEntries = 96;
|
|
const maxCampaignBattleBondEntries = 96;
|
|
const maxCampaignReserveTrainingEntries = 96;
|
|
const maxCampaignReserveTrainingAssignmentUnits = 96;
|
|
const maxCampaignSortieAssignmentUnits = 96;
|
|
const maxCampaignSortieOrderRewardClaims = 256;
|
|
const maxCampaignDisplayTextLength = 180;
|
|
const maxCampaignNumericValue = 999999;
|
|
const maxCampaignObjectiveTargetRadius = 99;
|
|
|
|
export const campaignReserveTrainingFocusDefinitions: CampaignReserveTrainingFocusDefinition[] = [
|
|
{
|
|
id: 'balanced',
|
|
label: '균형 훈련',
|
|
summary: '대기 무장의 경험치와 장비 숙련을 고르게 올립니다.',
|
|
expGained: 6,
|
|
equipmentExpGained: 2,
|
|
bondExpGained: 0
|
|
},
|
|
{
|
|
id: 'class-practice',
|
|
label: '병과 숙련',
|
|
summary: '대기 무장의 장비 경험치를 더 올려 병과 운용을 다듬습니다.',
|
|
expGained: 4,
|
|
equipmentExpGained: 5,
|
|
bondExpGained: 0
|
|
},
|
|
{
|
|
id: 'bond-practice',
|
|
label: '공명 합숙',
|
|
summary: '대기 무장의 개인 성장은 낮지만 관련 공명 경험치를 함께 올립니다.',
|
|
expGained: 5,
|
|
equipmentExpGained: 1,
|
|
bondExpGained: 4
|
|
}
|
|
];
|
|
|
|
export type CampaignBattleSettlement = {
|
|
battleId: string;
|
|
battleTitle: string;
|
|
outcome: FirstBattleReport['outcome'];
|
|
turnNumber: number;
|
|
rewardGold: number;
|
|
itemRewards: string[];
|
|
campaignRewards?: CampaignRewardSnapshot;
|
|
objectives: BattleObjectiveSnapshot[];
|
|
units: CampaignUnitProgressSnapshot[];
|
|
bonds: CampaignBondProgressSnapshot[];
|
|
reserveTraining?: CampaignReserveTrainingSnapshot[];
|
|
sortiePerformance?: CampaignSortiePerformanceSnapshot;
|
|
sortieReview?: CampaignSortieReviewSnapshot;
|
|
sortieOrder?: CampaignSortieOrderResultSnapshot;
|
|
sortieRecommendation?: CampaignSortieRecommendationSnapshot;
|
|
sortieCooperation?: CampaignSortieCooperationSnapshot;
|
|
completedAt: string;
|
|
};
|
|
|
|
export type CampaignSortieItemAssignments = Record<string, Record<string, number>>;
|
|
|
|
export const campaignSortiePresetIds = sortieOrderIds;
|
|
export type CampaignSortiePresetId = SortieOrderId;
|
|
|
|
export type CampaignSortiePreset = {
|
|
selectedUnitIds: string[];
|
|
formationAssignments: SortieFormationAssignments;
|
|
};
|
|
|
|
export type CampaignSortieFormationPresets = Partial<Record<CampaignSortiePresetId, CampaignSortiePreset>>;
|
|
|
|
export type CampaignSortieOrderSelection = {
|
|
battleId: BattleScenarioId;
|
|
orderId: CampaignSortiePresetId;
|
|
};
|
|
|
|
export type CampaignSortieResonanceSelection = {
|
|
battleId: BattleScenarioId;
|
|
bondId: string;
|
|
};
|
|
|
|
export type CampaignSortieOrderHistory = Partial<
|
|
Record<BattleScenarioId, Partial<Record<CampaignSortiePresetId, CampaignSortieOrderResultSnapshot>>>
|
|
>;
|
|
|
|
export const prologueVillageCampaignTutorialIds = {
|
|
entered: 'prologue-village-entered',
|
|
meetZhangFei: 'prologue-village-meet-zhang-fei',
|
|
meetGuanYu: 'prologue-village-meet-guan-yu',
|
|
registerVolunteers: 'prologue-village-register-volunteers',
|
|
checkSupplies: 'prologue-village-check-supplies',
|
|
complete: 'prologue-village-complete'
|
|
} as const;
|
|
|
|
export const prologueMilitiaCampCampaignTutorialIds = {
|
|
entered: 'prologue-camp-entered',
|
|
reviewScoutReport: 'prologue-camp-review-scout-report',
|
|
readyVanguard: 'prologue-camp-ready-vanguard',
|
|
inspectArms: 'prologue-camp-inspect-arms',
|
|
reassureVolunteer: 'prologue-camp-reassure-volunteer',
|
|
complete: 'prologue-camp-complete'
|
|
} as const;
|
|
|
|
export const campaignTutorialIds = [
|
|
'first-battle-basic-controls',
|
|
...Object.values(prologueVillageCampaignTutorialIds),
|
|
...Object.values(prologueMilitiaCampCampaignTutorialIds)
|
|
] as const;
|
|
export type CampaignTutorialId = (typeof campaignTutorialIds)[number];
|
|
const campaignTutorialIdSet = new Set<string>(campaignTutorialIds);
|
|
|
|
export type CampaignState = {
|
|
version: 1;
|
|
updatedAt: string;
|
|
step: CampaignStep;
|
|
activeSaveSlot: number;
|
|
gold: number;
|
|
roster: UnitData[];
|
|
bonds: CampBondSnapshot[];
|
|
inventory: Record<string, number>;
|
|
selectedSortieUnitIds: string[];
|
|
sortieFormationAssignments: SortieFormationAssignments;
|
|
sortieItemAssignments: CampaignSortieItemAssignments;
|
|
sortieFormationPresets: CampaignSortieFormationPresets;
|
|
sortieOrderSelection?: CampaignSortieOrderSelection;
|
|
sortieResonanceSelection?: CampaignSortieResonanceSelection;
|
|
sortieRecommendationSelection?: CampaignSortieRecommendationSnapshot;
|
|
sortieOrderHistory: CampaignSortieOrderHistory;
|
|
claimedSortieOrderRewardIds: string[];
|
|
reserveTrainingFocus: CampaignReserveTrainingFocusId;
|
|
reserveTrainingAssignments: CampaignReserveTrainingAssignments;
|
|
completedTutorialIds: CampaignTutorialId[];
|
|
completedCampDialogues: string[];
|
|
completedCampVisits: string[];
|
|
campDialogueChoiceIds: CampaignCampChoiceHistory;
|
|
campVisitChoiceIds: CampaignCampChoiceHistory;
|
|
acknowledgedVictoryRewardBattleIds: string[];
|
|
acknowledgedVictoryRewardCategories: CampaignVictoryRewardAcknowledgements;
|
|
dismissedVictoryRewardNoticeBattleIds: string[];
|
|
battleHistory: Record<string, CampaignBattleSettlement>;
|
|
latestBattleId?: string;
|
|
pendingAftermathBattleId?: BattleScenarioId;
|
|
activeCityStayId?: CityStayId;
|
|
firstBattleReport?: FirstBattleReport;
|
|
};
|
|
|
|
export const campaignStorageKey = 'heros-web:campaign-state';
|
|
export const campaignSaveSlotCount = 3;
|
|
|
|
const campaignBattleSteps: Record<string, { victory: CampaignStep; retry: CampaignStep }> = {
|
|
'first-battle-zhuo-commandery': { victory: 'first-camp', retry: 'first-battle' },
|
|
'second-battle-yellow-turban-pursuit': { victory: 'second-camp', retry: 'second-battle' },
|
|
'third-battle-guangzong-road': { victory: 'third-camp', retry: 'third-battle' },
|
|
'fourth-battle-guangzong-camp': { victory: 'fourth-camp', retry: 'fourth-battle' },
|
|
'fifth-battle-sishui-vanguard': { victory: 'fifth-camp', retry: 'fifth-battle' },
|
|
'sixth-battle-jieqiao-relief': { victory: 'sixth-camp', retry: 'sixth-battle' },
|
|
'seventh-battle-xuzhou-rescue': { victory: 'seventh-camp', retry: 'seventh-battle' },
|
|
'eighth-battle-xiaopei-supply-road': { victory: 'eighth-camp', retry: 'eighth-battle' },
|
|
'ninth-battle-xuzhou-gate-night-raid': { victory: 'ninth-camp', retry: 'ninth-battle' },
|
|
'tenth-battle-xuzhou-breakout': { victory: 'tenth-camp', retry: 'tenth-battle' },
|
|
'eleventh-battle-xudu-refuge-road': { victory: 'eleventh-camp', retry: 'eleventh-battle' },
|
|
'twelfth-battle-xiapi-outer-siege': { victory: 'twelfth-camp', retry: 'twelfth-battle' },
|
|
'thirteenth-battle-xiapi-final': { victory: 'thirteenth-camp', retry: 'thirteenth-battle' },
|
|
'fourteenth-battle-cao-break': { victory: 'fourteenth-camp', retry: 'fourteenth-battle' },
|
|
'fifteenth-battle-yuan-refuge-road': { victory: 'fifteenth-camp', retry: 'fifteenth-battle' },
|
|
'sixteenth-battle-liu-biao-refuge': { victory: 'sixteenth-camp', retry: 'sixteenth-battle' },
|
|
'seventeenth-battle-wolong-visit-road': { victory: 'seventeenth-camp', retry: 'seventeenth-battle' },
|
|
'eighteenth-battle-bowang-ambush': { victory: 'eighteenth-camp', retry: 'eighteenth-battle' },
|
|
'nineteenth-battle-changban-refuge': { victory: 'nineteenth-camp', retry: 'nineteenth-battle' },
|
|
'twentieth-battle-jiangdong-envoy': { victory: 'twentieth-camp', retry: 'twentieth-battle' },
|
|
'twenty-first-battle-red-cliffs-vanguard': { victory: 'twenty-first-camp', retry: 'twenty-first-battle' },
|
|
'twenty-second-battle-red-cliffs-fire': { victory: 'twenty-second-camp', retry: 'twenty-second-battle' },
|
|
'twenty-third-battle-jingzhou-south-entry': { victory: 'twenty-third-camp', retry: 'twenty-third-battle' },
|
|
'twenty-fourth-battle-guiyang-persuasion': { victory: 'twenty-fourth-camp', retry: 'twenty-fourth-battle' },
|
|
'twenty-fifth-battle-wuling-mountain-road': { victory: 'twenty-fifth-camp', retry: 'twenty-fifth-battle' },
|
|
'twenty-sixth-battle-changsha-veteran': { victory: 'twenty-sixth-camp', retry: 'twenty-sixth-battle' },
|
|
'twenty-seventh-battle-yizhou-relief-road': { victory: 'twenty-seventh-camp', retry: 'twenty-seventh-battle' },
|
|
'twenty-eighth-battle-fu-pass-entry': { victory: 'twenty-eighth-camp', retry: 'twenty-eighth-battle' },
|
|
'twenty-ninth-battle-luo-outer-wall': { victory: 'twenty-ninth-camp', retry: 'twenty-ninth-battle' },
|
|
'thirtieth-battle-luofeng-ambush': { victory: 'thirtieth-camp', retry: 'thirtieth-battle' },
|
|
'thirty-first-battle-luo-main-gate': { victory: 'thirty-first-camp', retry: 'thirty-first-battle' },
|
|
'thirty-second-battle-mianzhu-gate': { victory: 'thirty-second-camp', retry: 'thirty-second-battle' },
|
|
'thirty-third-battle-chengdu-surrender': { victory: 'thirty-third-camp', retry: 'thirty-third-battle' },
|
|
'thirty-fourth-battle-jiameng-pass': { victory: 'thirty-fourth-camp', retry: 'thirty-fourth-battle' },
|
|
'thirty-fifth-battle-yangping-scout': { victory: 'thirty-fifth-camp', retry: 'thirty-fifth-battle' },
|
|
'thirty-sixth-battle-dingjun-vanguard': { victory: 'thirty-sixth-camp', retry: 'thirty-sixth-battle' },
|
|
'thirty-seventh-battle-hanzhong-decisive': { victory: 'thirty-seventh-camp', retry: 'thirty-seventh-battle' },
|
|
'thirty-eighth-battle-jing-defense': { victory: 'thirty-eighth-camp', retry: 'thirty-eighth-battle' },
|
|
'thirty-ninth-battle-fan-castle-vanguard': { victory: 'thirty-ninth-camp', retry: 'thirty-ninth-battle' },
|
|
'fortieth-battle-han-river-flood': { victory: 'fortieth-camp', retry: 'fortieth-battle' },
|
|
'forty-first-battle-fan-castle-siege': { victory: 'forty-first-camp', retry: 'forty-first-battle' },
|
|
'forty-second-battle-jing-rear-crisis': { victory: 'forty-second-camp', retry: 'forty-second-battle' },
|
|
'forty-third-battle-gongan-collapse': { victory: 'forty-third-camp', retry: 'forty-third-battle' },
|
|
'forty-fourth-battle-maicheng-isolation': { victory: 'forty-fourth-camp', retry: 'forty-fourth-battle' },
|
|
'forty-fifth-battle-yiling-vanguard': { victory: 'forty-fifth-camp', retry: 'forty-fifth-battle' },
|
|
'forty-sixth-battle-yiling-fire': { victory: 'forty-sixth-camp', retry: 'forty-sixth-battle' },
|
|
'forty-seventh-battle-nanzhong-stabilization': { victory: 'forty-seventh-camp', retry: 'forty-seventh-battle' },
|
|
'forty-eighth-battle-meng-huo-main-force': { victory: 'forty-eighth-camp', retry: 'forty-eighth-battle' },
|
|
'forty-ninth-battle-meng-huo-second-capture': { victory: 'forty-ninth-camp', retry: 'forty-ninth-battle' },
|
|
'fiftieth-battle-meng-huo-third-capture': { victory: 'fiftieth-camp', retry: 'fiftieth-battle' },
|
|
'fifty-first-battle-meng-huo-fourth-capture': { victory: 'fifty-first-camp', retry: 'fifty-first-battle' },
|
|
'fifty-second-battle-meng-huo-fifth-capture': { victory: 'fifty-second-camp', retry: 'fifty-second-battle' },
|
|
'fifty-third-battle-meng-huo-sixth-capture': { victory: 'fifty-third-camp', retry: 'fifty-third-battle' },
|
|
'fifty-fourth-battle-meng-huo-final-capture': { victory: 'fifty-fourth-camp', retry: 'fifty-fourth-battle' },
|
|
'fifty-fifth-battle-northern-qishan-road': { victory: 'fifty-fifth-camp', retry: 'fifty-fifth-battle' },
|
|
'fifty-sixth-battle-tianshui-advance': { victory: 'fifty-sixth-camp', retry: 'fifty-sixth-battle' },
|
|
'fifty-seventh-battle-jieting-crisis': { victory: 'fifty-seventh-camp', retry: 'fifty-seventh-battle' },
|
|
'fifty-eighth-battle-qishan-retreat': { victory: 'fifty-eighth-camp', retry: 'fifty-eighth-battle' },
|
|
'fifty-ninth-battle-chencang-siege': { victory: 'fifty-ninth-camp', retry: 'fifty-ninth-battle' },
|
|
'sixtieth-battle-wudu-yinping': { victory: 'sixtieth-camp', retry: 'sixtieth-battle' },
|
|
'sixty-first-battle-hanzhong-rain-defense': { victory: 'sixty-first-camp', retry: 'sixty-first-battle' },
|
|
'sixty-second-battle-qishan-renewed-offensive': { victory: 'sixty-second-camp', retry: 'sixty-second-battle' },
|
|
'sixty-third-battle-lucheng-pursuit': { victory: 'sixty-third-camp', retry: 'sixty-third-battle' },
|
|
'sixty-fourth-battle-weishui-camps': { victory: 'sixty-fourth-camp', retry: 'sixty-fourth-battle' },
|
|
'sixty-fifth-battle-weishui-northbank': { victory: 'sixty-fifth-camp', retry: 'sixty-fifth-battle' },
|
|
'sixty-sixth-battle-wuzhang-final': { victory: 'sixty-sixth-camp', retry: 'sixty-sixth-battle' }
|
|
};
|
|
|
|
const campaignStepIds = new Set<CampaignStep>([
|
|
'new',
|
|
'prologue',
|
|
'prologue-town',
|
|
'prologue-camp',
|
|
'first-victory-story',
|
|
'ending-complete',
|
|
'hanzhong-king-camp',
|
|
'shu-han-foundation-camp',
|
|
'baidi-entrustment-camp',
|
|
'northern-campaign-prep-camp',
|
|
...Object.values(campaignBattleSteps).flatMap(({ victory, retry }) => [victory, retry])
|
|
]);
|
|
|
|
export type CampaignSaveSlotSummary = {
|
|
slot: number;
|
|
exists: boolean;
|
|
updatedAt?: string;
|
|
step?: CampaignStep;
|
|
gold?: number;
|
|
battleTitle?: string;
|
|
progressTitle?: string;
|
|
progressMeta?: string;
|
|
rosterCount?: number;
|
|
inventoryCount?: number;
|
|
};
|
|
|
|
let campaignState: CampaignState | undefined;
|
|
|
|
const campaignRecruitUnitById = new Map(campaignRecruitUnits.map((unit) => [unit.id, unit]));
|
|
|
|
const campaignStepProgressLabels: Partial<Record<CampaignStep, { title: string; meta: string }>> = {
|
|
new: { title: '새 캠페인', meta: '시작 전' },
|
|
prologue: { title: '황건 봉기 · 탁현', meta: '의병의 시작' },
|
|
'prologue-town': { title: '탁현에서 모인 뜻', meta: '장비·관우와의 만남' },
|
|
'prologue-camp': { title: '탁현 의용군 막사', meta: '첫 출전 준비' },
|
|
'first-victory-story': { title: '탁현 방어 성공', meta: '승리 후 이야기' },
|
|
'hanzhong-king-camp': { title: '한중왕 즉위 준비', meta: '군영 의정' },
|
|
'shu-han-foundation-camp': { title: '촉한 건국 선포', meta: '군영 의정' },
|
|
'baidi-entrustment-camp': { title: '백제성 유탁', meta: '군영 의정' },
|
|
'northern-campaign-prep-camp': { title: '북벌 준비 회의', meta: '군영 의정' },
|
|
'ending-complete': { title: '북벌의 끝과 남은 뜻', meta: '완료' }
|
|
};
|
|
|
|
export function summarizeCampaignProgress(state: CampaignState) {
|
|
const pendingAftermath = state.pendingAftermathBattleId
|
|
? state.battleHistory[state.pendingAftermathBattleId]
|
|
: undefined;
|
|
if (pendingAftermath?.outcome === 'victory') {
|
|
return {
|
|
title: pendingAftermath.battleTitle,
|
|
meta: '승리 후 이야기'
|
|
};
|
|
}
|
|
|
|
const battleId = battleIdForCampaignStep(state.step);
|
|
const battleTitle = battleId ? scenarioTitle(battleId) : undefined;
|
|
if (battleId && battleTitle) {
|
|
const settlement = state.battleHistory[battleId];
|
|
return {
|
|
title: battleTitle,
|
|
meta: settlement?.outcome === 'defeat' ? '재도전 준비' : '출전 준비'
|
|
};
|
|
}
|
|
|
|
const special = campaignStepProgressLabels[state.step];
|
|
if (special) {
|
|
return special;
|
|
}
|
|
|
|
const latestSettlement = latestCampaignSettlement(state);
|
|
if (isCampCampaignStep(state.step) && latestSettlement) {
|
|
return {
|
|
title: latestSettlement.battleTitle,
|
|
meta: latestSettlement.outcome === 'victory' ? '승리 후 군영' : '재정비'
|
|
};
|
|
}
|
|
|
|
if (latestSettlement) {
|
|
return {
|
|
title: latestSettlement.battleTitle,
|
|
meta: latestSettlement.outcome === 'victory' ? '최근 승리' : '최근 전투'
|
|
};
|
|
}
|
|
|
|
if (state.firstBattleReport) {
|
|
return {
|
|
title: state.firstBattleReport.battleTitle,
|
|
meta: state.firstBattleReport.outcome === 'victory' ? '승리 기록' : '전투 기록'
|
|
};
|
|
}
|
|
|
|
return {
|
|
title: '캠페인 진행',
|
|
meta: '진행 중'
|
|
};
|
|
}
|
|
|
|
function scenarioTitle(id?: BattleScenarioId | string) {
|
|
if (id && id in battleScenarios) {
|
|
return battleScenarios[id as BattleScenarioId].title;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function latestCampaignSettlement(state: CampaignState) {
|
|
if (state.latestBattleId) {
|
|
const latest = state.battleHistory[state.latestBattleId];
|
|
if (latest) {
|
|
return latest;
|
|
}
|
|
}
|
|
|
|
return Object.values(state.battleHistory)
|
|
.sort((a, b) => String(b.completedAt).localeCompare(String(a.completedAt)))[0];
|
|
}
|
|
|
|
function countCampaignInventory(inventory: Record<string, number>) {
|
|
return Object.values(inventory).reduce((total, amount) => total + Math.max(0, amount), 0);
|
|
}
|
|
|
|
export function createInitialCampaignState(): CampaignState {
|
|
return {
|
|
version: 1,
|
|
updatedAt: new Date().toISOString(),
|
|
step: 'new',
|
|
activeSaveSlot: 1,
|
|
gold: 0,
|
|
roster: [],
|
|
bonds: [],
|
|
inventory: {},
|
|
selectedSortieUnitIds: [],
|
|
sortieFormationAssignments: {},
|
|
sortieItemAssignments: {},
|
|
sortieFormationPresets: {},
|
|
sortieOrderHistory: {},
|
|
claimedSortieOrderRewardIds: [],
|
|
reserveTrainingFocus: defaultCampaignReserveTrainingFocusId,
|
|
reserveTrainingAssignments: {},
|
|
completedTutorialIds: [],
|
|
completedCampDialogues: [],
|
|
completedCampVisits: [],
|
|
campDialogueChoiceIds: {},
|
|
campVisitChoiceIds: {},
|
|
acknowledgedVictoryRewardBattleIds: [],
|
|
acknowledgedVictoryRewardCategories: {},
|
|
dismissedVictoryRewardNoticeBattleIds: [],
|
|
battleHistory: {}
|
|
};
|
|
}
|
|
|
|
export function startNewCampaign() {
|
|
const state = createInitialCampaignState();
|
|
clearCampaignBattleSavesForSlot(state.activeSaveSlot);
|
|
state.step = 'prologue';
|
|
return setCampaignState(state);
|
|
}
|
|
|
|
export function getCampaignState() {
|
|
return cloneCampaignState(ensureCampaignState());
|
|
}
|
|
|
|
export function setCampaignState(state: CampaignState) {
|
|
campaignState = normalizeCampaignState(state);
|
|
campaignState.updatedAt = new Date().toISOString();
|
|
persistCampaignState(campaignState);
|
|
return cloneCampaignState(campaignState);
|
|
}
|
|
|
|
export function loadCampaignState(slot?: number) {
|
|
campaignState = readStoredCampaignState(slot) ?? readStoredCampaignState() ?? readLatestSlottedCampaignState() ?? createInitialCampaignState();
|
|
return cloneCampaignState(campaignState);
|
|
}
|
|
|
|
export function readCampaignSaveState(slot: number) {
|
|
const state = readStoredCampaignState(slot);
|
|
return state ? cloneCampaignState(state) : undefined;
|
|
}
|
|
|
|
export function saveCampaignState(state = ensureCampaignState(), slot = state.activeSaveSlot) {
|
|
campaignState = normalizeCampaignState(state);
|
|
campaignState.activeSaveSlot = normalizeSlot(slot);
|
|
campaignState.updatedAt = new Date().toISOString();
|
|
persistCampaignState(campaignState);
|
|
return cloneCampaignState(campaignState);
|
|
}
|
|
|
|
export function resetCampaignState() {
|
|
clearAllCampaignBattleSaves();
|
|
campaignState = createInitialCampaignState();
|
|
tryStorage()?.removeItem(campaignStorageKey);
|
|
for (let slot = 1; slot <= campaignSaveSlotCount; slot += 1) {
|
|
tryStorage()?.removeItem(campaignSaveSlotKey(slot));
|
|
}
|
|
return cloneCampaignState(campaignState);
|
|
}
|
|
|
|
export function hasCampaignSave() {
|
|
return Boolean(readStoredCampaignState() ?? readLatestSlottedCampaignState());
|
|
}
|
|
|
|
export function listCampaignSaveSlots(): CampaignSaveSlotSummary[] {
|
|
return Array.from({ length: campaignSaveSlotCount }, (_, index) => {
|
|
const slot = index + 1;
|
|
const state = readStoredCampaignState(slot);
|
|
if (!state) {
|
|
return { slot, exists: false };
|
|
}
|
|
const progress = summarizeCampaignProgress(state);
|
|
return {
|
|
slot,
|
|
exists: true,
|
|
updatedAt: state.updatedAt,
|
|
step: state.step,
|
|
gold: state.gold,
|
|
battleTitle: progress.title,
|
|
progressTitle: progress.title,
|
|
progressMeta: progress.meta,
|
|
rosterCount: state.roster.length,
|
|
inventoryCount: countCampaignInventory(state.inventory)
|
|
};
|
|
});
|
|
}
|
|
|
|
export function markCampaignStep(step: CampaignStep) {
|
|
const state = ensureCampaignState();
|
|
state.step = step;
|
|
if (!normalizeActiveCityStayId(state.activeCityStayId, state.latestBattleId, step)) {
|
|
delete state.activeCityStayId;
|
|
}
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
export function setActiveCityStayId(cityStayId?: CityStayId) {
|
|
const state = ensureCampaignState();
|
|
const normalizedCityStayId = normalizeActiveCityStayId(cityStayId, state.latestBattleId, state.step);
|
|
if (normalizedCityStayId) {
|
|
state.activeCityStayId = normalizedCityStayId;
|
|
} else {
|
|
delete state.activeCityStayId;
|
|
}
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
export function hasCompletedCampaignTutorial(id: CampaignTutorialId, state = ensureCampaignState()) {
|
|
return state.completedTutorialIds.includes(id);
|
|
}
|
|
|
|
export function completeCampaignTutorial(id: CampaignTutorialId) {
|
|
const state = ensureCampaignState();
|
|
if (!state.completedTutorialIds.includes(id)) {
|
|
state.completedTutorialIds.push(id);
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
}
|
|
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 setCampaignSortieResonanceSelection(battleId: BattleScenarioId, bondId?: string) {
|
|
const state = ensureCampaignState();
|
|
if (!hasCampaignBattleScenario(battleId)) {
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
if (bondId) {
|
|
const normalizedBondId = normalizeKeyString(bondId);
|
|
const bond = resolveCampaignSortieResonanceBond(battleId, normalizedBondId, state.bonds);
|
|
const selectedUnitIds = new Set(state.selectedSortieUnitIds);
|
|
if (
|
|
!bond ||
|
|
bond.level < coreSortieResonanceMinLevel ||
|
|
!bond.unitIds.every((unitId) => selectedUnitIds.has(unitId))
|
|
) {
|
|
return cloneCampaignState(state);
|
|
}
|
|
state.sortieResonanceSelection = { battleId, bondId: bond.id };
|
|
} else if (!state.sortieResonanceSelection || state.sortieResonanceSelection.battleId === battleId) {
|
|
delete state.sortieResonanceSelection;
|
|
}
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
export function setCampaignReserveTrainingFocus(focusId: CampaignReserveTrainingFocusId) {
|
|
const state = ensureCampaignState();
|
|
const normalizedFocusId = normalizeReserveTrainingFocusId(focusId);
|
|
const rosterUnitIds = normalizedRosterUnitIds(state.roster);
|
|
const allowedUnitIds = rosterUnitIds.size > 0 ? rosterUnitIds : undefined;
|
|
const assignments = normalizeCampaignReserveTrainingAssignments(state.reserveTrainingAssignments, allowedUnitIds);
|
|
state.reserveTrainingFocus = normalizedFocusId;
|
|
state.reserveTrainingAssignments = Object.fromEntries(
|
|
Object.entries(assignments).filter(([, assignedFocusId]) => assignedFocusId !== normalizedFocusId)
|
|
) as CampaignReserveTrainingAssignments;
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
export function setCampaignReserveTrainingAssignment(unitId: string, focusId?: CampaignReserveTrainingFocusId) {
|
|
const state = ensureCampaignState();
|
|
const normalizedUnitId = normalizeKeyString(unitId);
|
|
const rosterUnitIds = normalizedRosterUnitIds(state.roster);
|
|
const allowedUnitIds = rosterUnitIds.size > 0 ? rosterUnitIds : undefined;
|
|
if (!normalizedUnitId || (allowedUnitIds && !allowedUnitIds.has(normalizedUnitId))) {
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
const assignments = normalizeCampaignReserveTrainingAssignments(state.reserveTrainingAssignments, allowedUnitIds);
|
|
if (focusId && isReserveTrainingFocusId(focusId)) {
|
|
assignments[normalizedUnitId] = focusId;
|
|
} else {
|
|
delete assignments[normalizedUnitId];
|
|
}
|
|
state.reserveTrainingAssignments = assignments;
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
export function normalizeCampaignReserveTrainingAssignments(
|
|
assignments?: Record<string, unknown>,
|
|
allowedUnitIds?: Set<string>
|
|
): CampaignReserveTrainingAssignments {
|
|
return Object.entries(assignments ?? {}).reduce<CampaignReserveTrainingAssignments>((next, [unitId, focusId]) => {
|
|
const normalizedUnitId = normalizeKeyString(unitId);
|
|
if (
|
|
!normalizedUnitId ||
|
|
next[normalizedUnitId] ||
|
|
(allowedUnitIds && !allowedUnitIds.has(normalizedUnitId)) ||
|
|
(!allowedUnitIds && Object.keys(next).length >= maxCampaignReserveTrainingAssignmentUnits) ||
|
|
!isReserveTrainingFocusId(focusId)
|
|
) {
|
|
return next;
|
|
}
|
|
|
|
next[normalizedUnitId] = focusId;
|
|
return next;
|
|
}, {});
|
|
}
|
|
|
|
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 = mergeCampCompletionIds(
|
|
state.completedCampDialogues,
|
|
reportClone.completedCampDialogues,
|
|
Object.keys(state.campDialogueChoiceIds)
|
|
);
|
|
const completedCampVisits = mergeCampCompletionIds(
|
|
state.completedCampVisits,
|
|
reportClone.completedCampVisits ?? [],
|
|
Object.keys(state.campVisitChoiceIds)
|
|
);
|
|
|
|
reportClone.completedCampDialogues = completedCampDialogues;
|
|
reportClone.completedCampVisits = completedCampVisits;
|
|
let pendingSortieOrderReward: ReturnType<typeof sortieOrderDefinition> | undefined;
|
|
let pendingSortieOrderClaimId: string | undefined;
|
|
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');
|
|
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 })),
|
|
...(bestOrderResult.command ? { command: { ...bestOrderResult.command } } : {})
|
|
};
|
|
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
|
|
};
|
|
}
|
|
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;
|
|
delete state.activeCityStayId;
|
|
delete state.pendingAftermathBattleId;
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
return cloneReport(reportClone);
|
|
}
|
|
|
|
state.step = victoryStep;
|
|
state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold);
|
|
const alliedReportUnits = reportClone.units.filter((unit) => unit.faction === 'ally');
|
|
const recruitUnitIds = new Set(reportClone.campaignRewards?.recruits.map((recruit) => recruit.unitId) ?? []);
|
|
const reportUnitById = new Map(reportClone.units.filter((unit) => unit.faction === 'ally').map((unit) => [unit.id, unit]));
|
|
const recruitedUnits = [...recruitUnitIds]
|
|
.map((unitId) => reportUnitById.get(unitId) ?? campaignRecruitUnitById.get(unitId))
|
|
.filter((unit): unit is UnitData => Boolean(unit));
|
|
state.roster = mergeRosterProgress(state.roster, [...alliedReportUnits, ...recruitedUnits]);
|
|
state.bonds = reportClone.bonds.map(cloneBondSnapshot);
|
|
backfillEarlyJianYongRecruit(state);
|
|
const reserveTraining = previousSettlement || reportClone.outcome !== 'victory'
|
|
? previousSettlement?.reserveTraining ?? []
|
|
: applyReserveTrainingProgress(
|
|
state.roster,
|
|
state.bonds,
|
|
reportClone.units.filter((unit) => unit.faction === 'ally'),
|
|
state.reserveTrainingFocus,
|
|
state.reserveTrainingAssignments
|
|
);
|
|
if (!previousSettlement && reportClone.outcome === 'victory') {
|
|
reportClone.bonds = state.bonds.map(cloneBondSnapshot);
|
|
}
|
|
state.completedCampDialogues = completedCampDialogues;
|
|
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;
|
|
}
|
|
if (state.sortieResonanceSelection?.battleId === battleId) {
|
|
delete state.sortieResonanceSelection;
|
|
}
|
|
if (state.sortieRecommendationSelection?.battleId === battleId) {
|
|
delete state.sortieRecommendationSelection;
|
|
}
|
|
state.latestBattleId = battleId;
|
|
delete state.activeCityStayId;
|
|
state.pendingAftermathBattleId = battleId as BattleScenarioId;
|
|
state.updatedAt = new Date().toISOString();
|
|
|
|
persistCampaignState(state);
|
|
return cloneReport(reportClone);
|
|
}
|
|
|
|
export function getFirstBattleReport() {
|
|
const report = ensureCampaignState().firstBattleReport;
|
|
return report ? cloneReport(report) : undefined;
|
|
}
|
|
|
|
export function completeCampaignAftermath(battleId: BattleScenarioId) {
|
|
const state = ensureCampaignState();
|
|
if (state.pendingAftermathBattleId !== battleId) {
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
delete state.pendingAftermathBattleId;
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
export type CampaignVictoryRewardReport = Pick<
|
|
FirstBattleReport,
|
|
'battleId' | 'battleTitle' | 'outcome' | 'rewardGold' | 'itemRewards' | 'campaignRewards' | 'sortieOrder'
|
|
>;
|
|
|
|
export function campaignVictoryRewardPresentation(report: CampaignVictoryRewardReport): CampaignVictoryRewardPresentation {
|
|
const definition = report.outcome === 'victory' && report.sortieOrder?.rewardGranted
|
|
? sortieOrderDefinition(report.sortieOrder.orderId)
|
|
: undefined;
|
|
const sortieOrderBonus = definition
|
|
? {
|
|
orderId: definition.id,
|
|
label: sortieOrderDisplayLabel(report.battleId, definition.id),
|
|
rewardGold: definition.rewardGold,
|
|
rewardItems: [...definition.rewardItems]
|
|
}
|
|
: undefined;
|
|
return {
|
|
baseRewardGold: report.rewardGold,
|
|
rewardGold: report.rewardGold + (sortieOrderBonus?.rewardGold ?? 0),
|
|
itemRewards: [...report.itemRewards, ...(sortieOrderBonus?.rewardItems ?? [])],
|
|
sortieOrderBonus
|
|
};
|
|
}
|
|
|
|
type CampaignVictoryRewardSource = Pick<
|
|
FirstBattleReport,
|
|
'battleId' | 'rewardGold' | 'itemRewards' | 'campaignRewards' | 'sortieOrder'
|
|
>;
|
|
|
|
export function campaignVictoryRewardCategoriesFor(
|
|
report: CampaignVictoryRewardSource
|
|
): CampaignVictoryRewardCategoryId[] {
|
|
const categories: CampaignVictoryRewardCategoryId[] = [];
|
|
const rewards = report.campaignRewards;
|
|
const orderDefinition = report.sortieOrder?.rewardGranted
|
|
? sortieOrderDefinition(report.sortieOrder.orderId)
|
|
: undefined;
|
|
if (report.rewardGold + (orderDefinition?.rewardGold ?? 0) > 0) {
|
|
categories.push('gold');
|
|
}
|
|
const categorizedRewardItems = new Set([
|
|
...(rewards?.supplies ?? []),
|
|
...(rewards?.equipment ?? []),
|
|
...(rewards?.reputation ?? [])
|
|
]);
|
|
const legacyExtraItems = report.itemRewards.filter((item) => !categorizedRewardItems.has(item));
|
|
const supplyItems = rewards
|
|
? [...rewards.supplies, ...legacyExtraItems, ...(orderDefinition?.rewardItems ?? [])]
|
|
: [...report.itemRewards, ...(orderDefinition?.rewardItems ?? [])];
|
|
if ((rewards?.equipment.length ?? 0) > 0) {
|
|
categories.push('equipment');
|
|
}
|
|
if (supplyItems.length > 0) {
|
|
categories.push('supplies');
|
|
}
|
|
if ((rewards?.reputation.length ?? 0) > 0) {
|
|
categories.push('reputation');
|
|
}
|
|
if ((rewards?.recruits.length ?? 0) > 0) {
|
|
categories.push('recruits');
|
|
}
|
|
if ((rewards?.unlocks.length ?? 0) > 0) {
|
|
categories.push('unlocks');
|
|
}
|
|
return categories;
|
|
}
|
|
|
|
export function getPendingCampaignVictoryRewardCategories(battleId?: string) {
|
|
const state = ensureCampaignState();
|
|
if (battleId === undefined) {
|
|
const pending = new Set(
|
|
campaignVictoryRewardBattleIds(state).flatMap((candidateBattleId) =>
|
|
pendingCampaignVictoryRewardCategoriesFor(state, candidateBattleId)
|
|
)
|
|
);
|
|
return campaignVictoryRewardCategoryIds.filter((category) => pending.has(category));
|
|
}
|
|
return pendingCampaignVictoryRewardCategoriesFor(state, normalizeKeyString(battleId));
|
|
}
|
|
|
|
export function getPendingCampaignVictoryRewardBattleIds() {
|
|
const state = ensureCampaignState();
|
|
return campaignVictoryRewardBattleIds(state).filter(
|
|
(battleId) => pendingCampaignVictoryRewardCategoriesFor(state, battleId).length > 0
|
|
);
|
|
}
|
|
|
|
function pendingCampaignVictoryRewardCategoriesFor(state: CampaignState, normalizedBattleId: string) {
|
|
const report = campaignVictoryRewardSourceFor(state, normalizedBattleId);
|
|
if (!normalizedBattleId || !report || !isCampaignVictory(report)) {
|
|
return [] as CampaignVictoryRewardCategoryId[];
|
|
}
|
|
if (state.acknowledgedVictoryRewardBattleIds.includes(normalizedBattleId)) {
|
|
return [] as CampaignVictoryRewardCategoryId[];
|
|
}
|
|
const acknowledged = new Set(state.acknowledgedVictoryRewardCategories[normalizedBattleId] ?? []);
|
|
return campaignVictoryRewardCategoriesFor(report).filter((category) => !acknowledged.has(category));
|
|
}
|
|
|
|
export function getPendingCampaignVictoryRewardReport() {
|
|
const state = ensureCampaignState();
|
|
const battleId = [...campaignVictoryRewardBattleIds(state)]
|
|
.reverse()
|
|
.find((candidateBattleId) => pendingCampaignVictoryRewardCategoriesFor(state, candidateBattleId).length > 0);
|
|
const report = battleId ? campaignVictoryRewardSourceFor(state, battleId) : undefined;
|
|
if (!report || report.outcome !== 'victory') {
|
|
return undefined;
|
|
}
|
|
return cloneCampaignVictoryRewardReport(report);
|
|
}
|
|
|
|
export function getPendingCampaignVictoryRewardNoticeReport() {
|
|
const state = ensureCampaignState();
|
|
const battleId = state.latestBattleId;
|
|
if (
|
|
!battleId ||
|
|
state.dismissedVictoryRewardNoticeBattleIds.includes(battleId) ||
|
|
pendingCampaignVictoryRewardCategoriesFor(state, battleId).length === 0
|
|
) {
|
|
return undefined;
|
|
}
|
|
const report = battleId ? campaignVictoryRewardSourceFor(state, battleId) : undefined;
|
|
if (!report || report.outcome !== 'victory') {
|
|
return undefined;
|
|
}
|
|
return cloneCampaignVictoryRewardReport(report);
|
|
}
|
|
|
|
export function dismissCampaignVictoryRewardNotice(battleId: string) {
|
|
const state = ensureCampaignState();
|
|
const normalizedBattleId = normalizeKeyString(battleId);
|
|
const report = campaignVictoryRewardSourceFor(state, normalizedBattleId);
|
|
if (
|
|
!normalizedBattleId ||
|
|
!(normalizedBattleId in battleScenarios) ||
|
|
!report ||
|
|
!isCampaignVictory(report) ||
|
|
state.dismissedVictoryRewardNoticeBattleIds.includes(normalizedBattleId)
|
|
) {
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
state.dismissedVictoryRewardNoticeBattleIds = [
|
|
...state.dismissedVictoryRewardNoticeBattleIds,
|
|
normalizedBattleId
|
|
];
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
export function acknowledgeCampaignVictoryReward(
|
|
battleId: string,
|
|
requestedCategories?: readonly CampaignVictoryRewardCategoryId[]
|
|
) {
|
|
const state = ensureCampaignState();
|
|
const normalizedBattleId = normalizeKeyString(battleId);
|
|
const report = campaignVictoryRewardSourceFor(state, normalizedBattleId);
|
|
if (
|
|
!normalizedBattleId ||
|
|
!(normalizedBattleId in battleScenarios) ||
|
|
!report ||
|
|
!isCampaignVictory(report)
|
|
) {
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
const availableCategories = campaignVictoryRewardCategoriesFor(report);
|
|
const availableCategorySet = new Set(availableCategories);
|
|
const categories = requestedCategories === undefined
|
|
? availableCategories
|
|
: uniqueVictoryRewardCategories(requestedCategories).filter((category) => availableCategorySet.has(category));
|
|
const previousCategories = state.acknowledgedVictoryRewardCategories[normalizedBattleId] ?? [];
|
|
const acknowledgedCategories = uniqueVictoryRewardCategories([...previousCategories, ...categories]);
|
|
const fullyAcknowledged = availableCategories.length > 0 &&
|
|
availableCategories.every((category) => acknowledgedCategories.includes(category));
|
|
const categoryChanged = JSON.stringify(previousCategories) !== JSON.stringify(acknowledgedCategories);
|
|
const battleAcknowledged = state.acknowledgedVictoryRewardBattleIds.includes(normalizedBattleId);
|
|
if (categoryChanged) {
|
|
state.acknowledgedVictoryRewardCategories = {
|
|
...state.acknowledgedVictoryRewardCategories,
|
|
[normalizedBattleId]: acknowledgedCategories
|
|
};
|
|
}
|
|
if (fullyAcknowledged && !battleAcknowledged) {
|
|
state.acknowledgedVictoryRewardBattleIds = [
|
|
...state.acknowledgedVictoryRewardBattleIds,
|
|
normalizedBattleId
|
|
];
|
|
}
|
|
if (categoryChanged || (fullyAcknowledged && !battleAcknowledged)) {
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
}
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
function campaignVictoryRewardSourceFor(state: CampaignState, battleId: string) {
|
|
if (state.firstBattleReport?.battleId === battleId) {
|
|
return state.firstBattleReport;
|
|
}
|
|
return state.battleHistory[battleId];
|
|
}
|
|
|
|
function campaignVictoryRewardBattleIds(state: CampaignState) {
|
|
const battleIds = Object.keys(state.battleHistory).filter((battleId) => isCampaignVictory(state.battleHistory[battleId]));
|
|
const latestReportBattleId = state.firstBattleReport?.outcome === 'victory'
|
|
? state.firstBattleReport.battleId
|
|
: undefined;
|
|
if (latestReportBattleId && !battleIds.includes(latestReportBattleId)) {
|
|
battleIds.push(latestReportBattleId);
|
|
}
|
|
return battleIds;
|
|
}
|
|
|
|
function cloneCampaignVictoryRewardReport(
|
|
report: CampaignVictoryRewardReport
|
|
): CampaignVictoryRewardReport {
|
|
return {
|
|
battleId: report.battleId,
|
|
battleTitle: report.battleTitle,
|
|
outcome: report.outcome,
|
|
rewardGold: report.rewardGold,
|
|
itemRewards: normalizeRewardStrings(report.itemRewards),
|
|
campaignRewards: cloneCampaignRewardSnapshot(report.campaignRewards, report.battleId),
|
|
...(report.sortieOrder
|
|
? { sortieOrder: JSON.parse(JSON.stringify(report.sortieOrder)) as CampaignSortieOrderResultSnapshot }
|
|
: {})
|
|
};
|
|
}
|
|
|
|
function isCampaignVictory(report: FirstBattleReport | CampaignBattleSettlement) {
|
|
return report.outcome === 'victory';
|
|
}
|
|
|
|
function uniqueVictoryRewardCategories(value: unknown): CampaignVictoryRewardCategoryId[] {
|
|
const categories = new Set(uniqueStrings(value).filter((category) => campaignVictoryRewardCategoryIdSet.has(category)));
|
|
return campaignVictoryRewardCategoryIds.filter((category) => categories.has(category));
|
|
}
|
|
|
|
export function applyCampBondExp(dialogueId: string, bondId: string, amount: number, choiceId?: string) {
|
|
const state = ensureCampaignState();
|
|
if (!state.firstBattleReport) {
|
|
return undefined;
|
|
}
|
|
|
|
if (
|
|
hasCampCompletionFlag(
|
|
state.completedCampDialogues,
|
|
state.firstBattleReport.completedCampDialogues,
|
|
state.campDialogueChoiceIds,
|
|
dialogueId
|
|
)
|
|
) {
|
|
const completionSynced = syncCompletionFlag(
|
|
state.completedCampDialogues,
|
|
state.firstBattleReport.completedCampDialogues,
|
|
dialogueId
|
|
);
|
|
const choiceRemembered = rememberCampChoice(state.campDialogueChoiceIds, dialogueId, choiceId);
|
|
if (completionSynced || choiceRemembered) {
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
}
|
|
return cloneReport(state.firstBattleReport);
|
|
}
|
|
|
|
const campaignBond = state.bonds.find((candidate) => candidate.id === bondId);
|
|
const reportBond = state.firstBattleReport.bonds.find((candidate) => candidate.id === bondId);
|
|
if (!campaignBond || !reportBond) {
|
|
return undefined;
|
|
}
|
|
|
|
syncCompletionFlag(state.completedCampDialogues, state.firstBattleReport.completedCampDialogues, dialogueId);
|
|
rememberCampChoice(state.campDialogueChoiceIds, dialogueId, choiceId);
|
|
advanceBond(campaignBond, amount);
|
|
reportBond.level = campaignBond.level;
|
|
reportBond.exp = campaignBond.exp;
|
|
reportBond.battleExp = campaignBond.battleExp;
|
|
state.updatedAt = new Date().toISOString();
|
|
|
|
persistCampaignState(state);
|
|
return cloneReport(state.firstBattleReport);
|
|
}
|
|
|
|
export function applyCampVisitReward(
|
|
visitId: string,
|
|
reward: { bondId?: string; bondExp?: number; gold?: number; itemRewards?: string[] },
|
|
choiceId?: string
|
|
) {
|
|
const state = ensureCampaignState();
|
|
if (!state.firstBattleReport) {
|
|
return undefined;
|
|
}
|
|
|
|
if (
|
|
hasCampCompletionFlag(
|
|
state.completedCampVisits,
|
|
state.firstBattleReport.completedCampVisits,
|
|
state.campVisitChoiceIds,
|
|
visitId
|
|
)
|
|
) {
|
|
const completionSynced = syncCompletionFlag(
|
|
state.completedCampVisits,
|
|
state.firstBattleReport.completedCampVisits,
|
|
visitId
|
|
);
|
|
const choiceRemembered = rememberCampChoice(state.campVisitChoiceIds, visitId, choiceId);
|
|
if (completionSynced || choiceRemembered) {
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
}
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
syncCompletionFlag(state.completedCampVisits, state.firstBattleReport.completedCampVisits, visitId);
|
|
rememberCampChoice(state.campVisitChoiceIds, visitId, choiceId);
|
|
|
|
if (reward.bondId && reward.bondExp && reward.bondExp > 0) {
|
|
const campaignBond = state.bonds.find((candidate) => candidate.id === reward.bondId);
|
|
const reportBond = state.firstBattleReport.bonds.find((candidate) => candidate.id === reward.bondId);
|
|
if (campaignBond) {
|
|
advanceBond(campaignBond, reward.bondExp);
|
|
if (reportBond) {
|
|
reportBond.level = campaignBond.level;
|
|
reportBond.exp = campaignBond.exp;
|
|
reportBond.battleExp = campaignBond.battleExp;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (reward.gold && reward.gold > 0) {
|
|
state.gold += reward.gold;
|
|
}
|
|
|
|
if (reward.itemRewards?.length) {
|
|
state.inventory = applyRewardDelta(state.inventory, reward.itemRewards, 1);
|
|
}
|
|
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
function hasCampCompletionFlag(
|
|
stateFlags: string[],
|
|
reportFlags: string[],
|
|
choiceHistory: CampaignCampChoiceHistory,
|
|
flagId: string
|
|
) {
|
|
const normalizedFlagId = normalizeKeyString(flagId);
|
|
return (
|
|
stateFlags.includes(flagId) ||
|
|
reportFlags.includes(flagId) ||
|
|
Boolean(normalizedFlagId && choiceHistory[normalizedFlagId])
|
|
);
|
|
}
|
|
|
|
function rememberCampChoice(history: CampaignCampChoiceHistory, entryId: string, choiceId?: string) {
|
|
const normalizedEntryId = normalizeKeyString(entryId);
|
|
const normalizedChoiceId = normalizeKeyString(choiceId);
|
|
if (!normalizedEntryId || !normalizedChoiceId || history[normalizedEntryId]) {
|
|
return false;
|
|
}
|
|
if (Object.keys(history).length >= maxCampaignCampHistoryEntries) {
|
|
return false;
|
|
}
|
|
history[normalizedEntryId] = normalizedChoiceId;
|
|
return true;
|
|
}
|
|
|
|
function syncCompletionFlag(stateFlags: string[], reportFlags: string[], flagId: string) {
|
|
let changed = false;
|
|
if (!stateFlags.includes(flagId)) {
|
|
stateFlags.push(flagId);
|
|
changed = true;
|
|
}
|
|
if (!reportFlags.includes(flagId)) {
|
|
reportFlags.push(flagId);
|
|
changed = true;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
export function ensureCampaignRosterUnits(units: UnitData[], bonds: BattleBond[] = []) {
|
|
const state = ensureCampaignState();
|
|
let changed = false;
|
|
const existingUnitIds = new Set(state.roster.map((unit) => unit.id));
|
|
|
|
units.forEach((unit) => {
|
|
if (existingUnitIds.has(unit.id)) {
|
|
return;
|
|
}
|
|
state.roster.push(cloneUnit(unit));
|
|
existingUnitIds.add(unit.id);
|
|
changed = true;
|
|
});
|
|
|
|
const existingBondIds = new Set(state.bonds.map((bond) => bond.id));
|
|
bonds.forEach((bond) => {
|
|
if (existingBondIds.has(bond.id)) {
|
|
return;
|
|
}
|
|
state.bonds.push(createCampBondSnapshot(bond));
|
|
existingBondIds.add(bond.id);
|
|
changed = true;
|
|
});
|
|
|
|
if (state.firstBattleReport) {
|
|
const reportUnitIds = new Set(state.firstBattleReport.units.map((unit) => unit.id));
|
|
units.forEach((unit) => {
|
|
if (reportUnitIds.has(unit.id)) {
|
|
return;
|
|
}
|
|
state.firstBattleReport?.units.push(cloneUnit(unit));
|
|
reportUnitIds.add(unit.id);
|
|
changed = true;
|
|
});
|
|
|
|
const reportBondIds = new Set(state.firstBattleReport.bonds.map((bond) => bond.id));
|
|
bonds.forEach((bond) => {
|
|
if (reportBondIds.has(bond.id)) {
|
|
return;
|
|
}
|
|
state.firstBattleReport?.bonds.push(createCampBondSnapshot(bond));
|
|
reportBondIds.add(bond.id);
|
|
changed = true;
|
|
});
|
|
}
|
|
|
|
if (changed) {
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
}
|
|
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
const earlyJianYongRecruitBattleId: BattleScenarioId = 'first-battle-zhuo-commandery';
|
|
|
|
/**
|
|
* 첫 승리 보상에 간옹이 추가되기 전에 만들어진 저장도 동일한 4인 편성 풀을 갖게 한다.
|
|
* 반복 호출해도 roster, reward, report, bond에 중복 항목을 만들지 않는다.
|
|
*/
|
|
function backfillEarlyJianYongRecruit(state: CampaignState) {
|
|
const firstSettlement = state.battleHistory[earlyJianYongRecruitBattleId];
|
|
const firstReport = state.firstBattleReport?.battleId === earlyJianYongRecruitBattleId
|
|
? state.firstBattleReport
|
|
: undefined;
|
|
const firstVictoryRecorded = firstSettlement?.outcome === 'victory' || firstReport?.outcome === 'victory';
|
|
|
|
if (!firstVictoryRecorded) {
|
|
return;
|
|
}
|
|
|
|
if (!state.roster.some((unit) => unit.id === jianYongRecruitUnit.id)) {
|
|
state.roster.push(cloneUnit(jianYongRecruitUnit));
|
|
}
|
|
if (!state.bonds.some((bond) => bond.id === jianYongRecruitBond.id)) {
|
|
// 전역 인연 진행도는 전투 보고서의 96개 스냅샷 제한 대상이 아니다.
|
|
// 후반 저장을 마이그레이션할 때 기존 인연을 지우지 않고 누락 항목만 추가한다.
|
|
state.bonds.push(createCampBondSnapshot(jianYongRecruitBond));
|
|
}
|
|
|
|
if (firstReport?.outcome === 'victory') {
|
|
if (!firstReport.bonds.some((bond) => bond.id === jianYongRecruitBond.id)) {
|
|
appendRequiredLimitedMigrationEntry(firstReport.bonds, createCampBondSnapshot(jianYongRecruitBond), maxCampaignBattleBondEntries);
|
|
}
|
|
firstReport.campaignRewards = backfillEarlyJianYongReward(firstReport.campaignRewards);
|
|
}
|
|
|
|
if (firstSettlement?.outcome === 'victory') {
|
|
if (!firstSettlement.bonds.some((bond) => bond.id === jianYongRecruitBond.id)) {
|
|
appendRequiredLimitedMigrationEntry(firstSettlement.bonds, {
|
|
id: jianYongRecruitBond.id,
|
|
title: jianYongRecruitBond.title,
|
|
level: jianYongRecruitBond.level,
|
|
exp: jianYongRecruitBond.exp,
|
|
battleExp: 0
|
|
}, maxCampaignBattleBondEntries);
|
|
}
|
|
firstSettlement.campaignRewards = backfillEarlyJianYongReward(firstSettlement.campaignRewards);
|
|
}
|
|
}
|
|
|
|
function backfillEarlyJianYongReward(existing?: CampaignRewardSnapshot): CampaignRewardSnapshot {
|
|
const rewardDefinition = battleScenarios[earlyJianYongRecruitBattleId].campaignReward;
|
|
const snapshot = cloneCampaignRewardSnapshot(existing, earlyJianYongRecruitBattleId) ?? {
|
|
supplies: [...(rewardDefinition?.supplies ?? [])],
|
|
equipment: [...(rewardDefinition?.equipment ?? [])],
|
|
reputation: [...(rewardDefinition?.reputation ?? [])],
|
|
recruits: (rewardDefinition?.recruits ?? []).map((unitId) => ({
|
|
unitId,
|
|
name: campaignRecruitUnitById.get(unitId)?.name ?? battleScenarios[earlyJianYongRecruitBattleId].units.find((unit) => unit.id === unitId)?.name ?? unitId
|
|
})),
|
|
unlocks: rewardDefinition?.unlockBattleId
|
|
? [{
|
|
battleId: rewardDefinition.unlockBattleId,
|
|
title: rewardDefinition.unlockLabel ?? battleScenarios[rewardDefinition.unlockBattleId].title
|
|
}]
|
|
: [],
|
|
...(rewardDefinition?.note ? { note: rewardDefinition.note } : {})
|
|
};
|
|
|
|
if (!snapshot.recruits.some((recruit) => recruit.unitId === jianYongRecruitUnit.id)) {
|
|
appendRequiredLimitedMigrationEntry(
|
|
snapshot.recruits,
|
|
{ unitId: jianYongRecruitUnit.id, name: jianYongRecruitUnit.name },
|
|
maxCampaignStringListEntries
|
|
);
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
function appendRequiredLimitedMigrationEntry<T>(entries: T[], entry: T, limit: number) {
|
|
if (entries.length >= limit) {
|
|
entries.splice(limit - 1);
|
|
}
|
|
entries.push(entry);
|
|
}
|
|
|
|
function ensureCampaignState() {
|
|
if (!campaignState) {
|
|
campaignState = readStoredCampaignState() ?? createInitialCampaignState();
|
|
}
|
|
return campaignState;
|
|
}
|
|
|
|
function normalizeCampaignState(state: Partial<CampaignState>): CampaignState {
|
|
const normalized = {
|
|
...createInitialCampaignState(),
|
|
...cloneCampaignState(state as CampaignState)
|
|
};
|
|
normalized.version = 1;
|
|
normalized.updatedAt = normalizeCampaignTimestamp(normalized.updatedAt) ?? new Date().toISOString();
|
|
normalized.step = normalizeCampaignStep(normalized.step);
|
|
normalized.activeSaveSlot = normalizeSlot(normalized.activeSaveSlot);
|
|
normalized.gold = normalizeNonNegativeInteger(normalized.gold);
|
|
normalized.roster = arrayOrEmpty<unknown>(normalized.roster)
|
|
.map(normalizeUnitDataSnapshot)
|
|
.filter((unit): unit is UnitData => Boolean(unit));
|
|
normalized.bonds = arrayOrEmpty<unknown>(normalized.bonds)
|
|
.map(normalizeCampBondSnapshot)
|
|
.filter((bond): bond is CampBondSnapshot => Boolean(bond));
|
|
normalized.completedCampDialogues = uniqueCampHistoryIds(normalized.completedCampDialogues);
|
|
normalized.completedCampVisits = uniqueCampHistoryIds(normalized.completedCampVisits);
|
|
normalized.campDialogueChoiceIds = normalizeCampChoiceHistory(normalized.campDialogueChoiceIds);
|
|
normalized.campVisitChoiceIds = normalizeCampChoiceHistory(normalized.campVisitChoiceIds);
|
|
normalized.completedTutorialIds = uniqueStrings(normalized.completedTutorialIds)
|
|
.filter((id): id is CampaignTutorialId => campaignTutorialIdSet.has(id));
|
|
normalized.acknowledgedVictoryRewardBattleIds = uniqueStrings(normalized.acknowledgedVictoryRewardBattleIds)
|
|
.filter((battleId) => battleId in battleScenarios);
|
|
normalized.acknowledgedVictoryRewardCategories = normalizeCampaignVictoryRewardAcknowledgements(
|
|
normalized.acknowledgedVictoryRewardCategories
|
|
);
|
|
normalized.dismissedVictoryRewardNoticeBattleIds = uniqueStrings(normalized.dismissedVictoryRewardNoticeBattleIds)
|
|
.filter((battleId) => battleId in battleScenarios);
|
|
normalized.inventory = normalizeInventory(normalized.inventory);
|
|
normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory);
|
|
normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport);
|
|
normalized.completedCampDialogues = mergeCampCompletionIds(
|
|
normalized.completedCampDialogues,
|
|
normalized.firstBattleReport?.completedCampDialogues ?? [],
|
|
Object.keys(normalized.campDialogueChoiceIds)
|
|
);
|
|
normalized.completedCampVisits = mergeCampCompletionIds(
|
|
normalized.completedCampVisits,
|
|
normalized.firstBattleReport?.completedCampVisits ?? [],
|
|
Object.keys(normalized.campVisitChoiceIds)
|
|
);
|
|
if (normalized.firstBattleReport) {
|
|
normalized.firstBattleReport.completedCampDialogues = [...normalized.completedCampDialogues];
|
|
normalized.firstBattleReport.completedCampVisits = [...normalized.completedCampVisits];
|
|
}
|
|
backfillEarlyJianYongRecruit(normalized);
|
|
const rosterUnitIds = normalizedRosterUnitIds(normalized.roster);
|
|
const sortieUnitFilter = rosterUnitIds.size > 0 ? rosterUnitIds : undefined;
|
|
if (sortieUnitFilter) {
|
|
normalized.bonds = normalized.bonds.filter((bond) => bond.unitIds.every((unitId) => sortieUnitFilter.has(unitId)));
|
|
}
|
|
normalized.selectedSortieUnitIds = uniqueStrings(normalized.selectedSortieUnitIds)
|
|
.filter((unitId) => !sortieUnitFilter || sortieUnitFilter.has(unitId));
|
|
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.sortieResonanceSelection = normalizeCampaignSortieResonanceSelection(
|
|
normalized.sortieResonanceSelection,
|
|
normalized.bonds,
|
|
normalized.selectedSortieUnitIds
|
|
);
|
|
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,
|
|
normalized.sortieOrderHistory
|
|
);
|
|
normalized.reserveTrainingFocus = normalizeReserveTrainingFocusId(normalized.reserveTrainingFocus);
|
|
normalized.reserveTrainingAssignments = normalizeCampaignReserveTrainingAssignments(recordOrEmpty(normalized.reserveTrainingAssignments), sortieUnitFilter);
|
|
if (sortieUnitFilter) {
|
|
normalized.battleHistory = filterBattleHistoryRosterReferences(
|
|
normalized.battleHistory,
|
|
sortieUnitFilter,
|
|
normalized.bonds
|
|
);
|
|
}
|
|
normalized.latestBattleId = normalizeLatestBattleId(normalized.latestBattleId, normalized.battleHistory);
|
|
const activeCityStayId = normalizeActiveCityStayId(
|
|
normalized.activeCityStayId,
|
|
normalized.latestBattleId,
|
|
normalized.step
|
|
);
|
|
if (activeCityStayId) {
|
|
normalized.activeCityStayId = activeCityStayId;
|
|
} else {
|
|
delete normalized.activeCityStayId;
|
|
}
|
|
const pendingAftermathBattleId = typeof normalized.pendingAftermathBattleId === 'string' &&
|
|
normalized.pendingAftermathBattleId in battleScenarios
|
|
? normalized.pendingAftermathBattleId as BattleScenarioId
|
|
: undefined;
|
|
const pendingAftermathStep = pendingAftermathBattleId
|
|
? campaignBattleSteps[pendingAftermathBattleId]?.victory
|
|
: undefined;
|
|
if (
|
|
pendingAftermathBattleId &&
|
|
normalized.latestBattleId === pendingAftermathBattleId &&
|
|
normalized.step === pendingAftermathStep &&
|
|
normalized.firstBattleReport?.battleId === pendingAftermathBattleId &&
|
|
normalized.firstBattleReport.outcome === 'victory' &&
|
|
normalized.battleHistory[pendingAftermathBattleId]?.outcome === 'victory'
|
|
) {
|
|
normalized.pendingAftermathBattleId = pendingAftermathBattleId;
|
|
} else {
|
|
delete normalized.pendingAftermathBattleId;
|
|
}
|
|
if (normalized.firstBattleReport && sortieUnitFilter) {
|
|
normalized.firstBattleReport = filterFirstBattleReportRosterReferences(normalized.firstBattleReport, sortieUnitFilter);
|
|
}
|
|
const recordedVictoryBattleIds = new Set(
|
|
Object.entries(normalized.battleHistory)
|
|
.filter(([, settlement]) => settlement.outcome === 'victory')
|
|
.map(([battleId]) => battleId)
|
|
);
|
|
if (normalized.firstBattleReport?.outcome === 'victory') {
|
|
recordedVictoryBattleIds.add(normalized.firstBattleReport.battleId);
|
|
}
|
|
normalized.acknowledgedVictoryRewardBattleIds = normalized.acknowledgedVictoryRewardBattleIds
|
|
.filter((battleId) => recordedVictoryBattleIds.has(battleId));
|
|
normalized.dismissedVictoryRewardNoticeBattleIds = normalized.dismissedVictoryRewardNoticeBattleIds
|
|
.filter((battleId) => recordedVictoryBattleIds.has(battleId));
|
|
const acknowledgedVictoryRewardCategories = Object.entries(normalized.acknowledgedVictoryRewardCategories)
|
|
.reduce<CampaignVictoryRewardAcknowledgements>((acknowledgements, [battleId, categories]) => {
|
|
if (!recordedVictoryBattleIds.has(battleId)) {
|
|
return acknowledgements;
|
|
}
|
|
const source = campaignVictoryRewardSourceFor(normalized, battleId);
|
|
if (!source || !isCampaignVictory(source)) {
|
|
return acknowledgements;
|
|
}
|
|
const availableCategories = new Set(campaignVictoryRewardCategoriesFor(source));
|
|
const validCategories = uniqueVictoryRewardCategories(categories)
|
|
.filter((category) => availableCategories.has(category));
|
|
if (validCategories.length > 0) {
|
|
acknowledgements[battleId] = validCategories;
|
|
}
|
|
return acknowledgements;
|
|
}, {});
|
|
normalized.acknowledgedVictoryRewardBattleIds.forEach((battleId) => {
|
|
const source = campaignVictoryRewardSourceFor(normalized, battleId);
|
|
if (source && isCampaignVictory(source)) {
|
|
acknowledgedVictoryRewardCategories[battleId] = campaignVictoryRewardCategoriesFor(source);
|
|
}
|
|
});
|
|
normalized.acknowledgedVictoryRewardCategories = acknowledgedVictoryRewardCategories;
|
|
normalized.acknowledgedVictoryRewardBattleIds = [...recordedVictoryBattleIds]
|
|
.filter((battleId) => {
|
|
const source = campaignVictoryRewardSourceFor(normalized, battleId);
|
|
if (!source || !isCampaignVictory(source)) {
|
|
return false;
|
|
}
|
|
const acknowledged = new Set(normalized.acknowledgedVictoryRewardCategories[battleId] ?? []);
|
|
const available = campaignVictoryRewardCategoriesFor(source);
|
|
return available.length > 0 && available.every((category) => acknowledged.has(category));
|
|
});
|
|
normalized.sortieOrderHistory = normalizeCampaignSortieOrderHistory(
|
|
normalized.sortieOrderHistory,
|
|
normalized.battleHistory,
|
|
normalized.firstBattleReport
|
|
);
|
|
return normalized;
|
|
}
|
|
|
|
function arrayOrEmpty<T>(value: unknown): T[] {
|
|
return Array.isArray(value) ? value as T[] : [];
|
|
}
|
|
|
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function recordOrEmpty<T = unknown>(value: unknown): Record<string, T> {
|
|
return isPlainObject(value) ? value as Record<string, T> : {};
|
|
}
|
|
|
|
function normalizeCampaignVictoryRewardAcknowledgements(value: unknown): CampaignVictoryRewardAcknowledgements {
|
|
return Object.entries(recordOrEmpty(value))
|
|
.slice(0, maxCampaignStringListEntries)
|
|
.reduce<CampaignVictoryRewardAcknowledgements>((acknowledgements, [battleId, categories]) => {
|
|
const normalizedBattleId = normalizeKeyString(battleId);
|
|
const normalizedCategories = uniqueVictoryRewardCategories(categories);
|
|
if (normalizedBattleId && normalizedBattleId in battleScenarios && normalizedCategories.length > 0) {
|
|
acknowledgements[normalizedBattleId] = normalizedCategories;
|
|
}
|
|
return acknowledgements;
|
|
}, {});
|
|
}
|
|
|
|
function normalizeCampChoiceHistory(value: unknown): CampaignCampChoiceHistory {
|
|
const history: CampaignCampChoiceHistory = {};
|
|
let acceptedEntryCount = 0;
|
|
for (const [entryId, choiceId] of Object.entries(recordOrEmpty(value))) {
|
|
if (acceptedEntryCount >= maxCampaignCampHistoryEntries) {
|
|
break;
|
|
}
|
|
const normalizedEntryId = normalizeKeyString(entryId);
|
|
const normalizedChoiceId = normalizeKeyString(choiceId);
|
|
if (normalizedEntryId && normalizedChoiceId) {
|
|
if (!history[normalizedEntryId]) {
|
|
acceptedEntryCount += 1;
|
|
}
|
|
history[normalizedEntryId] = normalizedChoiceId;
|
|
}
|
|
}
|
|
return history;
|
|
}
|
|
|
|
function uniqueCampHistoryIds(value: unknown): string[] {
|
|
return [
|
|
...new Set(
|
|
arrayOrEmpty<unknown>(value)
|
|
.filter((entry): entry is string => typeof entry === 'string')
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0 && entry.length <= maxCampaignStringListLength)
|
|
)
|
|
].slice(0, maxCampaignCampHistoryEntries);
|
|
}
|
|
|
|
function mergeCampCompletionIds(...completionIdGroups: string[][]) {
|
|
return uniqueCampHistoryIds(completionIdGroups.flat());
|
|
}
|
|
|
|
function uniqueStrings(value: unknown): string[] {
|
|
return [
|
|
...new Set(
|
|
arrayOrEmpty<unknown>(value)
|
|
.filter((entry): entry is string => typeof entry === 'string')
|
|
.map((entry) => entry.trim())
|
|
.filter((entry) => entry.length > 0 && entry.length <= maxCampaignStringListLength)
|
|
)
|
|
].slice(0, maxCampaignStringListEntries);
|
|
}
|
|
|
|
function normalizeRewardStrings(value: unknown): string[] {
|
|
return [
|
|
...new Set(
|
|
arrayOrEmpty<unknown>(value)
|
|
.filter((entry): entry is string => typeof entry === 'string')
|
|
.map((entry) => entry.replace(/\s+/g, ' ').trim())
|
|
.filter((entry) => entry.length > 0 && entry.length <= maxCampaignStringListLength && Boolean(parseRewardLabel(entry)))
|
|
)
|
|
].slice(0, maxCampaignStringListEntries);
|
|
}
|
|
|
|
function uniqueByKey<T>(entries: T[], keyForEntry: (entry: T) => string) {
|
|
const seen = new Set<string>();
|
|
return entries.filter((entry) => {
|
|
const key = keyForEntry(entry);
|
|
if (!key || seen.has(key)) {
|
|
return false;
|
|
}
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function normalizeKeyString(value: unknown): string {
|
|
if (typeof value !== 'string') {
|
|
return '';
|
|
}
|
|
|
|
const text = value.trim();
|
|
return text.length > 0 && text.length <= maxCampaignStringListLength ? text : '';
|
|
}
|
|
|
|
function normalizeDisplayString(value: unknown, maxLength = maxCampaignDisplayTextLength): string {
|
|
if (typeof value !== 'string') {
|
|
return '';
|
|
}
|
|
|
|
return value.trim().slice(0, maxLength);
|
|
}
|
|
|
|
function normalizeLimitedArray<T>(value: unknown, normalizer: (entry: unknown) => T | undefined, maxEntries: number): T[] {
|
|
const normalized: T[] = [];
|
|
for (const entry of arrayOrEmpty<unknown>(value)) {
|
|
const normalizedEntry = normalizer(entry);
|
|
if (normalizedEntry) {
|
|
normalized.push(normalizedEntry);
|
|
if (normalized.length >= maxEntries) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function normalizedRosterUnitIds(roster: UnitData[]) {
|
|
return new Set(
|
|
roster
|
|
.map((unit) => unit.id)
|
|
.filter((unitId): unitId is string => typeof unitId === 'string' && unitId.length > 0)
|
|
);
|
|
}
|
|
|
|
function normalizeNonNegativeInteger(value: unknown) {
|
|
const numeric = Math.floor(Number(value));
|
|
return Number.isFinite(numeric) && numeric > 0 ? Math.min(numeric, maxCampaignNumericValue) : 0;
|
|
}
|
|
|
|
function normalizeCharacterLevel(value: unknown) {
|
|
return Math.min(99, Math.max(1, normalizeNonNegativeInteger(value)));
|
|
}
|
|
|
|
function normalizeCharacterExpForLevel(level: number, value: unknown) {
|
|
const exp = normalizeNonNegativeInteger(value);
|
|
return Math.min(exp, level >= 99 ? 100 : 99);
|
|
}
|
|
|
|
function normalizeBondLevel(value: unknown) {
|
|
return Math.min(100, Math.max(1, normalizeNonNegativeInteger(value)));
|
|
}
|
|
|
|
function normalizeBondExpForLevel(level: number, value: unknown) {
|
|
const exp = normalizeNonNegativeInteger(value);
|
|
return Math.min(exp, level >= 100 ? 100 : 99);
|
|
}
|
|
|
|
function normalizeCampaignTimestamp(value: unknown) {
|
|
if (typeof value !== 'string' || value.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return campaignTimestampMs(value) === undefined ? undefined : value;
|
|
}
|
|
|
|
function campaignTimestampMs(value: unknown) {
|
|
if (typeof value !== 'string' || value.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
const timestamp = Date.parse(value);
|
|
return Number.isFinite(timestamp) ? timestamp : undefined;
|
|
}
|
|
|
|
function normalizeInventory(value: unknown) {
|
|
return Object.entries(recordOrEmpty(value)).reduce<Record<string, number>>((inventory, [itemId, amount]) => {
|
|
const normalizedItemId = itemId.replace(/\s+/g, ' ').trim();
|
|
const normalizedAmount = normalizeNonNegativeInteger(amount);
|
|
if (normalizedItemId && normalizedItemId.length <= maxCampaignStringListLength && normalizedAmount > 0) {
|
|
const nextAmount = (inventory[normalizedItemId] ?? 0) + normalizedAmount;
|
|
const cap = campaignInventoryCaps[normalizedItemId] ?? maxCampaignUnknownInventoryAmount;
|
|
inventory[normalizedItemId] = Math.min(nextAmount, cap);
|
|
}
|
|
return inventory;
|
|
}, {});
|
|
}
|
|
|
|
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 };
|
|
}
|
|
|
|
function normalizeCampaignSortieResonanceSelection(
|
|
value: unknown,
|
|
bonds: readonly CampBondSnapshot[],
|
|
selectedSortieUnitIds: readonly string[]
|
|
): CampaignSortieResonanceSelection | undefined {
|
|
if (!isPlainObject(value)) {
|
|
return undefined;
|
|
}
|
|
const battleId = typeof value.battleId === 'string' ? value.battleId : '';
|
|
const bondId = normalizeKeyString(value.bondId);
|
|
if (!hasCampaignBattleScenario(battleId) || !bondId) {
|
|
return undefined;
|
|
}
|
|
const bond = resolveCampaignSortieResonanceBond(battleId, bondId, bonds);
|
|
const selectedUnitIds = new Set(selectedSortieUnitIds);
|
|
if (
|
|
!bond ||
|
|
bond.level < coreSortieResonanceMinLevel ||
|
|
!bond.unitIds.every((unitId) => selectedUnitIds.has(unitId))
|
|
) {
|
|
return undefined;
|
|
}
|
|
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
|
|
};
|
|
}
|
|
|
|
type CampaignSortieCooperationNormalizationOptions = {
|
|
allowedUnitIds?: ReadonlySet<string>;
|
|
allowedBondIds?: ReadonlySet<string>;
|
|
bonds?: readonly {
|
|
id: string;
|
|
unitIds: readonly string[];
|
|
}[];
|
|
};
|
|
|
|
export function normalizeCampaignSortieCooperationSnapshot(
|
|
value: unknown,
|
|
options: CampaignSortieCooperationNormalizationOptions = {}
|
|
): CampaignSortieCooperationSnapshot | undefined {
|
|
if (!isPlainObject(value) || value.version !== 1) {
|
|
return undefined;
|
|
}
|
|
const bondId = normalizeKeyString(value.bondId);
|
|
const rawUnitIds = arrayOrEmpty<unknown>(value.unitIds);
|
|
if (!bondId || rawUnitIds.length !== 2) {
|
|
return undefined;
|
|
}
|
|
const unitIds = rawUnitIds.map(normalizeKeyString);
|
|
if (
|
|
!unitIds[0] ||
|
|
!unitIds[1] ||
|
|
unitIds[0] === unitIds[1] ||
|
|
(options.allowedBondIds && !options.allowedBondIds.has(bondId)) ||
|
|
unitIds.some((unitId) => options.allowedUnitIds && !options.allowedUnitIds.has(unitId))
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
const matchingBond = options.bonds?.find((bond) => bond.id === bondId);
|
|
if (options.bonds && !matchingBond) {
|
|
return undefined;
|
|
}
|
|
if (
|
|
matchingBond &&
|
|
(
|
|
matchingBond.unitIds.length !== 2 ||
|
|
!matchingBond.unitIds.every((unitId) => unitIds.includes(unitId))
|
|
)
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
const attempts = normalizeNonNegativeInteger(value.attempts);
|
|
if (attempts <= 0) {
|
|
return undefined;
|
|
}
|
|
const normalizedUnitIds = matchingBond
|
|
? [matchingBond.unitIds[0], matchingBond.unitIds[1]] as [string, string]
|
|
: [unitIds[0], unitIds[1]] as [string, string];
|
|
const successes = Math.min(attempts, normalizeNonNegativeInteger(value.successes));
|
|
return {
|
|
version: 1,
|
|
bondId,
|
|
unitIds: normalizedUnitIds,
|
|
attempts,
|
|
successes,
|
|
additionalDamage: successes > 0
|
|
? normalizeNonNegativeInteger(value.additionalDamage)
|
|
: 0
|
|
};
|
|
}
|
|
|
|
function resolveCampaignSortieResonanceBond(
|
|
battleId: string,
|
|
bondId: string,
|
|
campaignBonds: readonly CampBondSnapshot[]
|
|
): BattleBond | undefined {
|
|
if (!hasCampaignBattleScenario(battleId)) {
|
|
return undefined;
|
|
}
|
|
const scenarioBond = battleScenarios[battleId].bonds.find((bond) => bond.id === bondId);
|
|
if (!scenarioBond) {
|
|
return undefined;
|
|
}
|
|
const campaignBond = campaignBonds.find((bond) => bond.id === bondId);
|
|
return campaignBond
|
|
? { ...scenarioBond, level: campaignBond.level, exp: campaignBond.exp }
|
|
: scenarioBond;
|
|
}
|
|
|
|
function hasCampaignBattleScenario(battleId: string): battleId is BattleScenarioId {
|
|
return Object.prototype.hasOwnProperty.call(battleScenarios, battleId);
|
|
}
|
|
|
|
export function normalizeCampaignSortieOrderResultSnapshot(
|
|
value: unknown,
|
|
outcome?: FirstBattleReport['outcome'],
|
|
commandContext?: CampaignSortieOrderCommandNormalizationContext
|
|
): 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);
|
|
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,
|
|
...(command ? { command } : {})
|
|
};
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
return results;
|
|
},
|
|
{}
|
|
);
|
|
if (Object.keys(normalizedByOrder).length > 0) {
|
|
history[battleId as BattleScenarioId] = normalizedByOrder;
|
|
}
|
|
return history;
|
|
}, {});
|
|
}
|
|
|
|
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
|
|
) {
|
|
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);
|
|
if (!normalizedSettlement) {
|
|
return history;
|
|
}
|
|
|
|
history[normalizedSettlement.battleId] = normalizedSettlement;
|
|
return history;
|
|
}, {});
|
|
}
|
|
|
|
function normalizeLatestBattleId(value: unknown, battleHistory: Record<string, CampaignBattleSettlement>) {
|
|
if (typeof value === 'string' && value in battleHistory) {
|
|
return value;
|
|
}
|
|
|
|
return Object.values(battleHistory)
|
|
.sort((a, b) => (campaignTimestampMs(b.completedAt) ?? 0) - (campaignTimestampMs(a.completedAt) ?? 0))[0]?.battleId;
|
|
}
|
|
|
|
function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rosterUnitIds: Set<string>): FirstBattleReport {
|
|
const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance, rosterUnitIds);
|
|
const sortiePerformanceUnchanged = sortiePerformanceSnapshotsShareRoster(report.sortiePerformance, sortiePerformance);
|
|
const sortieReview = sortiePerformanceUnchanged
|
|
? normalizeCampaignSortieReviewSnapshot(report.sortieReview, sortiePerformance)
|
|
: undefined;
|
|
const sortieOrder = sortieReview
|
|
? normalizeCampaignSortieOrderResultSnapshot(report.sortieOrder, report.outcome, {
|
|
performance: sortiePerformance,
|
|
turnNumber: report.turnNumber
|
|
})
|
|
: undefined;
|
|
const sortieRecommendation = sortiePerformance
|
|
? normalizeCampaignSortieRecommendationSnapshot(report.sortieRecommendation, {
|
|
expectedBattleId: report.battleId,
|
|
allowedUnitIds: rosterUnitIds
|
|
})
|
|
: undefined;
|
|
const filteredBonds = report.bonds.filter((bond) => bond.unitIds.every((unitId) => rosterUnitIds.has(unitId)));
|
|
const sortieCooperation = normalizeCampaignSortieCooperationSnapshot(report.sortieCooperation, {
|
|
allowedUnitIds: rosterUnitIds,
|
|
allowedBondIds: new Set(filteredBonds.map((bond) => bond.id)),
|
|
bonds: filteredBonds
|
|
});
|
|
const filtered = {
|
|
...report,
|
|
units: report.units.filter((unit) => unit.faction !== 'ally' || rosterUnitIds.has(unit.id)),
|
|
bonds: filteredBonds,
|
|
...(sortiePerformance ? { sortiePerformance } : {}),
|
|
...(sortieReview ? { sortieReview } : {}),
|
|
...(sortieOrder ? { sortieOrder } : {}),
|
|
...(sortieRecommendation ? { sortieRecommendation } : {}),
|
|
...(sortieCooperation ? { sortieCooperation } : {})
|
|
};
|
|
if (!sortiePerformance) {
|
|
delete filtered.sortiePerformance;
|
|
}
|
|
if (!sortieReview) {
|
|
delete filtered.sortieReview;
|
|
}
|
|
if (!sortieOrder) {
|
|
delete filtered.sortieOrder;
|
|
}
|
|
if (!sortieRecommendation) {
|
|
delete filtered.sortieRecommendation;
|
|
}
|
|
if (!sortieCooperation) {
|
|
delete filtered.sortieCooperation;
|
|
}
|
|
if (filtered.mvp && !rosterUnitIds.has(filtered.mvp.unitId)) {
|
|
delete filtered.mvp;
|
|
}
|
|
return filtered;
|
|
}
|
|
|
|
function filterBattleHistoryRosterReferences(
|
|
history: Record<string, CampaignBattleSettlement>,
|
|
rosterUnitIds: Set<string>,
|
|
campaignBonds: readonly CampBondSnapshot[]
|
|
): Record<string, CampaignBattleSettlement> {
|
|
const bondIds = new Set(campaignBonds.map((bond) => bond.id));
|
|
return Object.entries(history).reduce<Record<string, CampaignBattleSettlement>>((filteredHistory, [battleId, settlement]) => {
|
|
const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(settlement.sortiePerformance, rosterUnitIds);
|
|
const sortiePerformanceUnchanged = sortiePerformanceSnapshotsShareRoster(settlement.sortiePerformance, sortiePerformance);
|
|
const sortieReview = sortiePerformanceUnchanged
|
|
? normalizeCampaignSortieReviewSnapshot(settlement.sortieReview, sortiePerformance)
|
|
: undefined;
|
|
const sortieOrder = sortieReview
|
|
? normalizeCampaignSortieOrderResultSnapshot(settlement.sortieOrder, settlement.outcome, {
|
|
performance: sortiePerformance,
|
|
turnNumber: settlement.turnNumber
|
|
})
|
|
: undefined;
|
|
const sortieRecommendation = sortiePerformance
|
|
? normalizeCampaignSortieRecommendationSnapshot(settlement.sortieRecommendation, {
|
|
expectedBattleId: settlement.battleId,
|
|
allowedUnitIds: rosterUnitIds
|
|
})
|
|
: undefined;
|
|
const sortieCooperation = normalizeCampaignSortieCooperationSnapshot(settlement.sortieCooperation, {
|
|
allowedUnitIds: rosterUnitIds,
|
|
allowedBondIds: bondIds,
|
|
bonds: campaignBonds
|
|
});
|
|
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 } : {}),
|
|
...(sortieOrder ? { sortieOrder } : {}),
|
|
...(sortieRecommendation ? { sortieRecommendation } : {}),
|
|
...(sortieCooperation ? { sortieCooperation } : {})
|
|
};
|
|
if (!sortiePerformance) {
|
|
delete filteredHistory[battleId].sortiePerformance;
|
|
}
|
|
if (!sortieReview) {
|
|
delete filteredHistory[battleId].sortieReview;
|
|
}
|
|
if (!sortieOrder) {
|
|
delete filteredHistory[battleId].sortieOrder;
|
|
}
|
|
if (!sortieRecommendation) {
|
|
delete filteredHistory[battleId].sortieRecommendation;
|
|
}
|
|
if (!sortieCooperation) {
|
|
delete filteredHistory[battleId].sortieCooperation;
|
|
}
|
|
return filteredHistory;
|
|
}, {});
|
|
}
|
|
|
|
function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettlement | undefined {
|
|
if (!isPlainObject(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
const settlement = value as Partial<CampaignBattleSettlement>;
|
|
const battleId = typeof settlement.battleId === 'string' ? settlement.battleId : '';
|
|
const battleTitle = normalizeDisplayString(settlement.battleTitle) || battleScenarios[battleId as BattleScenarioId]?.title;
|
|
const completedAt = normalizeCampaignTimestamp(settlement.completedAt);
|
|
if (
|
|
battleId.length === 0 ||
|
|
!(battleId in battleScenarios) ||
|
|
!battleTitle ||
|
|
(settlement.outcome !== 'victory' && settlement.outcome !== 'defeat') ||
|
|
!completedAt
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
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, {
|
|
performance: sortiePerformance,
|
|
turnNumber
|
|
})
|
|
: undefined;
|
|
const units = normalizeLimitedArray(
|
|
settlement.units,
|
|
normalizeCampaignUnitProgressSnapshot,
|
|
maxCampaignBattleUnitEntries
|
|
);
|
|
const bonds = normalizeLimitedArray(
|
|
settlement.bonds,
|
|
normalizeCampaignBondProgressSnapshot,
|
|
maxCampaignBattleBondEntries
|
|
);
|
|
const sortieCooperation = normalizeCampaignSortieCooperationSnapshot(settlement.sortieCooperation, {
|
|
allowedUnitIds: new Set(units.map((unit) => unit.unitId)),
|
|
allowedBondIds: new Set(bonds.map((bond) => bond.id))
|
|
});
|
|
|
|
return {
|
|
battleId,
|
|
battleTitle,
|
|
outcome: settlement.outcome,
|
|
turnNumber,
|
|
rewardGold: normalizeNonNegativeInteger(settlement.rewardGold),
|
|
itemRewards: normalizeRewardStrings(settlement.itemRewards),
|
|
campaignRewards: cloneCampaignRewardSnapshot(settlement.campaignRewards, battleId),
|
|
objectives: normalizeLimitedArray(settlement.objectives, normalizeBattleObjectiveSnapshot, maxCampaignBattleObjectiveEntries),
|
|
units,
|
|
bonds,
|
|
reserveTraining: normalizeLimitedArray(settlement.reserveTraining, normalizeReserveTrainingSnapshot, maxCampaignReserveTrainingEntries),
|
|
...(sortiePerformance ? { sortiePerformance } : {}),
|
|
...(sortieReview ? { sortieReview } : {}),
|
|
...(sortieOrder ? { sortieOrder } : {}),
|
|
...(sortieRecommendation ? { sortieRecommendation } : {}),
|
|
...(sortieCooperation ? { sortieCooperation } : {}),
|
|
completedAt
|
|
};
|
|
}
|
|
|
|
function normalizeBattleObjectiveSnapshot(value: unknown): BattleObjectiveSnapshot | undefined {
|
|
if (!isPlainObject(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
const id = normalizeKeyString(value.id);
|
|
const label = normalizeDisplayString(value.label);
|
|
if (!id || !label) {
|
|
return undefined;
|
|
}
|
|
|
|
const status = value.status === 'active' || value.status === 'done' || value.status === 'failed' ? value.status : undefined;
|
|
const category = value.category === 'primary' || value.category === 'required' || value.category === 'bonus' ? value.category : undefined;
|
|
const targetTile = normalizeObjectiveTargetTile(value.targetTile);
|
|
const summary = normalizeDisplayString(value.summary);
|
|
const failureReason = normalizeDisplayString(value.failureReason);
|
|
return {
|
|
id,
|
|
label,
|
|
achieved: Boolean(value.achieved),
|
|
...(status ? { status } : {}),
|
|
detail: normalizeDisplayString(value.detail),
|
|
...(category ? { category } : {}),
|
|
...(summary ? { summary } : {}),
|
|
...(failureReason ? { failureReason } : {}),
|
|
...(targetTile ? { targetTile } : {}),
|
|
rewardGold: normalizeNonNegativeInteger(value.rewardGold)
|
|
};
|
|
}
|
|
|
|
function normalizeObjectiveTargetTile(value: unknown): BattleObjectiveSnapshot['targetTile'] | undefined {
|
|
if (!isPlainObject(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
const x = Math.floor(Number(value.x));
|
|
const y = Math.floor(Number(value.y));
|
|
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0) {
|
|
return undefined;
|
|
}
|
|
|
|
const radius = Math.min(normalizeNonNegativeInteger(value.radius), maxCampaignObjectiveTargetRadius);
|
|
return radius > 0 ? { x, y, radius } : { x, y };
|
|
}
|
|
|
|
function normalizeCampaignUnitProgressSnapshot(value: unknown): CampaignUnitProgressSnapshot | undefined {
|
|
if (!isPlainObject(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
const unitId = normalizeKeyString(value.unitId);
|
|
const name = normalizeDisplayString(value.name);
|
|
if (!unitId || !name) {
|
|
return undefined;
|
|
}
|
|
|
|
const hp = normalizeNonNegativeInteger(value.hp);
|
|
const maxHp = Math.max(hp, normalizeNonNegativeInteger(value.maxHp));
|
|
const level = normalizeCharacterLevel(value.level);
|
|
return {
|
|
unitId,
|
|
name,
|
|
level,
|
|
exp: normalizeCharacterExpForLevel(level, value.exp),
|
|
hp,
|
|
maxHp: Math.max(1, maxHp),
|
|
equipment: normalizeEquipmentSet(value.equipment)
|
|
};
|
|
}
|
|
|
|
function normalizeCampaignBondProgressSnapshot(value: unknown): CampaignBondProgressSnapshot | undefined {
|
|
if (!isPlainObject(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
const id = normalizeKeyString(value.id);
|
|
const title = normalizeDisplayString(value.title);
|
|
if (!id || !title) {
|
|
return undefined;
|
|
}
|
|
|
|
const level = normalizeBondLevel(value.level);
|
|
return {
|
|
id,
|
|
title,
|
|
level,
|
|
exp: normalizeBondExpForLevel(level, value.exp),
|
|
battleExp: normalizeNonNegativeInteger(value.battleExp)
|
|
};
|
|
}
|
|
|
|
function normalizeReserveTrainingSnapshot(value: unknown): CampaignReserveTrainingSnapshot | undefined {
|
|
if (!isPlainObject(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
const unitId = normalizeKeyString(value.unitId);
|
|
const name = normalizeDisplayString(value.name);
|
|
const focusLabel = normalizeDisplayString(value.focusLabel);
|
|
if (!unitId || !name) {
|
|
return undefined;
|
|
}
|
|
|
|
const level = normalizeCharacterLevel(value.level);
|
|
return {
|
|
unitId,
|
|
name,
|
|
expGained: normalizeNonNegativeInteger(value.expGained),
|
|
equipmentExpGained: normalizeNonNegativeInteger(value.equipmentExpGained),
|
|
bondExpGained: normalizeNonNegativeInteger(value.bondExpGained),
|
|
focusId: normalizeReserveTrainingFocusId(typeof value.focusId === 'string' ? value.focusId : undefined),
|
|
...(focusLabel ? { focusLabel } : {}),
|
|
level,
|
|
exp: normalizeCharacterExpForLevel(level, value.exp)
|
|
};
|
|
}
|
|
|
|
export function normalizeCampaignSortieItemAssignments(assignments?: Record<string, unknown>, allowedUnitIds?: Set<string>) {
|
|
return Object.entries(assignments ?? {}).reduce<CampaignSortieItemAssignments>((next, [unitId, stocks]) => {
|
|
const normalizedUnitId = normalizeKeyString(unitId);
|
|
if (
|
|
!normalizedUnitId ||
|
|
(allowedUnitIds && !allowedUnitIds.has(normalizedUnitId)) ||
|
|
(!allowedUnitIds && Object.keys(next).length >= maxCampaignSortieAssignmentUnits)
|
|
) {
|
|
return next;
|
|
}
|
|
|
|
const unitStocks = Object.entries(recordOrEmpty(stocks)).reduce<Record<string, number>>((nextStocks, [itemId, amount]) => {
|
|
const normalizedItemId = normalizeKeyString(itemId);
|
|
const maxPerUnit = sortieSupplyMaxByUsableId[normalizedItemId];
|
|
if (!maxPerUnit) {
|
|
return nextStocks;
|
|
}
|
|
const normalizedAmount = normalizeNonNegativeInteger(amount);
|
|
if (normalizedItemId && normalizedAmount > 0) {
|
|
nextStocks[normalizedItemId] = Math.min(maxPerUnit, normalizedAmount);
|
|
}
|
|
return nextStocks;
|
|
}, {});
|
|
|
|
if (Object.keys(unitStocks).length > 0) {
|
|
next[normalizedUnitId] = {
|
|
...(next[normalizedUnitId] ?? {}),
|
|
...unitStocks
|
|
};
|
|
}
|
|
return next;
|
|
}, {});
|
|
}
|
|
|
|
export function normalizeCampaignSortieFormationPresets(value: unknown, allowedUnitIds?: Set<string>) {
|
|
const source = recordOrEmpty(value);
|
|
return campaignSortiePresetIds.reduce<CampaignSortieFormationPresets>((presets, presetId) => {
|
|
const candidate = source[presetId];
|
|
if (!isPlainObject(candidate)) {
|
|
return presets;
|
|
}
|
|
|
|
const selectedUnitIds = uniqueStrings(candidate.selectedUnitIds)
|
|
.filter((unitId) => !allowedUnitIds || allowedUnitIds.has(unitId))
|
|
.slice(0, maxCampaignSortieAssignmentUnits);
|
|
if (selectedUnitIds.length === 0) {
|
|
return presets;
|
|
}
|
|
|
|
presets[presetId] = {
|
|
selectedUnitIds,
|
|
formationAssignments: normalizeSortieFormationAssignments(
|
|
recordOrEmpty(candidate.formationAssignments),
|
|
new Set(selectedUnitIds)
|
|
)
|
|
};
|
|
return presets;
|
|
}, {});
|
|
}
|
|
|
|
export function normalizeCampaignSortiePerformanceSnapshot(
|
|
value: unknown,
|
|
allowedUnitIds?: Set<string>
|
|
): CampaignSortiePerformanceSnapshot | undefined {
|
|
if (!isPlainObject(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
const selectedUnitIds = uniqueStrings(value.selectedUnitIds)
|
|
.filter((unitId) => !allowedUnitIds || allowedUnitIds.has(unitId))
|
|
.slice(0, maxCampaignSortieAssignmentUnits);
|
|
if (selectedUnitIds.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
const selectedIdSet = new Set(selectedUnitIds);
|
|
const unitStats = uniqueByKey(
|
|
normalizeLimitedArray(value.unitStats, (entry): CampaignSortieUnitPerformanceSnapshot | undefined => {
|
|
if (!isPlainObject(entry)) {
|
|
return undefined;
|
|
}
|
|
const unitId = normalizeKeyString(entry.unitId);
|
|
if (!unitId || !selectedIdSet.has(unitId)) {
|
|
return undefined;
|
|
}
|
|
const hpBefore = normalizeNonNegativeInteger(entry.hpBefore);
|
|
const strategyUses = normalizeNonNegativeInteger(entry.strategyUses);
|
|
return {
|
|
unitId,
|
|
hpBefore,
|
|
maxHpBefore: Math.max(hpBefore, normalizeNonNegativeInteger(entry.maxHpBefore)),
|
|
damageDealt: normalizeNonNegativeInteger(entry.damageDealt),
|
|
damageTaken: normalizeNonNegativeInteger(entry.damageTaken),
|
|
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),
|
|
sortieExtendedBondTriggers: normalizeNonNegativeInteger(entry.sortieExtendedBondTriggers),
|
|
intentDefeats: normalizeNonNegativeInteger(entry.intentDefeats),
|
|
intentLineBreaks: normalizeNonNegativeInteger(entry.intentLineBreaks),
|
|
intentGuardSuccesses: normalizeNonNegativeInteger(entry.intentGuardSuccesses)
|
|
};
|
|
}, maxCampaignSortieAssignmentUnits),
|
|
(entry) => entry.unitId
|
|
);
|
|
|
|
return {
|
|
selectedUnitIds,
|
|
formationAssignments: normalizeSortieFormationAssignments(recordOrEmpty(value.formationAssignments), selectedIdSet),
|
|
unitStats
|
|
};
|
|
}
|
|
|
|
export function normalizeCampaignSortieReviewSnapshot(
|
|
value: unknown,
|
|
performance?: CampaignSortiePerformanceSnapshot
|
|
): CampaignSortieReviewSnapshot | undefined {
|
|
if (!isPlainObject(value) || value.version !== 1 || !campaignSortiePerformanceSupportsReview(performance)) {
|
|
return undefined;
|
|
}
|
|
|
|
const score = Math.min(100, normalizeNonNegativeInteger(value.score));
|
|
const grade: CampaignSortieReviewGrade = score >= 90 ? 'S' : score >= 75 ? 'A' : score >= 60 ? 'B' : score >= 45 ? 'C' : 'D';
|
|
const sourcePresetIdSet = new Set(arrayOrEmpty<unknown>(value.sourcePresetIds));
|
|
const sourcePresetIds = campaignSortiePresetIds.filter((presetId) => sourcePresetIdSet.has(presetId));
|
|
|
|
return {
|
|
version: 1,
|
|
score,
|
|
grade,
|
|
activeBondCount: Math.min(maxCampaignBattleBondEntries, normalizeNonNegativeInteger(value.activeBondCount)),
|
|
enemyCount: Math.min(maxCampaignBattleUnitEntries, normalizeNonNegativeInteger(value.enemyCount)),
|
|
sourcePresetIds
|
|
};
|
|
}
|
|
|
|
function campaignSortiePerformanceSupportsReview(
|
|
performance?: CampaignSortiePerformanceSnapshot
|
|
): performance is CampaignSortiePerformanceSnapshot {
|
|
if (!performance || performance.selectedUnitIds.length === 0) {
|
|
return false;
|
|
}
|
|
const statsUnitIds = new Set(performance.unitStats.map((stats) => stats.unitId));
|
|
return performance.selectedUnitIds.every((unitId) => (
|
|
Boolean(performance.formationAssignments[unitId]) && statsUnitIds.has(unitId)
|
|
));
|
|
}
|
|
|
|
function sortiePerformanceSnapshotsShareRoster(
|
|
source?: CampaignSortiePerformanceSnapshot,
|
|
filtered?: CampaignSortiePerformanceSnapshot
|
|
) {
|
|
return Boolean(
|
|
source &&
|
|
filtered &&
|
|
source.selectedUnitIds.length === filtered.selectedUnitIds.length &&
|
|
source.selectedUnitIds.every((unitId, index) => filtered.selectedUnitIds[index] === unitId)
|
|
);
|
|
}
|
|
|
|
function normalizeReserveTrainingFocusId(focusId?: string): CampaignReserveTrainingFocusId {
|
|
return isReserveTrainingFocusId(focusId)
|
|
? focusId as CampaignReserveTrainingFocusId
|
|
: defaultCampaignReserveTrainingFocusId;
|
|
}
|
|
|
|
function isReserveTrainingFocusId(value: unknown): value is CampaignReserveTrainingFocusId {
|
|
return typeof value === 'string' && campaignReserveTrainingFocusDefinitions.some((focus) => focus.id === value);
|
|
}
|
|
|
|
export function isCampaignStep(value: unknown): value is CampaignStep {
|
|
return typeof value === 'string' && campaignStepIds.has(value as CampaignStep);
|
|
}
|
|
|
|
function normalizeCampaignStep(step: unknown): CampaignStep {
|
|
return isCampaignStep(step) ? step : 'new';
|
|
}
|
|
|
|
function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefined {
|
|
if (!isPlainObject(report)) {
|
|
return undefined;
|
|
}
|
|
|
|
const battleId = typeof report.battleId === 'string' ? report.battleId : '';
|
|
if (
|
|
battleId.length === 0 ||
|
|
!(battleId in battleScenarios) ||
|
|
(report.outcome !== 'victory' && report.outcome !== 'defeat')
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
const battleTitle = normalizeDisplayString(report.battleTitle) || battleScenarios[battleId as BattleScenarioId]?.title;
|
|
if (!battleTitle) {
|
|
return undefined;
|
|
}
|
|
|
|
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, {
|
|
performance: sortiePerformance,
|
|
turnNumber
|
|
})
|
|
: undefined;
|
|
const units = normalizeLimitedArray(
|
|
report.units,
|
|
normalizeUnitDataSnapshot,
|
|
maxCampaignBattleUnitEntries
|
|
);
|
|
const bonds = normalizeLimitedArray(
|
|
report.bonds,
|
|
normalizeCampBondSnapshot,
|
|
maxCampaignBattleBondEntries
|
|
);
|
|
const sortieCooperation = normalizeCampaignSortieCooperationSnapshot(report.sortieCooperation, {
|
|
allowedUnitIds: new Set(
|
|
units.filter((unit) => unit.faction === 'ally').map((unit) => unit.id)
|
|
),
|
|
allowedBondIds: new Set(bonds.map((bond) => bond.id)),
|
|
bonds
|
|
});
|
|
|
|
return {
|
|
battleId,
|
|
battleTitle,
|
|
outcome: report.outcome,
|
|
turnNumber,
|
|
rewardGold: normalizeNonNegativeInteger(report.rewardGold),
|
|
defeatedEnemies: normalizeNonNegativeInteger(report.defeatedEnemies),
|
|
totalEnemies: normalizeNonNegativeInteger(report.totalEnemies),
|
|
objectives: normalizeLimitedArray(report.objectives, normalizeBattleObjectiveSnapshot, maxCampaignBattleObjectiveEntries),
|
|
units,
|
|
bonds,
|
|
mvp: normalizeCampMvpSnapshot(report.mvp),
|
|
itemRewards: normalizeRewardStrings(report.itemRewards),
|
|
campaignRewards: cloneCampaignRewardSnapshot(
|
|
isPlainObject(report.campaignRewards) ? report.campaignRewards as CampaignRewardSnapshot : undefined,
|
|
battleId
|
|
),
|
|
...(sortiePerformance ? { sortiePerformance } : {}),
|
|
...(sortieReview ? { sortieReview } : {}),
|
|
...(sortieOrder ? { sortieOrder } : {}),
|
|
...(sortieRecommendation ? { sortieRecommendation } : {}),
|
|
...(sortieCooperation ? { sortieCooperation } : {}),
|
|
completedCampDialogues: uniqueCampHistoryIds(report.completedCampDialogues),
|
|
completedCampVisits: uniqueCampHistoryIds(report.completedCampVisits),
|
|
createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
function normalizeUnitDataSnapshot(value: unknown): UnitData | undefined {
|
|
if (!isPlainObject(value) || (value.faction !== 'ally' && value.faction !== 'enemy')) {
|
|
return undefined;
|
|
}
|
|
|
|
const id = normalizeKeyString(value.id);
|
|
const name = normalizeDisplayString(value.name);
|
|
if (!id || !name) {
|
|
return undefined;
|
|
}
|
|
|
|
const stats = recordOrEmpty(value.stats);
|
|
const hp = normalizeNonNegativeInteger(value.hp);
|
|
const maxHp = Math.max(hp, normalizeNonNegativeInteger(value.maxHp));
|
|
const level = normalizeCharacterLevel(value.level);
|
|
return {
|
|
id,
|
|
name,
|
|
faction: value.faction,
|
|
className: normalizeDisplayString(value.className),
|
|
classKey: normalizeUnitClassKey(value.classKey),
|
|
level,
|
|
exp: normalizeCharacterExpForLevel(level, value.exp),
|
|
hp,
|
|
maxHp: Math.max(1, maxHp),
|
|
attack: normalizeNonNegativeInteger(value.attack),
|
|
move: normalizeNonNegativeInteger(value.move),
|
|
stats: {
|
|
might: normalizeNonNegativeInteger(stats.might),
|
|
intelligence: normalizeNonNegativeInteger(stats.intelligence),
|
|
leadership: normalizeNonNegativeInteger(stats.leadership),
|
|
agility: normalizeNonNegativeInteger(stats.agility),
|
|
luck: normalizeNonNegativeInteger(stats.luck)
|
|
},
|
|
equipment: normalizeEquipmentSet(value.equipment),
|
|
x: normalizeNonNegativeInteger(value.x),
|
|
y: normalizeNonNegativeInteger(value.y)
|
|
};
|
|
}
|
|
|
|
function normalizeUnitClassKey(value: unknown): UnitData['classKey'] {
|
|
return typeof value === 'string' && value in unitClasses ? value as UnitData['classKey'] : 'infantry';
|
|
}
|
|
|
|
function normalizeEquipmentSet(value: unknown): UnitData['equipment'] {
|
|
const equipmentRecord = recordOrEmpty(value);
|
|
return equipmentSlots.reduce<UnitData['equipment']>((equipment, slot) => {
|
|
const state = recordOrEmpty(equipmentRecord[slot]);
|
|
const itemId =
|
|
typeof state.itemId === 'string' && itemCatalog[state.itemId]?.slot === slot
|
|
? state.itemId
|
|
: defaultEquipmentBySlot[slot];
|
|
const level = normalizeEquipmentLevel(state.level);
|
|
equipment[slot] = {
|
|
itemId,
|
|
level,
|
|
exp: normalizeEquipmentExpForLevel(level, state.exp)
|
|
};
|
|
return equipment;
|
|
}, {} as UnitData['equipment']);
|
|
}
|
|
|
|
function normalizeEquipmentLevel(value: unknown) {
|
|
const level = normalizeNonNegativeInteger(value);
|
|
return Math.min(9, Math.max(1, level));
|
|
}
|
|
|
|
function normalizeEquipmentExpForLevel(level: number, value: unknown) {
|
|
const exp = normalizeNonNegativeInteger(value);
|
|
const next = equipmentExpToNext(level);
|
|
return Math.min(exp, level >= 9 ? next : Math.max(0, next - 1));
|
|
}
|
|
|
|
function normalizeCampMvpSnapshot(value: unknown): CampMvpSnapshot | undefined {
|
|
if (!isPlainObject(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
const unitId = normalizeKeyString(value.unitId);
|
|
const name = normalizeDisplayString(value.name);
|
|
if (!unitId || !name) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
unitId,
|
|
name,
|
|
damageDealt: normalizeNonNegativeInteger(value.damageDealt),
|
|
defeats: normalizeNonNegativeInteger(value.defeats)
|
|
};
|
|
}
|
|
|
|
function normalizeCampBondSnapshot(value: unknown): CampBondSnapshot | undefined {
|
|
if (!isPlainObject(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
const unitIds = arrayOrEmpty<unknown>(value.unitIds).filter((unitId): unitId is string => typeof unitId === 'string' && unitId.length > 0);
|
|
const id = normalizeKeyString(value.id);
|
|
const title = normalizeDisplayString(value.title);
|
|
if (!id || !title || unitIds.length < 2 || unitIds[0] === unitIds[1]) {
|
|
return undefined;
|
|
}
|
|
|
|
const level = normalizeBondLevel(value.level);
|
|
return {
|
|
...(value as CampBondSnapshot),
|
|
id,
|
|
title,
|
|
unitIds: [unitIds[0], unitIds[1]] as [string, string],
|
|
level,
|
|
exp: normalizeBondExpForLevel(level, value.exp),
|
|
battleExp: normalizeNonNegativeInteger(value.battleExp)
|
|
};
|
|
}
|
|
|
|
function reserveTrainingFocusDefinition(focusId?: string) {
|
|
const normalizedId = normalizeReserveTrainingFocusId(focusId);
|
|
return campaignReserveTrainingFocusDefinitions.find((focus) => focus.id === normalizedId) ?? campaignReserveTrainingFocusDefinitions[0];
|
|
}
|
|
|
|
function readStoredCampaignState(slot?: number) {
|
|
const parsed = readStoredCampaignStateObject(slot);
|
|
return parsed ? normalizeCampaignState(parsed) : undefined;
|
|
}
|
|
|
|
function readStoredCampaignStateObject(slot?: number) {
|
|
const raw = tryStorage()?.getItem(slot === undefined ? campaignStorageKey : campaignSaveSlotKey(slot));
|
|
if (!raw) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(raw) as Partial<CampaignState> | null;
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
return undefined;
|
|
}
|
|
if (parsed.version !== undefined && parsed.version !== 1) {
|
|
return undefined;
|
|
}
|
|
return parsed;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function persistCampaignState(state: CampaignState) {
|
|
const storage = tryStorage();
|
|
storage?.setItem(campaignStorageKey, JSON.stringify(state));
|
|
storage?.setItem(campaignSaveSlotKey(state.activeSaveSlot), JSON.stringify(state));
|
|
}
|
|
|
|
function tryStorage() {
|
|
if (typeof window === 'undefined') {
|
|
return undefined;
|
|
}
|
|
return window.localStorage;
|
|
}
|
|
|
|
function campaignSaveSlotKey(slot: number) {
|
|
return `${campaignStorageKey}:slot-${normalizeSlot(slot)}`;
|
|
}
|
|
|
|
function normalizeSlot(slot: number | undefined) {
|
|
if (!slot || Number.isNaN(slot)) {
|
|
return 1;
|
|
}
|
|
return Math.min(campaignSaveSlotCount, Math.max(1, Math.floor(slot)));
|
|
}
|
|
|
|
function readLatestSlottedCampaignState() {
|
|
let latestSlot: number | undefined;
|
|
let latestUpdatedAt = Number.NEGATIVE_INFINITY;
|
|
|
|
for (let slot = 1; slot <= campaignSaveSlotCount; slot += 1) {
|
|
const timestamp = campaignTimestampMs(readStoredCampaignStateObject(slot)?.updatedAt);
|
|
if (timestamp !== undefined && timestamp > latestUpdatedAt) {
|
|
latestSlot = slot;
|
|
latestUpdatedAt = timestamp;
|
|
}
|
|
}
|
|
|
|
return latestSlot ? readStoredCampaignState(latestSlot) : undefined;
|
|
}
|
|
|
|
function advanceBond(bond: CampBondSnapshot, amount: number) {
|
|
const gained = normalizeNonNegativeInteger(amount);
|
|
if (gained <= 0) {
|
|
return;
|
|
}
|
|
|
|
bond.battleExp += gained;
|
|
let exp = bond.exp + gained;
|
|
while (bond.level < 100 && exp >= 100) {
|
|
exp -= 100;
|
|
bond.level += 1;
|
|
}
|
|
bond.exp = bond.level >= 100 ? Math.min(exp, 100) : exp;
|
|
}
|
|
|
|
function createBattleSettlement(report: FirstBattleReport, reserveTraining: CampaignReserveTrainingSnapshot[] = []): CampaignBattleSettlement {
|
|
return {
|
|
battleId: report.battleId,
|
|
battleTitle: report.battleTitle,
|
|
outcome: report.outcome,
|
|
turnNumber: report.turnNumber,
|
|
rewardGold: report.rewardGold,
|
|
itemRewards: [...report.itemRewards],
|
|
campaignRewards: cloneCampaignRewardSnapshot(report.campaignRewards, report.battleId),
|
|
objectives: report.objectives.map((objective) => ({ ...objective })),
|
|
units: report.units
|
|
.filter((unit) => unit.faction === 'ally')
|
|
.map((unit) => ({
|
|
unitId: unit.id,
|
|
name: unit.name,
|
|
level: unit.level,
|
|
exp: unit.exp,
|
|
hp: unit.hp,
|
|
maxHp: unit.maxHp,
|
|
equipment: cloneUnit(unit).equipment
|
|
})),
|
|
bonds: report.bonds.map((bond) => ({
|
|
id: bond.id,
|
|
title: bond.title,
|
|
level: bond.level,
|
|
exp: bond.exp,
|
|
battleExp: bond.battleExp
|
|
})),
|
|
reserveTraining: reserveTraining.map((entry) => ({ ...entry })),
|
|
...(report.sortiePerformance
|
|
? {
|
|
sortiePerformance: {
|
|
selectedUnitIds: [...report.sortiePerformance.selectedUnitIds],
|
|
formationAssignments: { ...report.sortiePerformance.formationAssignments },
|
|
unitStats: report.sortiePerformance.unitStats.map((stats) => ({ ...stats }))
|
|
}
|
|
}
|
|
: {}),
|
|
...(report.sortieReview
|
|
? {
|
|
sortieReview: {
|
|
...report.sortieReview,
|
|
sourcePresetIds: [...report.sortieReview.sourcePresetIds]
|
|
}
|
|
}
|
|
: {}),
|
|
...(report.sortieOrder
|
|
? {
|
|
sortieOrder: {
|
|
...report.sortieOrder,
|
|
progress: report.sortieOrder.progress.map((entry) => ({ ...entry })),
|
|
...(report.sortieOrder.command ? { command: { ...report.sortieOrder.command } } : {})
|
|
}
|
|
}
|
|
: {}),
|
|
...(report.sortieRecommendation
|
|
? { sortieRecommendation: cloneCampaignSortieRecommendationSnapshot(report.sortieRecommendation) }
|
|
: {}),
|
|
...(report.sortieCooperation
|
|
? { sortieCooperation: cloneCampaignSortieCooperationSnapshot(report.sortieCooperation) }
|
|
: {}),
|
|
completedAt: report.createdAt
|
|
};
|
|
}
|
|
|
|
function applyRewardDelta(inventory: Record<string, number>, rewards: string[], direction: 1 | -1) {
|
|
return rewards.reduce<Record<string, number>>((nextInventory, reward) => {
|
|
const parsed = parseRewardLabel(reward);
|
|
if (!parsed) {
|
|
return nextInventory;
|
|
}
|
|
|
|
const { label, amount } = parsed;
|
|
const nextAmount = Math.max(0, (nextInventory[label] ?? 0) + amount * direction);
|
|
const cap = direction === 1 ? campaignInventoryCaps[label] : undefined;
|
|
nextInventory[label] = cap ? Math.min(nextAmount, cap) : nextAmount;
|
|
if (nextInventory[label] === 0) {
|
|
delete nextInventory[label];
|
|
}
|
|
return nextInventory;
|
|
}, { ...inventory });
|
|
}
|
|
|
|
function mergeRosterProgress(currentRoster: UnitData[], deployedUnits: UnitData[]) {
|
|
const merged = new Map<string, UnitData>();
|
|
currentRoster.forEach((unit) => merged.set(unit.id, cloneUnit(unit)));
|
|
deployedUnits.forEach((unit) => merged.set(unit.id, cloneUnit(unit)));
|
|
return Array.from(merged.values());
|
|
}
|
|
|
|
function applyReserveTrainingProgress(
|
|
roster: UnitData[],
|
|
bonds: CampBondSnapshot[],
|
|
deployedUnits: UnitData[],
|
|
focusId: CampaignReserveTrainingFocusId,
|
|
reserveTrainingAssignments: CampaignReserveTrainingAssignments = {}
|
|
): CampaignReserveTrainingSnapshot[] {
|
|
const focus = reserveTrainingFocusDefinition(focusId);
|
|
const deployedIds = new Set(deployedUnits.map((unit) => unit.id));
|
|
const reserveUnits = roster.filter((unit) => unit.faction === 'ally' && !deployedIds.has(unit.id));
|
|
const reserveIds = new Set(reserveUnits.map((unit) => unit.id));
|
|
const focusByUnitId = new Map(
|
|
reserveUnits.map((unit) => [unit.id, reserveTrainingFocusDefinition(reserveTrainingAssignments[unit.id] ?? focus.id)])
|
|
);
|
|
const bondExpByUnit = new Map<string, number>();
|
|
|
|
if (reserveIds.size > 0) {
|
|
bonds.forEach((bond) => {
|
|
const reservePartners = bond.unitIds
|
|
.filter((unitId) => reserveIds.has(unitId))
|
|
.map((unitId) => ({ unitId, bondExpGained: focusByUnitId.get(unitId)?.bondExpGained ?? 0 }))
|
|
.filter((partner) => partner.bondExpGained > 0);
|
|
if (reservePartners.length === 0) {
|
|
return;
|
|
}
|
|
advanceBond(bond, Math.max(...reservePartners.map((partner) => partner.bondExpGained)));
|
|
reservePartners.forEach((partner) => {
|
|
bondExpByUnit.set(partner.unitId, (bondExpByUnit.get(partner.unitId) ?? 0) + partner.bondExpGained);
|
|
});
|
|
});
|
|
}
|
|
|
|
return reserveUnits
|
|
.map((unit) => {
|
|
const unitFocus = focusByUnitId.get(unit.id) ?? focus;
|
|
const expGained = unitFocus.expGained;
|
|
const equipmentExpGained = unitFocus.equipmentExpGained;
|
|
advanceUnitExp(unit, expGained);
|
|
advanceReserveEquipment(unit, equipmentExpGained);
|
|
return {
|
|
unitId: unit.id,
|
|
name: unit.name,
|
|
expGained,
|
|
equipmentExpGained,
|
|
bondExpGained: bondExpByUnit.get(unit.id) ?? 0,
|
|
focusId: unitFocus.id,
|
|
focusLabel: unitFocus.label,
|
|
level: unit.level,
|
|
exp: unit.exp
|
|
};
|
|
});
|
|
}
|
|
|
|
function advanceUnitExp(unit: UnitData, amount: number) {
|
|
let exp = unit.exp + amount;
|
|
while (unit.level < 99 && exp >= 100) {
|
|
exp -= 100;
|
|
unit.level += 1;
|
|
unit.maxHp += 2;
|
|
unit.hp = Math.min(unit.maxHp, unit.hp + 2);
|
|
unit.attack += 1;
|
|
}
|
|
unit.exp = unit.level >= 99 ? Math.min(exp, 100) : exp;
|
|
}
|
|
|
|
function advanceReserveEquipment(unit: UnitData, amount: number) {
|
|
equipmentSlots.forEach((slot) => {
|
|
const state = unit.equipment[slot];
|
|
let exp = state.exp + amount;
|
|
while (state.level < 9 && exp >= equipmentExpToNext(state.level)) {
|
|
exp -= equipmentExpToNext(state.level);
|
|
state.level += 1;
|
|
}
|
|
state.exp = state.level >= 9 ? Math.min(exp, equipmentExpToNext(state.level)) : exp;
|
|
});
|
|
}
|
|
|
|
function parseRewardLabel(reward: string) {
|
|
const normalized = reward.replace(/\s+/g, ' ').trim();
|
|
if (!normalized) {
|
|
return undefined;
|
|
}
|
|
|
|
const match = normalized.match(/^(.*?)(?:\s*([+xX-]?)\s*(\d+))$/);
|
|
|
|
if (!match) {
|
|
return { label: normalized, amount: 1 };
|
|
}
|
|
|
|
const amount = Number(match[3] ?? 1) * (match[2] === '-' ? -1 : 1);
|
|
const label = match[1].trim();
|
|
if (!label || !Number.isSafeInteger(amount) || amount <= 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return { label, amount };
|
|
}
|
|
|
|
function cloneCampaignState(state: CampaignState): CampaignState {
|
|
return JSON.parse(JSON.stringify(state)) as CampaignState;
|
|
}
|
|
|
|
function normalizeActiveCityStayId(
|
|
cityStayId: unknown,
|
|
latestBattleId: string | undefined,
|
|
step: CampaignStep
|
|
): CityStayId | undefined {
|
|
if (typeof cityStayId !== 'string' || !latestBattleId || !isCampCampaignStep(step)) {
|
|
return undefined;
|
|
}
|
|
if (!(latestBattleId in battleScenarios)) {
|
|
return undefined;
|
|
}
|
|
|
|
const cityStay = findCityStayAfterBattle(latestBattleId as BattleScenarioId);
|
|
const expectedCampStep = cityStay
|
|
? campaignBattleSteps[cityStay.afterBattleId]?.victory
|
|
: undefined;
|
|
return cityStay?.id === cityStayId && expectedCampStep === step
|
|
? cityStay.id
|
|
: undefined;
|
|
}
|
|
|
|
function cloneReport(report: FirstBattleReport): FirstBattleReport {
|
|
const cloned = JSON.parse(JSON.stringify(report)) as FirstBattleReport;
|
|
cloned.itemRewards = normalizeRewardStrings(cloned.itemRewards);
|
|
cloned.completedCampDialogues = uniqueCampHistoryIds(cloned.completedCampDialogues);
|
|
cloned.completedCampVisits = uniqueCampHistoryIds(cloned.completedCampVisits);
|
|
if (cloned.campaignRewards) {
|
|
cloned.campaignRewards = cloneCampaignRewardSnapshot(cloned.campaignRewards, cloned.battleId);
|
|
}
|
|
if (cloned.sortieCooperation) {
|
|
cloned.sortieCooperation = cloneCampaignSortieCooperationSnapshot(cloned.sortieCooperation);
|
|
}
|
|
return cloned;
|
|
}
|
|
|
|
export function cloneCampaignSortieRecommendationSnapshot(snapshot: CampaignSortieRecommendationSnapshot) {
|
|
return {
|
|
...snapshot,
|
|
selectedUnitIds: [...snapshot.selectedUnitIds],
|
|
formationAssignments: { ...snapshot.formationAssignments },
|
|
unitReasons: { ...snapshot.unitReasons }
|
|
} satisfies CampaignSortieRecommendationSnapshot;
|
|
}
|
|
|
|
export function cloneCampaignSortieCooperationSnapshot(snapshot: CampaignSortieCooperationSnapshot) {
|
|
return {
|
|
...snapshot,
|
|
unitIds: [...snapshot.unitIds] as [string, string]
|
|
} satisfies CampaignSortieCooperationSnapshot;
|
|
}
|
|
|
|
function cloneCampaignRewardSnapshot(rewards?: CampaignRewardSnapshot, battleId?: string): CampaignRewardSnapshot | undefined {
|
|
if (!rewards) {
|
|
return undefined;
|
|
}
|
|
|
|
const rewardRecord = recordOrEmpty(rewards);
|
|
const note = typeof rewardRecord.note === 'string' ? rewardRecord.note.trim() : '';
|
|
return {
|
|
supplies: normalizeRewardStrings(rewardRecord.supplies),
|
|
equipment: normalizeRewardStrings(rewardRecord.equipment),
|
|
reputation: normalizeRewardStrings(rewardRecord.reputation),
|
|
recruits: uniqueByKey(
|
|
arrayOrEmpty<unknown>(rewardRecord.recruits)
|
|
.filter(isPlainObject)
|
|
.map((recruit) => ({
|
|
unitId: normalizeKeyString(recruit.unitId),
|
|
name: normalizeDisplayString(recruit.name)
|
|
}))
|
|
.filter((recruit) => recruit.unitId && recruit.name && isKnownCampaignRewardRecruit(recruit.unitId, battleId)),
|
|
(recruit) => recruit.unitId
|
|
)
|
|
.slice(0, maxCampaignStringListEntries),
|
|
unlocks: uniqueByKey(
|
|
arrayOrEmpty<unknown>(rewardRecord.unlocks)
|
|
.filter(isPlainObject)
|
|
.map((unlock) => ({
|
|
battleId: normalizeKeyString(unlock.battleId),
|
|
title: normalizeDisplayString(unlock.title)
|
|
}))
|
|
.filter((unlock) => unlock.battleId && unlock.title && unlock.battleId in battleScenarios),
|
|
(unlock) => unlock.battleId
|
|
)
|
|
.slice(0, maxCampaignStringListEntries),
|
|
note: note.length > 0 && note.length <= maxCampaignRewardNoteLength ? note : undefined
|
|
};
|
|
}
|
|
|
|
function isKnownCampaignRewardRecruit(unitId: string, battleId?: string) {
|
|
if (campaignRecruitUnitById.has(unitId)) {
|
|
return true;
|
|
}
|
|
|
|
if (!battleId || !(battleId in battleScenarios)) {
|
|
return false;
|
|
}
|
|
|
|
return battleScenarios[battleId as BattleScenarioId].units.some((unit) => unit.faction === 'ally' && unit.id === unitId);
|
|
}
|
|
|
|
function cloneUnit(unit: UnitData): UnitData {
|
|
return JSON.parse(JSON.stringify(unit)) as UnitData;
|
|
}
|
|
|
|
function cloneBondSnapshot(bond: CampBondSnapshot): CampBondSnapshot {
|
|
return {
|
|
...bond,
|
|
unitIds: [...bond.unitIds] as [string, string]
|
|
};
|
|
}
|
|
|
|
function createCampBondSnapshot(bond: BattleBond): CampBondSnapshot {
|
|
return {
|
|
...bond,
|
|
unitIds: [...bond.unitIds] as [string, string],
|
|
battleExp: 0
|
|
};
|
|
}
|