Add Liu Biao camp visit events

This commit is contained in:
2026-06-23 04:51:40 +09:00
parent 8f2479e5fc
commit c209107b43
5 changed files with 389 additions and 14 deletions

View File

@@ -2520,6 +2520,7 @@ export class BattleScene extends Phaser.Scene {
: undefined,
itemRewards: outcome === 'victory' ? [...battleScenario.itemRewards] : [],
completedCampDialogues: [],
completedCampVisits: [],
createdAt: new Date().toISOString()
});
}

View File

@@ -17,6 +17,7 @@ import {
type UnitData
} from '../data/scenario';
import {
applyCampVisitReward,
applyCampBondExp,
ensureCampaignRosterUnits,
getCampaignState,
@@ -28,7 +29,7 @@ import {
} from '../state/campaignState';
import { palette } from '../ui/palette';
type CampTab = 'status' | 'dialogue' | 'supplies';
type CampTab = 'status' | 'dialogue' | 'visit' | 'supplies';
type CampDialogue = {
id: string;
@@ -48,6 +49,26 @@ type CampDialogueChoice = {
rewardExp: number;
};
type CampVisitDefinition = {
id: string;
title: string;
location: string;
availableAfterBattleIds: string[];
description: string;
bondId?: string;
lines: string[];
choices: CampVisitChoice[];
};
type CampVisitChoice = {
id: string;
label: string;
response: string;
bondExp?: number;
gold?: number;
itemRewards?: string[];
};
type CampTabButtonView = {
tab: CampTab;
bg: Phaser.GameObjects.Rectangle;
@@ -1720,12 +1741,104 @@ const campDialogues: CampDialogue[] = [
}
];
const campVisits: CampVisitDefinition[] = [
{
id: 'jingzhou-scholar-rumors',
title: '형주 선비들의 모임',
location: '신야 객사',
availableAfterBattleIds: [campBattleIds.sixteenth],
bondId: 'liu-bei__sun-qian',
description: '형주의 선비들이 모이는 객사에서 와룡과 봉추에 관한 소문을 모읍니다.',
lines: [
'손건: 형주의 선비들은 겉으로는 술과 시를 말하지만, 속으로는 천하의 흐름을 재고 있습니다.',
'유비: 그들의 말을 듣되 우리 뜻을 팔지는 말아야 하오. 와룡이라는 이름이 참이라면, 예로 찾아가야 하겠지.',
'객사의 선비들은 유비군이 오래 머물 손님인지, 천하를 논할 사람인지 조심스럽게 살핍니다.'
],
choices: [
{
id: 'ask-about-wolong',
label: '와룡의 거처를 묻는다',
response: '손건은 무리하게 캐묻지 않고 융중 근처의 초가와 제갈씨 집안에 관한 소문을 얻어 냈다.',
bondExp: 18,
itemRewards: ['와룡 소문 1']
},
{
id: 'share-liu-bei-intent',
label: '유비의 뜻을 조용히 전한다',
response: '손건은 유비가 형주의 땅보다 사람을 먼저 찾는다는 뜻을 전했고, 선비들의 경계가 조금 누그러졌다.',
bondExp: 20,
gold: 80
}
]
},
{
id: 'xinye-market-supplies',
title: '신야 장터 정비',
location: '신야 장터',
availableAfterBattleIds: [campBattleIds.sixteenth],
bondId: 'liu-bei__mi-zhu',
description: '피난민과 상인이 뒤섞인 장터에서 다음 여정을 위한 군량과 약을 챙깁니다.',
lines: [
'미축: 형주의 장터는 물자가 많지만 값도 빠르게 오릅니다. 지금 무엇을 먼저 챙길지 정해야 합니다.',
'유비: 백성의 몫을 빼앗지 않는 선에서 군을 살릴 물자를 구하시오. 다음 길은 길어질 것이오.',
'상인들은 유비군의 명분을 믿고 값을 낮출지, 낯선 객군이라며 셈을 올릴지 눈치를 봅니다.'
],
choices: [
{
id: 'buy-field-medicine',
label: '상처약을 먼저 구한다',
response: '미축은 좋은 약재를 골라 상처약을 더 확보했고, 병사들은 긴 행군에 조금 안심했다.',
bondExp: 12,
itemRewards: ['상처약 2']
},
{
id: 'stock-camp-grain',
label: '군량을 넉넉히 산다',
response: '미축은 콩과 마른 양식을 챙기며 군자금을 아꼈고, 남은 돈으로 다음 협상 여지도 마련했다.',
bondExp: 10,
gold: 120,
itemRewards: ['콩 2']
}
]
},
{
id: 'refugee-village-patrol',
title: '피난민 마을 순행',
location: '형주 북쪽 마을',
availableAfterBattleIds: [campBattleIds.sixteenth],
bondId: 'liu-bei__zhao-yun',
description: '조운과 함께 형주 북쪽 마을을 돌아보며 민심과 길목의 위험을 살핍니다.',
lines: [
'조운: 조조군을 피해 내려온 백성들이 마을 바깥에 모여 있습니다. 관문은 열렸지만 마음은 아직 닫혀 있습니다.',
'유비: 의탁한 몸이라도 백성을 외면할 수는 없소. 우리가 머무는 동안 폐가 아니라 힘이 되게 합시다.',
'피난민들은 유비의 이름을 듣고도 쉽게 다가오지 못하지만, 조운의 차분한 호위에 조금씩 안정을 찾습니다.'
],
choices: [
{
id: 'guard-refugee-road',
label: '피난길을 호위한다',
response: '조운은 피난민 행렬을 안전한 길로 이끌었고, 유비군의 깃발은 형주 북쪽 마을에 좋은 소문으로 남았다.',
bondExp: 22,
itemRewards: ['민심 기록 1']
},
{
id: 'listen-to-village-elders',
label: '마을 어른들의 말을 듣는다',
response: '유비는 마을 어른들의 걱정을 오래 들었고, 조운은 다음 행군에서 피해야 할 샛길을 지도에 표시했다.',
bondExp: 18,
itemRewards: ['탁주 1']
}
]
}
];
export class CampScene extends Phaser.Scene {
private campaign?: CampaignState;
private report?: FirstBattleReport;
private selectedUnitId = 'liu-bei';
private activeTab: CampTab = 'status';
private selectedDialogueId = campDialogues[0].id;
private selectedVisitId = campVisits[0].id;
private contentObjects: Phaser.GameObjects.GameObject[] = [];
private dialogueObjects: Phaser.GameObjects.GameObject[] = [];
private sortieObjects: Phaser.GameObjects.GameObject[] = [];
@@ -1749,6 +1862,7 @@ export class CampScene extends Phaser.Scene {
this.selectedUnitId = this.currentUnits().find((unit) => unit.faction === 'ally')?.id ?? 'liu-bei';
this.selectedSortieUnitIds = this.normalizedSortieUnitIds(this.campaign.selectedSortieUnitIds);
this.selectedDialogueId = this.availableCampDialogues()[0]?.id ?? campDialogues[0].id;
this.selectedVisitId = this.availableCampVisits()[0]?.id ?? campVisits[0].id;
soundDirector.playMusic('militia-theme');
const { width, height } = this.scale;
@@ -1789,6 +1903,7 @@ export class CampScene extends Phaser.Scene {
bonds: firstBattleBonds.map((bond) => ({ ...bond, battleExp: 0 })),
itemRewards: [],
completedCampDialogues: [],
completedCampVisits: [],
createdAt: new Date().toISOString()
};
}
@@ -1910,17 +2025,28 @@ export class CampScene extends Phaser.Scene {
return this.availableCampDialogues().filter((dialogue) => completed.includes(dialogue.id));
}
private availableCampVisits() {
const battleId = this.currentCampBattleId();
return campVisits.filter((visit) => visit.availableAfterBattleIds.includes(battleId));
}
private completedAvailableVisits() {
const completed = this.completedCampVisits();
return this.availableCampVisits().filter((visit) => completed.includes(visit.id));
}
private renderStaticButtons() {
this.addTabButton('장수', 'status', 742, 38);
this.addTabButton('대화', 'dialogue', 830, 38);
this.addTabButton('정비', 'supplies', 918, 38);
this.addCommandButton('저장', 1002, 38, 76, () => {
this.addTabButton('장수', 'status', 698, 38);
this.addTabButton('대화', 'dialogue', 784, 38);
this.addTabButton('방문', 'visit', 870, 38);
this.addTabButton('정비', 'supplies', 956, 38);
this.addCommandButton('저장', 1044, 38, 76, () => {
soundDirector.playSelect();
this.campaign = saveCampaignState(getCampaignState());
this.showCampNotice(`슬롯 ${this.campaign.activeSaveSlot}에 군영 진행을 저장했습니다.`);
this.render();
});
this.addCommandButton('다음 이야기', 1120, 38, 142, () => {
this.addCommandButton('다음 이야기', 1160, 38, 142, () => {
soundDirector.playSelect();
this.showSortiePrep();
});
@@ -1993,6 +2119,11 @@ export class CampScene extends Phaser.Scene {
return;
}
if (this.activeTab === 'visit') {
this.renderVisitPanel();
return;
}
if (this.activeTab === 'supplies') {
this.renderSuppliesPanel();
return;
@@ -2467,11 +2598,13 @@ export class CampScene extends Phaser.Scene {
bg.setStrokeStyle(1, palette.gold, 0.4);
const availableDialogues = this.availableCampDialogues();
const completedDialogues = this.completedAvailableDialogues().length;
const availableVisits = this.availableCampVisits();
const completedVisits = this.completedAvailableVisits().length;
const inventory = this.inventoryLabels().join(', ');
const selectedNames = this.selectedSortieUnits().map((unit) => unit.name).join(', ');
const reward = getSortieFlow(this.campaign?.latestBattleId).rewardHint;
this.trackSortie(this.add.text(x + 16, y + 10, reward, this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 16, y + 30, `현재 준비: 대화 ${completedDialogues}/${availableDialogues.length} · 출전 ${selectedNames || '없음'} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1);
this.trackSortie(this.add.text(x + 16, y + 30, `현재 준비: 대화 ${completedDialogues}/${availableDialogues.length} · 방문 ${completedVisits}/${availableVisits.length} · 출전 ${selectedNames || '없음'} · 보유 ${inventory || '없음'}`, this.textStyle(12, '#d4dce6'))).setDepth(depth + 1);
}
private nextSortieBriefing() {
@@ -2490,6 +2623,8 @@ export class CampScene extends Phaser.Scene {
const supplyCount = campSupplies.reduce((total, supply) => total + this.inventoryAmount(supply.label), 0);
const availableDialogues = this.availableCampDialogues();
const completedDialogues = this.completedAvailableDialogues().length;
const availableVisits = this.availableCampVisits();
const completedVisits = this.completedAvailableVisits().length;
const selected = this.selectedSortieUnits();
return [
{
@@ -2517,6 +2652,11 @@ export class CampScene extends Phaser.Scene {
complete: availableDialogues.length === 0 || completedDialogues >= availableDialogues.length,
detail: `${completedDialogues}/${availableDialogues.length} 완료`
},
{
label: '현지 방문',
complete: availableVisits.length === 0 || completedVisits > 0,
detail: availableVisits.length === 0 ? '방문 없음' : `${completedVisits}/${availableVisits.length} 완료`
},
{
label: '장비 상태',
complete: this.visitedTabs.has('status'),
@@ -2816,6 +2956,112 @@ export class CampScene extends Phaser.Scene {
});
}
private renderVisitPanel() {
const x = 394;
const y = 120;
const bg = this.track(this.add.rectangle(x, y, 828, 444, 0x101820, 0.9));
bg.setOrigin(0);
bg.setStrokeStyle(1, palette.gold, 0.58);
this.track(this.add.text(x + 24, y + 22, '형주 방문', this.textStyle(24, '#f2e3bf', true)));
this.track(this.add.text(x + 24, y + 56, '군영 밖 사람들을 만나 정보, 보급, 공명 보상을 얻습니다. 각 방문은 한 번만 완료할 수 있습니다.', this.textStyle(14, '#d4dce6')));
const visits = this.availableCampVisits();
if (!visits.some((visit) => visit.id === this.selectedVisitId)) {
this.selectedVisitId = visits[0]?.id ?? campVisits[0].id;
}
if (visits.length === 0) {
const empty = this.track(this.add.rectangle(x + 24, y + 104, 780, 250, 0x0d141c, 0.92));
empty.setOrigin(0);
empty.setStrokeStyle(1, palette.blue, 0.42);
this.track(this.add.text(x + 48, y + 130, '방문 가능한 장소 없음', this.textStyle(20, '#f2e3bf', true)));
this.track(this.add.text(x + 48, y + 170, '현재 군영에서는 따로 방문할 장소가 없습니다. 장수 대화와 정비를 진행하십시오.', this.textStyle(14, '#9fb0bf')));
return;
}
visits.forEach((visit, index) => {
const completed = this.completedCampVisits().includes(visit.id);
const rowY = y + 96 + index * 66;
const selected = this.selectedVisitId === visit.id;
const row = this.track(this.add.rectangle(x + 24, rowY, 320, 50, selected ? 0x25384a : 0x151f2a, selected ? 0.96 : 0.86));
row.setOrigin(0);
row.setStrokeStyle(1, completed ? palette.green : selected ? palette.gold : palette.blue, selected ? 0.72 : 0.46);
row.setInteractive({ useHandCursor: true });
row.on('pointerdown', () => {
soundDirector.playSelect();
this.selectedVisitId = visit.id;
this.render();
});
this.track(this.add.text(x + 38, rowY + 8, visit.title, this.textStyle(15, completed ? '#a8ffd0' : '#f2e3bf', true)));
this.track(this.add.text(x + 38, rowY + 29, visit.location, this.textStyle(12, '#9fb0bf')));
});
this.renderSelectedVisit(x + 372, y + 96, 416, 300);
this.renderVisitPreparationSummary(x + 24, y + 322, 320);
}
private renderSelectedVisit(x: number, y: number, width: number, height: number) {
const visits = this.availableCampVisits();
const visit = visits.find((candidate) => candidate.id === this.selectedVisitId) ?? visits[0];
if (!visit) {
return;
}
const completed = this.completedCampVisits().includes(visit.id);
const bg = this.track(this.add.rectangle(x, y, width, height, 0x0d141c, 0.92));
bg.setOrigin(0);
bg.setStrokeStyle(1, completed ? palette.green : palette.gold, 0.5);
this.track(this.add.text(x + 18, y + 14, visit.title, this.textStyle(20, '#f2e3bf', true)));
this.track(this.add.text(x + 18, y + 42, visit.description, {
...this.textStyle(13, '#d8b15f', true),
wordWrap: { width: width - 36, useAdvancedWrap: true },
lineSpacing: 2
}));
this.track(
this.add.text(x + 18, y + 88, visit.lines.join('\n\n'), {
...this.textStyle(14, '#d4dce6'),
wordWrap: { width: width - 36, useAdvancedWrap: true },
lineSpacing: 4
})
);
if (completed) {
const done = this.track(this.add.rectangle(x + width / 2, y + height - 34, 220, 34, 0x17231d, 0.96));
done.setStrokeStyle(1, palette.green, 0.76);
const text = this.track(this.add.text(x + width / 2, y + height - 34, '방문 완료', this.textStyle(15, '#a8ffd0', true)));
text.setOrigin(0.5);
return;
}
visit.choices.forEach((choice, index) => {
const choiceY = y + height - 80 + index * 42;
const button = this.track(this.add.rectangle(x + width / 2, choiceY, width - 36, 34, 0x1a2630, 0.96));
button.setStrokeStyle(1, palette.gold, 0.76);
button.setInteractive({ useHandCursor: true });
button.on('pointerover', () => button.setFillStyle(0x283947, 0.98));
button.on('pointerout', () => button.setFillStyle(0x1a2630, 0.96));
button.on('pointerdown', () => this.completeVisit(visit, choice));
const label = `${choice.label} ${this.visitRewardText(choice)}`;
const text = this.track(this.add.text(x + width / 2, choiceY, label, this.textStyle(13, '#f2e3bf', true)));
text.setOrigin(0.5);
text.setInteractive({ useHandCursor: true });
text.on('pointerdown', () => this.completeVisit(visit, choice));
});
}
private renderVisitPreparationSummary(x: number, y: number, width: number) {
const visits = this.availableCampVisits();
const completed = this.completedAvailableVisits();
const bg = this.track(this.add.rectangle(x, y, width, 104, 0x0d141c, 0.9));
bg.setOrigin(0);
bg.setStrokeStyle(1, palette.blue, 0.42);
this.track(this.add.text(x + 14, y + 12, '현지 준비', this.textStyle(17, '#f2e3bf', true)));
this.track(this.add.text(x + 14, y + 40, `방문 ${completed.length}/${visits.length} 완료`, this.textStyle(13, completed.length >= visits.length && visits.length > 0 ? '#a8ffd0' : '#d4dce6', true)));
const insightCount = this.inventoryAmount('와룡 소문') + this.inventoryAmount('민심 기록');
this.track(this.add.text(x + 14, y + 66, `제갈량 단서 ${insightCount} · 군자금 ${this.campaign?.gold ?? 0}`, this.textStyle(13, '#9fb0bf', true)));
}
private renderSuppliesPanel() {
const x = 394;
const y = 120;
@@ -3111,6 +3357,43 @@ export class CampScene extends Phaser.Scene {
return bonuses.join(' / ') || '특수 보정 없음';
}
private completeVisit(visit: CampVisitDefinition, choice: CampVisitChoice) {
if (this.completedCampVisits().includes(visit.id)) {
this.showCampNotice('이미 완료한 방문입니다.');
return;
}
const updated = applyCampVisitReward(visit.id, {
bondId: visit.bondId,
bondExp: choice.bondExp,
gold: choice.gold,
itemRewards: choice.itemRewards
});
if (updated) {
this.campaign = updated;
this.report = this.campaign.firstBattleReport ?? this.report;
}
soundDirector.playEffect('exp-gain', { volume: 0.26, stopAfterMs: 700 });
this.showVisitReward(choice);
this.render();
}
private showVisitReward(choice: CampVisitChoice) {
this.showCampNotice(`${choice.label} · ${this.visitRewardText(choice)}`);
this.time.delayedCall(120, () => this.showCampNotice(choice.response));
}
private visitRewardText(choice: CampVisitChoice) {
const rewards = [
choice.bondExp ? `공명 +${choice.bondExp}` : '',
choice.gold ? `군자금 +${choice.gold}` : '',
...(choice.itemRewards ?? []).map((reward) => `${reward}`)
].filter(Boolean);
return rewards.join(' / ') || '정보 획득';
}
private completeDialogue(dialogue: CampDialogue, choice: CampDialogueChoice) {
const rewardExp = dialogue.rewardExp + choice.rewardExp;
const updated = applyCampBondExp(dialogue.id, dialogue.bondId, rewardExp);
@@ -3270,6 +3553,13 @@ export class CampScene extends Phaser.Scene {
return this.report?.completedCampDialogues ?? [];
}
private completedCampVisits() {
if (this.campaign) {
return this.campaign.completedCampVisits;
}
return this.report?.completedCampVisits ?? [];
}
private inventoryLabels() {
const inventory = this.campaign?.inventory ?? {};
const entries = Object.entries(inventory).filter(([, amount]) => amount > 0);
@@ -3336,6 +3626,7 @@ export class CampScene extends Phaser.Scene {
sortieVisible: this.sortieObjects.length > 0,
selectedUnitId: this.selectedUnitId,
selectedDialogueId: this.selectedDialogueId,
selectedVisitId: this.selectedVisitId,
selectedSortieUnitIds: [...this.selectedSortieUnitIds],
sortieRoster: this.sortieAllies().map((unit) => {
const summary = this.sortieUnitTacticalSummary(unit);
@@ -3361,6 +3652,8 @@ export class CampScene extends Phaser.Scene {
campTitle: this.currentCampTitle(),
availableDialogueIds: this.availableCampDialogues().map((dialogue) => dialogue.id),
completedAvailableDialogues: this.completedAvailableDialogues().map((dialogue) => dialogue.id),
availableVisitIds: this.availableCampVisits().map((visit) => visit.id),
completedAvailableVisits: this.completedAvailableVisits().map((visit) => visit.id),
campaign: this.campaign
? {
step: this.campaign.step,
@@ -3373,7 +3666,8 @@ export class CampScene extends Phaser.Scene {
hp: unit.hp,
maxHp: unit.maxHp
})),
completedCampDialogues: this.campaign.completedCampDialogues
completedCampDialogues: this.campaign.completedCampDialogues,
completedCampVisits: this.campaign.completedCampVisits
}
: null,
report: this.report
@@ -3381,6 +3675,7 @@ export class CampScene extends Phaser.Scene {
rewardGold: this.report.rewardGold,
objectives: this.report.objectives,
completedCampDialogues: this.report.completedCampDialogues,
completedCampVisits: this.report.completedCampVisits,
bonds: this.report.bonds.map((bond) => ({ id: bond.id, level: bond.level, exp: bond.exp, battleExp: bond.battleExp }))
}
: null

View File

@@ -33,6 +33,7 @@ export type FirstBattleReport = {
mvp?: CampMvpSnapshot;
itemRewards: string[];
completedCampDialogues: string[];
completedCampVisits: string[];
createdAt: string;
};
@@ -114,6 +115,7 @@ export type CampaignState = {
inventory: Record<string, number>;
selectedSortieUnitIds: string[];
completedCampDialogues: string[];
completedCampVisits: string[];
battleHistory: Record<string, CampaignBattleSettlement>;
latestBattleId?: string;
firstBattleReport?: FirstBattleReport;
@@ -164,6 +166,7 @@ export function createInitialCampaignState(): CampaignState {
inventory: {},
selectedSortieUnitIds: [],
completedCampDialogues: [],
completedCampVisits: [],
battleHistory: {}
};
}
@@ -244,8 +247,11 @@ export function setFirstBattleReport(report: FirstBattleReport) {
const previousSettlement = state.battleHistory[battleId];
const completedCampDialogues =
battleId === 'first-battle-zhuo-commandery' ? [...reportClone.completedCampDialogues] : [...state.completedCampDialogues];
const completedCampVisits =
battleId === 'first-battle-zhuo-commandery' ? [...(reportClone.completedCampVisits ?? [])] : [...state.completedCampVisits];
reportClone.completedCampDialogues = completedCampDialogues;
reportClone.completedCampVisits = completedCampVisits;
state.firstBattleReport = reportClone;
const stepRule = campaignBattleSteps[battleId] ?? campaignBattleSteps['first-battle-zhuo-commandery'];
const victoryStep: CampaignStep = stepRule.victory;
@@ -255,6 +261,7 @@ export function setFirstBattleReport(report: FirstBattleReport) {
state.roster = mergeRosterProgress(state.roster, reportClone.units.filter((unit) => unit.faction === 'ally'));
state.bonds = reportClone.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);
state.battleHistory[battleId] = createBattleSettlement(reportClone);
@@ -298,6 +305,50 @@ export function applyCampBondExp(dialogueId: string, bondId: string, amount: num
return cloneReport(state.firstBattleReport);
}
export function applyCampVisitReward(
visitId: string,
reward: { bondId?: string; bondExp?: number; gold?: number; itemRewards?: string[] }
) {
const state = ensureCampaignState();
if (!state.firstBattleReport) {
return undefined;
}
if (state.completedCampVisits.includes(visitId)) {
return cloneCampaignState(state);
}
state.completedCampVisits.push(visitId);
if (!state.firstBattleReport.completedCampVisits.includes(visitId)) {
state.firstBattleReport.completedCampVisits.push(visitId);
}
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);
}
export function ensureCampaignRosterUnits(units: UnitData[], bonds: BattleBond[] = []) {
const state = ensureCampaignState();
let changed = false;
@@ -368,6 +419,7 @@ function normalizeCampaignState(state: CampaignState): CampaignState {
normalized.roster = (normalized.roster ?? []).map(cloneUnit);
normalized.bonds = (normalized.bonds ?? []).map(cloneBondSnapshot);
normalized.completedCampDialogues = [...new Set(normalized.completedCampDialogues ?? [])];
normalized.completedCampVisits = [...new Set(normalized.completedCampVisits ?? [])];
normalized.inventory = { ...(normalized.inventory ?? {}) };
normalized.selectedSortieUnitIds = [...new Set(normalized.selectedSortieUnitIds ?? [])];
normalized.battleHistory = normalized.battleHistory ?? {};
@@ -501,7 +553,9 @@ function cloneCampaignState(state: CampaignState): CampaignState {
}
function cloneReport(report: FirstBattleReport): FirstBattleReport {
return JSON.parse(JSON.stringify(report)) as FirstBattleReport;
const cloned = JSON.parse(JSON.stringify(report)) as FirstBattleReport;
cloned.completedCampVisits = [...new Set(cloned.completedCampVisits ?? [])];
return cloned;
}
function cloneUnit(unit: UnitData): UnitData {