Add selectable reserve drill focus

This commit is contained in:
2026-06-23 17:34:45 +09:00
parent 0736f07d1d
commit 8591fd7fa3
4 changed files with 242 additions and 21 deletions

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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<string, number>;
selectedSortieUnitIds: string[];
reserveTrainingFocus: CampaignReserveTrainingFocusId;
completedCampDialogues: string[];
completedCampVisits: string[];
battleHistory: Record<string, CampaignBattleSettlement>;
@@ -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<string, number>();
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
};