From 053536fd20306cf9814a0fc5ec0d41472484c9f0 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Thu, 9 Jul 2026 13:36:59 +0900 Subject: [PATCH] Prepare reserve drill assignment state --- .../verify-campaign-save-normalization.mjs | 55 ++++++++++++++++++- src/game/state/campaignState.ts | 33 ++++++++++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index 718363d..ec09a57 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -32,6 +32,7 @@ try { getCampaignState, hasCampaignSave, loadCampaignState, + normalizeCampaignReserveTrainingAssignments, normalizeCampaignSortieItemAssignments, resetCampaignState, setCampaignState, @@ -104,6 +105,35 @@ try { cappedLaunchFormation['unit-96'] === undefined, `Expected launch sortie formation assignments without a roster filter to stay capped: ${JSON.stringify(cappedLaunchFormation)}` ); + const normalizedReserveDrills = normalizeCampaignReserveTrainingAssignments( + { + 'liu-bei': 'bond-practice', + ' liu-bei ': 'class-practice', + 'guan-yu': 'class-practice', + 'ghost-unit': 'balanced', + 'zhang-fei': 'broken-focus', + ['x'.repeat(97)]: 'balanced', + '': 'balanced' + }, + new Set(['liu-bei', 'guan-yu']) + ); + assert( + normalizedReserveDrills['liu-bei'] === 'bond-practice' && + normalizedReserveDrills['guan-yu'] === 'class-practice' && + normalizedReserveDrills['ghost-unit'] === undefined && + normalizedReserveDrills['zhang-fei'] === undefined && + normalizedReserveDrills['x'.repeat(97)] === undefined, + `Expected reserve drill assignments to trim, filter, and preserve first valid focus ids: ${JSON.stringify(normalizedReserveDrills)}` + ); + const cappedReserveDrills = normalizeCampaignReserveTrainingAssignments( + Object.fromEntries(Array.from({ length: 120 }, (_, index) => [`unit-${index}`, 'balanced'])) + ); + assert( + Object.keys(cappedReserveDrills).length === 96 && + cappedReserveDrills['unit-95'] === 'balanced' && + cappedReserveDrills['unit-96'] === undefined, + `Expected reserve drill assignments without a roster filter to stay capped: ${JSON.stringify(cappedReserveDrills)}` + ); resetCampaignState(); const firstScenario = battleScenarios['first-battle-zhuo-commandery']; @@ -325,7 +355,8 @@ try { assert( Array.isArray(legacy.selectedSortieUnitIds) && typeof legacy.sortieFormationAssignments === 'object' && - typeof legacy.sortieItemAssignments === 'object', + typeof legacy.sortieItemAssignments === 'object' && + typeof legacy.reserveTrainingAssignments === 'object', `Expected modern sortie fields to be restored: ${JSON.stringify(legacy)}` ); @@ -360,6 +391,13 @@ try { }, selectedSortieUnitIds: { corrupted: true }, sortieFormationAssignments: 'front', + reserveTrainingFocus: 'ghost-focus', + reserveTrainingAssignments: { + 'liu-bei': 'bond-practice', + 'guan-yu': 'unknown-focus', + ['x'.repeat(97)]: 'balanced', + '': 'balanced' + }, sortieItemAssignments: { 'liu-bei': { ' bean ': '5', wine: -1, salve: '1', stone: '9', ['x'.repeat(97)]: '1' }, 'guan-yu': '2', @@ -407,6 +445,13 @@ try { Object.keys(malformed.sortieFormationAssignments).length === 0, `Expected malformed inventory and sortie assignments to normalize: ${JSON.stringify(malformed)}` ); + assert( + malformed.reserveTrainingFocus === 'balanced' && + malformed.reserveTrainingAssignments['liu-bei'] === 'bond-practice' && + malformed.reserveTrainingAssignments['guan-yu'] === undefined && + malformed.reserveTrainingAssignments['x'.repeat(97)] === undefined, + `Expected malformed reserve drill focus fields to normalize: ${JSON.stringify(malformed)}` + ); storage.clear(); storage.set( @@ -445,6 +490,11 @@ try { 'guan-yu': 'support', broken: 'center' }, + reserveTrainingAssignments: { + 'liu-bei': 'bond-practice', + 'ghost-unit': 'balanced', + 'guan-yu': 'class-practice' + }, sortieItemAssignments: { 'liu-bei': { bean: '2' }, 'ghost-unit': { bean: '1' }, @@ -458,6 +508,9 @@ try { staleSortie.selectedSortieUnitIds[0] === 'liu-bei' && staleSortie.sortieFormationAssignments['liu-bei'] === 'front' && staleSortie.sortieFormationAssignments['ghost-unit'] === undefined && + staleSortie.reserveTrainingAssignments['liu-bei'] === 'bond-practice' && + staleSortie.reserveTrainingAssignments['ghost-unit'] === undefined && + staleSortie.reserveTrainingAssignments['guan-yu'] === undefined && staleSortie.sortieItemAssignments['liu-bei']?.bean === 2 && staleSortie.sortieItemAssignments['ghost-unit'] === undefined && staleSortie.sortieItemAssignments['guan-yu'] === undefined, diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 7512085..002daa2 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -246,6 +246,8 @@ export type CampaignReserveTrainingFocusDefinition = { bondExpGained: number; }; +export type CampaignReserveTrainingAssignments = Partial>; + export const defaultCampaignReserveTrainingFocusId: CampaignReserveTrainingFocusId = 'balanced'; const defaultEquipmentBySlot: Record = { @@ -274,6 +276,7 @@ const maxCampaignBattleObjectiveEntries = 24; const maxCampaignBattleUnitEntries = 96; const maxCampaignBattleBondEntries = 96; const maxCampaignReserveTrainingEntries = 96; +const maxCampaignReserveTrainingAssignmentUnits = 96; const maxCampaignSortieAssignmentUnits = 96; const maxCampaignDisplayTextLength = 180; const maxCampaignNumericValue = 999999; @@ -335,6 +338,7 @@ export type CampaignState = { sortieFormationAssignments: SortieFormationAssignments; sortieItemAssignments: CampaignSortieItemAssignments; reserveTrainingFocus: CampaignReserveTrainingFocusId; + reserveTrainingAssignments: CampaignReserveTrainingAssignments; completedCampDialogues: string[]; completedCampVisits: string[]; battleHistory: Record; @@ -535,6 +539,7 @@ export function createInitialCampaignState(): CampaignState { sortieFormationAssignments: {}, sortieItemAssignments: {}, reserveTrainingFocus: defaultCampaignReserveTrainingFocusId, + reserveTrainingAssignments: {}, completedCampDialogues: [], completedCampVisits: [], battleHistory: {} @@ -623,6 +628,27 @@ export function setCampaignReserveTrainingFocus(focusId: CampaignReserveTraining return cloneCampaignState(state); } +export function normalizeCampaignReserveTrainingAssignments( + assignments?: Record, + allowedUnitIds?: Set +): CampaignReserveTrainingAssignments { + return Object.entries(assignments ?? {}).reduce((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); @@ -867,6 +893,7 @@ function normalizeCampaignState(state: Partial): CampaignState { normalized.sortieFormationAssignments = normalizeSortieFormationAssignments(recordOrEmpty(normalized.sortieFormationAssignments), sortieUnitFilter); normalized.sortieItemAssignments = normalizeCampaignSortieItemAssignments(recordOrEmpty(normalized.sortieItemAssignments), sortieUnitFilter); normalized.reserveTrainingFocus = normalizeReserveTrainingFocusId(normalized.reserveTrainingFocus); + normalized.reserveTrainingAssignments = normalizeCampaignReserveTrainingAssignments(recordOrEmpty(normalized.reserveTrainingAssignments), sortieUnitFilter); normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory); if (sortieUnitFilter) { normalized.battleHistory = filterBattleHistoryRosterReferences( @@ -1256,11 +1283,15 @@ export function normalizeCampaignSortieItemAssignments(assignments?: Record focus.id === focusId) + 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); }