From 8591fd7fa36ec951b770aec7b1eef359992af2f9 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Tue, 23 Jun 2026 17:34:45 +0900 Subject: [PATCH] Add selectable reserve drill focus --- docs/roadmap.md | 4 +- scripts/verify-flow.mjs | 55 ++++++++++++++-- src/game/scenes/CampScene.ts | 92 ++++++++++++++++++++++++-- src/game/state/campaignState.ts | 112 ++++++++++++++++++++++++++++++-- 4 files changed, 242 insertions(+), 21 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index b2f5823..a8ff8dd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -68,7 +68,7 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of - Forty-second battle Jing rear-crisis route, using Ma Liang and Mi Zhu's rear-guard roles across an 84x66 Jiangling ferry battlefield to repel Lu Meng's disguised scouts and expose Mi Fang's wavering rear line - Camp progress timeline tab that summarizes Liu Bei's long campaign arc, completed battles, current chapter, latest battle, and next major chapter - Tactical sortie preparation panel with battle-specific sortie limits, recommended officers with reasons, class role, named equipment, core stats, bond partner, next-map terrain suitability, deployment preview, formation roles, active bond count, recruited-officer count, reserve roster summary, and readiness advice for each deployable officer -- Officer collection support in camp, including full roster status, selected/reserve/recommended markers, and post-battle reserve training growth for benched officers +- Officer collection support in camp, including full roster status, selected/reserve/recommended markers, selectable reserve drill focus, and post-battle reserve training growth for benched officers - Treasure equipment effects wired into battle previews and resolution, including named weapon damage bonuses, defensive treasure mitigation, support recovery bonuses, turn-start charm recovery, and equipment-specific growth bonuses - Flow verification script from title through the forty-second battle victory, King of Hanzhong council milestone, Shu-Han foundation milestone, recruit sortie selection, Liu Biao visit rewards, Zhuge Liang recruitment, Bowang/Changban/Jiangdong/Red Cliffs/Jing Province/Guiyang/Wuling/Changsha/Yi Province/Fu Pass/Luo Castle/Luofeng/Luo main gate/Mianzhu/Chengdu/Jiameng/Yangping/Dingjun/Hanzhong/Jing defense/Fan Castle/Han River/Fan siege/Jing rear camp states, campaign timeline state, and camp save state @@ -76,7 +76,7 @@ Build a small complete tactical RPG loop that can grow into a longer Romance of 1. Build the Jing Province rear-collapse follow-up, using Lu Meng's infiltration, Mi Fang/Fu Shiren's wavering, and Guan Yu's overextended Fan Castle front as the next campaign tension 2. Expand Jing rear-crisis aftermath events, including warehouse trust, river rumor choices, officer loyalty checks, and supply decisions that affect sortie recommendations -3. Expand sortie selection into richer pre-battle officer planning, including explicit reserve drills, class practice, and bond-focused camp assignments +3. Expand reserve planning into individual officer assignments, including targeted class practice, paired bond training, and camp task slots 4. Add a dedicated treasure/equipment management view so players can compare special effects and growth 5. Keep expanding scenarios through the long campaign instead of treating the current slice as an ending diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index 8c27ab7..548f866 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -18,7 +18,10 @@ try { await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 }); await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined, undefined, { timeout: 90000 }); await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 }); - await page.waitForTimeout(500); + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('TitleScene'); + }, undefined, { timeout: 90000 }); const debugBeforeBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []); if (!debugBeforeBattle.includes('TitleScene')) { @@ -5480,6 +5483,7 @@ try { await clickSortieRosterUnit(page, unitId); } } + await clickReserveTrainingFocus(page, 'bond-practice'); const jingRearSortieReadyState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( @@ -5489,9 +5493,12 @@ try { !jingRearSortieReadyState.sortieRoster?.some((unit) => unit.id === 'liu-bei' && unit.selected && unit.required) || !jingRearSortieReadyState.sortieRoster?.some((unit) => unit.id === 'guan-yu' && unit.selected && unit.required) || jingRearSortieReadyState.sortiePlan?.selectedCount !== 7 || - jingRearSortieReadyState.sortiePlan?.recommendedSelectedCount < 7 + jingRearSortieReadyState.sortiePlan?.recommendedSelectedCount < 7 || + jingRearSortieReadyState.reserveTrainingFocus?.id !== 'bond-practice' || + !jingRearSortieReadyState.sortiePlan?.reserveTrainingLine?.includes('공명 합숙') || + jingRearSortieReadyState.sortiePlan?.reserveTrainingBondPreview !== 4 ) { - throw new Error(`Expected Jing rear sortie to deploy Liu Bei, Guan Yu, Ma Liang, Mi Zhu, Zhao Yun, Huang Quan, and Wang Ping: ${JSON.stringify(jingRearSortieReadyState)}`); + throw new Error(`Expected Jing rear sortie to deploy Liu Bei, Guan Yu, Ma Liang, Mi Zhu, Zhao Yun, Huang Quan, Wang Ping, and bond-focused reserve drill: ${JSON.stringify(jingRearSortieReadyState)}`); } await page.screenshot({ path: 'dist/verification-jing-rear-sortie.png', fullPage: true }); @@ -5563,9 +5570,13 @@ try { !fortySecondCampState.availableDialogueIds.every((id) => id.endsWith('jing-rear')) || fortySecondCampState.availableVisitIds?.length !== 2 || !fortySecondCampState.availableVisitIds.every((id) => id.includes('jing-rear')) || - fortySecondCampState.rosterCollection?.total < 22 + fortySecondCampState.rosterCollection?.total < 22 || + fortySecondCampState.campaign?.reserveTrainingFocus !== 'bond-practice' || + !fortySecondCampState.reserveTrainingAwards?.some( + (entry) => entry.focusId === 'bond-practice' && entry.focusLabel === '공명 합숙' && entry.bondExpGained > 0 + ) ) { - throw new Error(`Expected forty-second camp to expose Jing rear dialogue/visit sets and preserve the full officer roster: ${JSON.stringify(fortySecondCampState)}`); + throw new Error(`Expected forty-second camp to expose Jing rear dialogue/visit sets, preserve the full officer roster, and record bond-focused reserve training: ${JSON.stringify(fortySecondCampState)}`); } await page.screenshot({ path: 'dist/verification-jing-rear-camp.png', fullPage: true }); @@ -5706,15 +5717,28 @@ function assertSortieTacticalRoster(state, requiredUnitIds) { !state.sortiePlan || !state.sortiePlan.deploymentLine?.includes('전열') || !state.sortiePlan.objectiveLine || + !state.sortiePlan.reserveTrainingLine?.includes('대기 훈련') || !state.sortiePlan.recommendationLine?.includes('추천') || !Array.isArray(state.sortiePlan.formationSlots) || state.sortiePlan.formationSlots.length < 3 || typeof state.sortiePlan.activeBondCount !== 'number' || typeof state.sortiePlan.terrainScore !== 'number' || typeof state.sortiePlan.recommendedSelectedCount !== 'number' || - typeof state.sortiePlan.recommendedTotal !== 'number' + typeof state.sortiePlan.recommendedTotal !== 'number' || + typeof state.sortiePlan.reserveTrainingExpPreview !== 'number' || + typeof state.sortiePlan.reserveTrainingEquipmentPreview !== 'number' || + typeof state.sortiePlan.reserveTrainingBondPreview !== 'number' ) { - throw new Error(`Expected sortie preparation to expose deployment plan, bond count, and terrain score: ${JSON.stringify(state.sortiePlan)}`); + throw new Error(`Expected sortie preparation to expose deployment plan, reserve training, bond count, and terrain score: ${JSON.stringify(state.sortiePlan)}`); + } + + if ( + !state.reserveTrainingFocus || + !Array.isArray(state.reserveTrainingFocusOptions) || + state.reserveTrainingFocusOptions.length !== 3 || + !state.reserveTrainingFocusOptions.some((focus) => focus.id === 'bond-practice' && focus.bondExpGained > 0) + ) { + throw new Error(`Expected sortie preparation to expose selectable reserve drill focuses: ${JSON.stringify(state.reserveTrainingFocusOptions)}`); } const recommendedUnits = state.sortieRoster.filter((unit) => unit.recommended); @@ -5739,3 +5763,20 @@ async function clickSortieRosterUnit(page, unitId) { await page.mouse.click(254, 314 + (denseRoster ? 48 : 50) + index * rowGap); await page.waitForTimeout(120); } + +async function clickReserveTrainingFocus(page, focusId) { + const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if (!state?.sortieVisible || !Array.isArray(state.reserveTrainingFocusOptions)) { + throw new Error(`Cannot click reserve drill ${focusId}; sortie reserve drill selector is not visible: ${JSON.stringify(state)}`); + } + + const index = state.reserveTrainingFocusOptions.findIndex((focus) => focus.id === focusId); + if (index < 0) { + throw new Error(`Cannot click reserve drill ${focusId}; focus is missing: ${JSON.stringify(state.reserveTrainingFocusOptions)}`); + } + + const selectorX = 574; + const buttonWidth = Math.floor((314 - 40) / state.reserveTrainingFocusOptions.length); + await page.mouse.click(selectorX + 12 + index * (buttonWidth + 8) + buttonWidth / 2, 655); + await page.waitForTimeout(120); +} diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index f7cbb93..baefa72 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -53,12 +53,17 @@ import { import { applyCampVisitReward, applyCampBondExp, + campaignReserveTrainingFocusDefinitions, + defaultCampaignReserveTrainingFocusId, ensureCampaignRosterUnits, getCampaignState, getFirstBattleReport, markCampaignStep, saveCampaignState, + setCampaignReserveTrainingFocus, type CampaignState, + type CampaignReserveTrainingFocusDefinition, + type CampaignReserveTrainingFocusId, type FirstBattleReport } from '../state/campaignState'; import { palette } from '../ui/palette'; @@ -175,6 +180,12 @@ type SortiePlanSummary = { recommendationLine: string; recruitedLine: string; reserveLine: string; + reserveTrainingLine: string; + reserveTrainingFocusId: CampaignReserveTrainingFocusId; + reserveTrainingFocusLabel: string; + reserveTrainingExpPreview: number; + reserveTrainingEquipmentPreview: number; + reserveTrainingBondPreview: number; objectiveLine: string; formationSlots: SortieFormationSlot[]; warnings: string[]; @@ -7085,7 +7096,8 @@ export class CampScene extends Phaser.Scene { this.renderSortieChecklist(x + 576, y + 104, 578, 168, depth + 2); this.renderSortieUnitSummary(x + 34, y + 292, 728, 286, depth + 2); this.renderSortieFormationPlan(x + 782, y + 292, 372, 286, depth + 2); - this.renderSortieRewardHint(x + 34, y + 596, 808, 52, depth + 2); + this.renderSortieRewardHint(x + 34, y + 596, 480, 52, depth + 2); + this.renderSortieReserveTraining(x + 528, y + 596, 314, 52, depth + 2); this.addSortieButton('군영으로', x + width - 298, y + height - 42, 124, () => { soundDirector.playSelect(); @@ -7205,7 +7217,7 @@ export class CampScene extends Phaser.Scene { const summaryLines = [ plan.deploymentLine, - `${plan.recommendationLine} · 공명 ${plan.activeBondCount}개`, + `${plan.recommendationLine} · 공명 ${plan.activeBondCount}개 · ${plan.reserveTrainingFocusLabel}`, plan.recruitedLine, plan.objectiveLine ]; @@ -7416,6 +7428,7 @@ export class CampScene extends Phaser.Scene { const selectedIds = new Set(selectedUnits.map((unit) => unit.id)); const scenario = this.nextSortieScenario(); const rule = this.nextSortieRule(scenario); + const reserveFocus = this.reserveTrainingFocusDefinition(); const availableIds = new Set(allAllies.map((unit) => unit.id)); const recommended = rule.recommended.filter((entry) => availableIds.has(entry.unitId)); const missingRecommended = recommended.filter((entry) => !selectedIds.has(entry.unitId)); @@ -7484,6 +7497,15 @@ export class CampScene extends Phaser.Scene { ? `합류 무장 ${selectedRecruitedCount}/${recruitedUnits.length} · 대기 ${reserveUnits.length}` : '합류 무장 없음', reserveLine: reserveUnits.length > 0 ? `대기 무장: ${reserveNames}` : '전원 출전 중입니다.', + reserveTrainingLine: + reserveUnits.length > 0 + ? `대기 훈련 ${reserveFocus.label}: 경험 +${reserveFocus.expGained}, 장비 +${reserveFocus.equipmentExpGained}${reserveFocus.bondExpGained ? `, 공명 +${reserveFocus.bondExpGained}` : ''}` + : '대기 훈련 대상 없음', + reserveTrainingFocusId: reserveFocus.id, + reserveTrainingFocusLabel: reserveFocus.label, + reserveTrainingExpPreview: reserveFocus.expGained, + reserveTrainingEquipmentPreview: reserveFocus.equipmentExpGained, + reserveTrainingBondPreview: reserveFocus.bondExpGained, objectiveLine: scenario ? `${scenario.title} · ${scenario.victoryConditionLabel}` : '다음 전투 정보 없음', formationSlots, warnings @@ -7557,6 +7579,43 @@ export class CampScene extends Phaser.Scene { ).setDepth(depth + 1); } + private renderSortieReserveTraining(x: number, y: number, width: number, height: number, depth: number) { + const bg = this.trackSortie(this.add.rectangle(x, y, width, height, 0x151f2a, 0.9)); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(1, palette.blue, 0.4); + + const focus = this.reserveTrainingFocusDefinition(); + const reserveCount = this.sortiePlanSummary().reserveCount; + this.trackSortie(this.add.text(x + 12, y + 7, '대기 훈련', this.textStyle(12, '#f2e3bf', true))).setDepth(depth + 1); + this.trackSortie( + this.add.text( + x + width - 12, + y + 7, + reserveCount > 0 ? `${reserveCount}명 · ${focus.label}` : '대기 없음', + this.textStyle(11, reserveCount > 0 ? '#d8b15f' : '#9fb0bf', true) + ) + ).setOrigin(1, 0).setDepth(depth + 1); + + const buttonWidth = Math.floor((width - 40) / campaignReserveTrainingFocusDefinitions.length); + campaignReserveTrainingFocusDefinitions.forEach((option, index) => { + const buttonX = x + 12 + index * (buttonWidth + 8); + const selected = option.id === focus.id; + const button = this.trackSortie(this.add.rectangle(buttonX, y + 30, buttonWidth, 16, selected ? 0x263a2d : 0x0e151d, selected ? 0.98 : 0.86)); + button.setOrigin(0); + button.setDepth(depth + 1); + button.setStrokeStyle(1, selected ? palette.green : 0x53606c, selected ? 0.78 : 0.42); + button.setInteractive({ useHandCursor: true }); + button.on('pointerdown', () => this.setReserveTrainingFocus(option.id)); + + const label = this.trackSortie(this.add.text(buttonX + buttonWidth / 2, y + 33, option.label, this.textStyle(10, selected ? '#a8ffd0' : '#c8d2dd', true))); + label.setOrigin(0.5, 0); + label.setDepth(depth + 2); + label.setInteractive({ useHandCursor: true }); + label.on('pointerdown', () => this.setReserveTrainingFocus(option.id)); + }); + } + private nextSortieBriefing() { const flow = this.currentSortieFlow(); return { @@ -7570,6 +7629,17 @@ export class CampScene extends Phaser.Scene { return getSortieFlow(this.campaign?.latestBattleId, this.campaign?.step); } + private reserveTrainingFocusDefinition(): CampaignReserveTrainingFocusDefinition { + const focusId = this.campaign?.reserveTrainingFocus ?? defaultCampaignReserveTrainingFocusId; + return campaignReserveTrainingFocusDefinitions.find((focus) => focus.id === focusId) ?? campaignReserveTrainingFocusDefinitions[0]; + } + + private setReserveTrainingFocus(focusId: CampaignReserveTrainingFocusId) { + this.campaign = setCampaignReserveTrainingFocus(focusId); + soundDirector.playSelect(); + this.showSortiePrep(); + } + private sortieChecklist(): SortieChecklistItem[] { const units = this.currentUnits().filter((unit) => unit.faction === 'ally'); const liuBei = units.find((unit) => unit.id === 'liu-bei'); @@ -7739,7 +7809,9 @@ export class CampScene extends Phaser.Scene { const textX = compact ? x + 102 : x + 116; this.track(this.add.text(textX, y + (compact ? 7 : 17), `${unit.name} Lv ${unit.level}`, this.textStyle(compact ? 14 : 20, '#f2e3bf', true))); this.track(this.add.text(textX, y + (compact ? 27 : 47), `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(compact ? 10 : 13, '#d4dce6'))); - const expLine = training ? `경험 ${unit.exp}/100 · 대기 +${training.expGained}` : `경험 ${unit.exp}/100`; + const expLine = training + ? `경험 ${unit.exp}/100 · ${training.focusLabel ?? '대기'} +${training.expGained}` + : `경험 ${unit.exp}/100`; this.track(this.add.text(textX, y + (compact ? 40 : 72), expLine, this.textStyle(compact ? 10 : 13, training ? '#a8ffd0' : '#d8b15f', true))); this.drawBar(textX, y + cardHeight - (compact ? 10 : 17), compact ? 142 : 176, compact ? 5 : 8, unit.exp / 100, training ? palette.green : palette.gold); const badgeColor = rosterStatus.tone === 'selected' ? '#a8ffd0' : rosterStatus.tone === 'recommended' ? '#ffdf7b' : '#9fb0bf'; @@ -7760,7 +7832,7 @@ export class CampScene extends Phaser.Scene { this.add.text( x + width - 12, y + 6, - recentTrainingCount > 0 ? `최근 훈련 ${recentTrainingCount}` : '훈련 대기', + recentTrainingCount > 0 ? `최근 훈련 ${recentTrainingCount} · ${this.latestReserveTrainingFocusLabel()}` : '훈련 대기', this.textStyle(11, recentTrainingCount > 0 ? '#a8ffd0' : '#9fb0bf', true) ) ); @@ -8009,6 +8081,10 @@ export class CampScene extends Phaser.Scene { return this.campaign?.battleHistory[latestBattleId]?.reserveTraining ?? []; } + private latestReserveTrainingFocusLabel() { + return this.latestReserveTrainingAwards()[0]?.focusLabel ?? '대기'; + } + private latestReserveTraining(unitId: string) { return this.latestReserveTrainingAwards().find((entry) => entry.unitId === unitId); } @@ -8026,6 +8102,7 @@ export class CampScene extends Phaser.Scene { reserveCount: Math.max(0, allies.length - selectedCount), recruitedCount, averageLevel, + reserveTrainingFocus: this.reserveTrainingFocusDefinition(), reserveTrainingCount: reserveTrainingAwards.length, reserveTrainingExp: reserveTrainingAwards.reduce((sum, entry) => sum + entry.expGained, 0) }; @@ -8594,7 +8671,9 @@ export class CampScene extends Phaser.Scene { bg.setOrigin(0); bg.setStrokeStyle(1, training ? palette.green : palette.blue, training ? 0.5 : 0.34); const planText = recommendation ? `추천: ${recommendation.reason}` : `${roleLabel} 역할로 편성 가능`; - const trainingText = training ? `최근 대기 훈련 경험 +${training.expGained}, 장비 +${training.equipmentExpGained}` : '대기 중에도 전투 후 소량의 훈련 성장이 반영됩니다.'; + const trainingText = training + ? `최근 ${training.focusLabel ?? '대기 훈련'} 경험 +${training.expGained}, 장비 +${training.equipmentExpGained}${training.bondExpGained ? `, 공명 +${training.bondExpGained}` : ''}` + : '대기 중에도 전투 후 선택한 훈련 방침의 성장이 반영됩니다.'; this.track(this.add.text(x + 12, y + 5, `${status.label} · ${roleLabel}`, this.textStyle(11, status.tone === 'reserve' ? '#9fb0bf' : '#f2e3bf', true))); this.track(this.add.text(x + 112, y + 5, this.compactText(planText, 48), this.textStyle(11, '#d4dce6'))); const trainingLabel = this.track(this.add.text(x + width - 12, y + 5, this.compactText(trainingText, 28), this.textStyle(11, training ? '#a8ffd0' : '#9fb0bf', true))); @@ -8888,6 +8967,8 @@ export class CampScene extends Phaser.Scene { selectedDialogueId: this.selectedDialogueId, selectedVisitId: this.selectedVisitId, selectedSortieUnitIds: [...this.selectedSortieUnitIds], + reserveTrainingFocus: this.reserveTrainingFocusDefinition(), + reserveTrainingFocusOptions: campaignReserveTrainingFocusDefinitions.map((focus) => ({ ...focus })), sortieRoster: this.sortieAllies().map((unit) => { const summary = this.sortieUnitTacticalSummary(unit); return { @@ -8923,6 +9004,7 @@ export class CampScene extends Phaser.Scene { gold: this.campaign.gold, inventory: this.campaign.inventory, selectedSortieUnitIds: this.campaign.selectedSortieUnitIds, + reserveTrainingFocus: this.campaign.reserveTrainingFocus, roster: this.campaign.roster.map((unit) => ({ id: unit.id, name: unit.name, diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 8995018..9f555a4 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -152,10 +152,53 @@ export type CampaignReserveTrainingSnapshot = { 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 const defaultCampaignReserveTrainingFocusId: CampaignReserveTrainingFocusId = 'balanced'; + +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; @@ -179,6 +222,7 @@ export type CampaignState = { bonds: CampBondSnapshot[]; inventory: Record; selectedSortieUnitIds: string[]; + reserveTrainingFocus: CampaignReserveTrainingFocusId; completedCampDialogues: string[]; completedCampVisits: string[]; battleHistory: Record; @@ -256,6 +300,7 @@ export function createInitialCampaignState(): CampaignState { bonds: [], inventory: {}, selectedSortieUnitIds: [], + reserveTrainingFocus: defaultCampaignReserveTrainingFocusId, completedCampDialogues: [], completedCampVisits: [], battleHistory: {} @@ -331,6 +376,14 @@ export function markCampaignStep(step: CampaignStep) { return cloneCampaignState(state); } +export function setCampaignReserveTrainingFocus(focusId: CampaignReserveTrainingFocusId) { + const state = ensureCampaignState(); + state.reserveTrainingFocus = normalizeReserveTrainingFocusId(focusId); + state.updatedAt = new Date().toISOString(); + persistCampaignState(state); + return cloneCampaignState(state); +} + export function setFirstBattleReport(report: FirstBattleReport) { const state = ensureCampaignState(); const reportClone = cloneReport(report); @@ -350,10 +403,18 @@ export function setFirstBattleReport(report: FirstBattleReport) { state.step = reportClone.outcome === 'victory' ? victoryStep : retryStep; state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold); state.roster = mergeRosterProgress(state.roster, reportClone.units.filter((unit) => unit.faction === 'ally')); + state.bonds = reportClone.bonds.map(cloneBondSnapshot); const reserveTraining = previousSettlement || reportClone.outcome !== 'victory' ? previousSettlement?.reserveTraining ?? [] - : applyReserveTrainingProgress(state.roster, reportClone.units.filter((unit) => unit.faction === 'ally')); - state.bonds = reportClone.bonds.map(cloneBondSnapshot); + : applyReserveTrainingProgress( + state.roster, + state.bonds, + reportClone.units.filter((unit) => unit.faction === 'ally'), + state.reserveTrainingFocus + ); + 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); @@ -516,6 +577,7 @@ function normalizeCampaignState(state: CampaignState): CampaignState { normalized.completedCampVisits = [...new Set(normalized.completedCampVisits ?? [])]; normalized.inventory = { ...(normalized.inventory ?? {}) }; normalized.selectedSortieUnitIds = [...new Set(normalized.selectedSortieUnitIds ?? [])]; + normalized.reserveTrainingFocus = normalizeReserveTrainingFocusId(normalized.reserveTrainingFocus); normalized.battleHistory = normalized.battleHistory ?? {}; if (normalized.firstBattleReport) { normalized.firstBattleReport = cloneReport(normalized.firstBattleReport); @@ -523,6 +585,17 @@ function normalizeCampaignState(state: CampaignState): CampaignState { return normalized; } +function normalizeReserveTrainingFocusId(focusId?: string): CampaignReserveTrainingFocusId { + return campaignReserveTrainingFocusDefinitions.some((focus) => focus.id === focusId) + ? focusId as CampaignReserveTrainingFocusId + : defaultCampaignReserveTrainingFocusId; +} + +function reserveTrainingFocusDefinition(focusId?: string) { + const normalizedId = normalizeReserveTrainingFocusId(focusId); + return campaignReserveTrainingFocusDefinitions.find((focus) => focus.id === normalizedId) ?? campaignReserveTrainingFocusDefinitions[0]; +} + function readStoredCampaignState(slot?: number) { const raw = tryStorage()?.getItem(slot ? campaignSaveSlotKey(slot) : campaignStorageKey); if (!raw) { @@ -629,13 +702,35 @@ function mergeRosterProgress(currentRoster: UnitData[], deployedUnits: UnitData[ return Array.from(merged.values()); } -function applyReserveTrainingProgress(roster: UnitData[], deployedUnits: UnitData[]): CampaignReserveTrainingSnapshot[] { +function applyReserveTrainingProgress( + roster: UnitData[], + bonds: CampBondSnapshot[], + deployedUnits: UnitData[], + focusId: CampaignReserveTrainingFocusId +): CampaignReserveTrainingSnapshot[] { + const focus = reserveTrainingFocusDefinition(focusId); const deployedIds = new Set(deployedUnits.map((unit) => unit.id)); - return roster - .filter((unit) => unit.faction === 'ally' && !deployedIds.has(unit.id)) + const reserveUnits = roster.filter((unit) => unit.faction === 'ally' && !deployedIds.has(unit.id)); + const reserveIds = new Set(reserveUnits.map((unit) => unit.id)); + const bondExpByUnit = new Map(); + + if (focus.bondExpGained > 0 && reserveIds.size > 0) { + bonds.forEach((bond) => { + const reservePartners = bond.unitIds.filter((unitId) => reserveIds.has(unitId)); + if (reservePartners.length === 0) { + return; + } + advanceBond(bond, focus.bondExpGained); + reservePartners.forEach((unitId) => { + bondExpByUnit.set(unitId, (bondExpByUnit.get(unitId) ?? 0) + focus.bondExpGained); + }); + }); + } + + return reserveUnits .map((unit) => { - const expGained = 6; - const equipmentExpGained = 2; + const expGained = focus.expGained; + const equipmentExpGained = focus.equipmentExpGained; advanceUnitExp(unit, expGained); advanceReserveEquipment(unit, equipmentExpGained); return { @@ -643,6 +738,9 @@ function applyReserveTrainingProgress(roster: UnitData[], deployedUnits: UnitDat name: unit.name, expGained, equipmentExpGained, + bondExpGained: bondExpByUnit.get(unit.id) ?? 0, + focusId: focus.id, + focusLabel: focus.label, level: unit.level, exp: unit.exp };