Add Xu Province recruit sortie flow

This commit is contained in:
2026-06-23 01:07:17 +09:00
parent 87b200d772
commit 6b863882aa
7 changed files with 330 additions and 33 deletions

View File

@@ -1,6 +1,16 @@
export type TerrainType = 'plain' | 'road' | 'forest' | 'hill' | 'village' | 'fort' | 'camp' | 'river' | 'cliff';
export type UnitClassKey = 'lord' | 'infantry' | 'cavalry' | 'spearman' | 'archer' | 'bandit' | 'yellowTurban' | 'rebelLeader';
export type UnitClassKey =
| 'lord'
| 'infantry'
| 'cavalry'
| 'spearman'
| 'archer'
| 'strategist'
| 'quartermaster'
| 'bandit'
| 'yellowTurban'
| 'rebelLeader';
export type TerrainRule = {
label: string;
@@ -140,6 +150,54 @@ export const unitClasses: Record<UnitClassKey, UnitClassDefinition> = {
hill: 1
}
},
strategist: {
key: 'strategist',
name: '책사',
family: '책사계',
role: '책략 / 보조',
description: '직접 전투력은 낮지만 책략과 지휘 보조로 전장의 흐름을 바꾸는 병과.',
terrainRatings: {
plain: 95,
road: 100,
forest: 95,
hill: 105,
village: 110,
fort: 115,
camp: 115,
river: 0,
cliff: 0
},
movementCosts: {
hill: 1,
village: 1,
fort: 1,
camp: 1
}
},
quartermaster: {
key: 'quartermaster',
name: '군량관',
family: '보급계',
role: '회복 / 보급',
description: '군량과 보급로를 관리해 장기전에 강하고 마을과 진영에서 안정적인 병과.',
terrainRatings: {
plain: 95,
road: 105,
forest: 90,
hill: 90,
village: 115,
fort: 105,
camp: 120,
river: 0,
cliff: 0
},
movementCosts: {
road: 1,
village: 1,
fort: 1,
camp: 1
}
},
bandit: {
key: 'bandit',
name: '도적',

View File

@@ -2566,6 +2566,51 @@ export const seventhBattleUnits: UnitData[] = [
}
];
export const xuzhouRecruitUnits: UnitData[] = [
{
id: 'jian-yong',
name: '간옹',
faction: 'ally',
className: '책사',
classKey: 'strategist',
level: 4,
exp: 12,
hp: 26,
maxHp: 26,
attack: 7,
move: 4,
stats: { might: 42, intelligence: 78, leadership: 66, agility: 64, luck: 72 },
equipment: {
weapon: { itemId: 'training-sword', level: 1, exp: 8 },
armor: { itemId: 'cloth-armor', level: 1, exp: 6 },
accessory: { itemId: 'war-manual', level: 1, exp: 0 }
},
x: 3,
y: 17
},
{
id: 'mi-zhu',
name: '미축',
faction: 'ally',
className: '군량관',
classKey: 'quartermaster',
level: 4,
exp: 8,
hp: 28,
maxHp: 28,
attack: 7,
move: 4,
stats: { might: 40, intelligence: 74, leadership: 70, agility: 58, luck: 82 },
equipment: {
weapon: { itemId: 'training-sword', level: 1, exp: 6 },
armor: { itemId: 'cloth-armor', level: 1, exp: 6 },
accessory: { itemId: 'grain-pouch', level: 1, exp: 0 }
},
x: 4,
y: 17
}
];
export const firstBattleBonds: BattleBond[] = [
{
id: 'liu-bei__guan-yu',
@@ -2593,6 +2638,25 @@ export const firstBattleBonds: BattleBond[] = [
}
];
export const xuzhouRecruitBonds: BattleBond[] = [
{
id: 'liu-bei__jian-yong',
unitIds: ['liu-bei', 'jian-yong'],
title: '오랜 벗',
level: 48,
exp: 0,
description: '간옹은 유비가 흔들릴 때 현실적인 말로 길을 정리해 주는 오랜 벗이다.'
},
{
id: 'liu-bei__mi-zhu',
unitIds: ['liu-bei', 'mi-zhu'],
title: '서주 후원',
level: 42,
exp: 0,
description: '미축은 서주의 민심과 군량을 묶어 유비군이 오래 버틸 수 있게 돕는다.'
}
];
export const secondBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const thirdBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);
export const fourthBattleBonds: BattleBond[] = firstBattleBonds.map(cloneBattleBondForScenario);

View File

@@ -68,6 +68,8 @@ const unitTextureByClass: Partial<Record<UnitClassKey, string>> = {
infantry: 'unit-guan-yu',
spearman: 'unit-zhang-fei',
archer: 'unit-rebel-archer',
strategist: 'unit-liu-bei',
quartermaster: 'unit-liu-bei',
cavalry: 'unit-rebel-cavalry',
bandit: 'unit-rebel',
yellowTurban: 'unit-rebel',
@@ -470,17 +472,22 @@ const usableCatalog: Record<string, BattleUsable> = {
const unitStrategyIds: Record<string, string[]> = {
'liu-bei': ['aid', 'encourage'],
'guan-yu': ['fireTactic'],
'zhang-fei': ['roar']
'zhang-fei': ['roar'],
'jian-yong': ['aid', 'encourage', 'fireTactic'],
'mi-zhu': ['aid', 'encourage']
};
const initialItemStocks: Record<string, Record<string, number>> = {
'liu-bei': { bean: 2, salve: 1 },
'guan-yu': { bean: 1 },
'zhang-fei': { bean: 1, wine: 1 }
'zhang-fei': { bean: 1, wine: 1 },
'jian-yong': { bean: 1 },
'mi-zhu': { bean: 2, salve: 1 }
};
const attackRangeByClass: Partial<Record<UnitClassKey, number>> = {
archer: 2
archer: 2,
strategist: 2
};
const enemyAiByUnitId: Record<string, EnemyAiBehavior> = {
@@ -846,7 +853,7 @@ export class BattleScene extends Phaser.Scene {
private drawUnits() {
battleUnits.forEach((unit) => {
const sizeRatio = unit.faction === 'ally' ? 1.16 : 1.1;
const textureBase = unitTexture[unit.id] ?? unitTextureByClass[unit.classKey] ?? 'unit-rebel';
const textureBase = this.unitTextureKey(unit);
const sprite = this.add.sprite(this.tileCenterX(unit.x), this.tileCenterY(unit.y), textureBase, this.unitFrameIndex('south'));
sprite.setName(`unit-${unit.id}`);
sprite.setDisplaySize(this.layout.tileSize * sizeRatio, this.layout.tileSize * sizeRatio);
@@ -4257,7 +4264,7 @@ export class BattleScene extends Phaser.Scene {
attackerSprite.setDisplaySize(126, 126);
const defenderSprite = this.trackCombatObject(
this.add.sprite(defenderX, groundY, unitTexture[result.defender.id] ?? 'unit-rebel', this.unitFrameIndex(defenderDirection))
this.add.sprite(defenderX, groundY, this.unitTextureKey(result.defender), this.unitFrameIndex(defenderDirection))
);
defenderSprite.setDepth(depth + 6);
defenderSprite.setDisplaySize(126, 126);
@@ -4361,7 +4368,7 @@ export class BattleScene extends Phaser.Scene {
userSprite.setDepth(depth + 3);
userSprite.setDisplaySize(112, 112);
const targetSprite = this.trackCombatObject(this.add.sprite(left + panelWidth - 160, top + 146, unitTexture[result.target.id] ?? 'unit-rebel', this.unitFrameIndex('west')));
const targetSprite = this.trackCombatObject(this.add.sprite(left + panelWidth - 160, top + 146, this.unitTextureKey(result.target), this.unitFrameIndex('west')));
targetSprite.setDepth(depth + 3);
targetSprite.setDisplaySize(112, 112);
@@ -4423,7 +4430,7 @@ export class BattleScene extends Phaser.Scene {
portrait.setDepth(85);
portrait.setDisplaySize(58, 80);
} else {
const unitIcon = this.trackCombatObject(this.add.sprite(x + 38, y + 58, unitTexture[unit.id] ?? 'unit-rebel', this.unitFrameIndex('south')));
const unitIcon = this.trackCombatObject(this.add.sprite(x + 38, y + 58, this.unitTextureKey(unit), this.unitFrameIndex('south')));
unitIcon.setDepth(85);
unitIcon.setDisplaySize(64, 64);
}
@@ -5586,8 +5593,12 @@ export class BattleScene extends Phaser.Scene {
return unitFrameRows[direction] * 4 + frame;
}
private unitTextureKey(unit: UnitData) {
return unitTexture[unit.id] ?? unitTextureByClass[unit.classKey] ?? 'unit-rebel';
}
private unitActionTexture(unit: UnitData) {
const base = unitTexture[unit.id] ?? 'unit-rebel';
const base = this.unitTextureKey(unit);
const actionKey = `${base}-actions`;
return this.textures.exists(actionKey) ? actionKey : base;
}

View File

@@ -4,9 +4,10 @@ import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type
import { getUnitClass } from '../data/battleRules';
import { defaultBattleScenario } from '../data/battles';
import { getSortieFlow } from '../data/campaignFlow';
import { firstBattleBonds, firstBattleUnits, type PortraitKey, type UnitData } from '../data/scenario';
import { firstBattleBonds, firstBattleUnits, xuzhouRecruitBonds, xuzhouRecruitUnits, type PortraitKey, type UnitData } from '../data/scenario';
import {
applyCampBondExp,
ensureCampaignRosterUnits,
getCampaignState,
getFirstBattleReport,
markCampaignStep,
@@ -702,6 +703,60 @@ const campDialogues: CampDialogue[] = [
rewardExp: 7
}
]
},
{
id: 'liu-jian-yong-after-xuzhou',
title: '오랜 벗의 조언',
availableAfterBattleIds: [campBattleIds.seventh],
unitIds: ['liu-bei', 'jian-yong'],
bondId: 'liu-bei__jian-yong',
rewardExp: 18,
lines: [
'간옹: 현덕, 서주를 맡는 일은 칼을 드는 일보다 더 무겁네. 백성은 말보다 먼저 밥과 길을 보네.',
'유비: 헌화, 내가 그 무게를 감당할 수 있을까 두렵소.',
'간옹: 두려움을 아는 사람이면 적어도 함부로 빼앗지는 않겠지. 그러니 먼저 사람을 살피게.'
],
choices: [
{
id: 'listen-before-rule',
label: '먼저 백성의 말을 듣는다',
response: '간옹은 유비가 급히 명분을 세우기보다 서주의 목소리를 먼저 듣겠다고 하자 고개를 끄덕였다.',
rewardExp: 8
},
{
id: 'appoint-practical-aide',
label: '실무를 맡아 달라 한다',
response: '간옹은 투덜대면서도 군영 장부와 사람들의 청원을 살피겠다고 나섰다.',
rewardExp: 7
}
]
},
{
id: 'liu-mi-zhu-after-xuzhou',
title: '서주의 군량',
availableAfterBattleIds: [campBattleIds.seventh],
unitIds: ['liu-bei', 'mi-zhu'],
bondId: 'liu-bei__mi-zhu',
rewardExp: 18,
lines: [
'미축: 유 장군, 서주의 창고는 아직 온전하지만 오래 버티려면 상인과 호족의 마음을 함께 묶어야 합니다.',
'유비: 백성에게 더 짐을 지우고 싶지는 않소. 다른 길이 있겠소?',
'미축: 사사로운 이익을 줄이고 공적인 신뢰를 세우면 길은 열립니다. 제가 그 일을 맡겠습니다.'
],
choices: [
{
id: 'protect-grain',
label: '군량을 백성과 나눈다',
response: '미축은 군량을 무기로 삼지 않겠다는 유비의 뜻을 확인하고 서주의 상단을 설득하기로 했다.',
rewardExp: 8
},
{
id: 'secure-supply-route',
label: '보급로 정비를 부탁한다',
response: '미축은 곧장 장부를 펼쳐 길목과 창고를 다시 묶는 계획을 세웠다.',
rewardExp: 7
}
]
}
];
@@ -730,6 +785,7 @@ export class CampScene extends Phaser.Scene {
this.visitedTabs = new Set(['status']);
this.campaign = getCampaignState();
this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport();
this.ensureCurrentCampRecruitment();
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;
@@ -777,6 +833,15 @@ export class CampScene extends Phaser.Scene {
};
}
private ensureCurrentCampRecruitment() {
if (this.currentCampBattleId() !== campBattleIds.seventh || !this.campaign) {
return;
}
this.campaign = ensureCampaignRosterUnits(xuzhouRecruitUnits, xuzhouRecruitBonds);
this.report = this.campaign.firstBattleReport ?? this.report;
}
private reportSummary() {
if (!this.report) {
return '';
@@ -947,8 +1012,8 @@ export class CampScene extends Phaser.Scene {
this.renderSortieBriefing(x + 34, y + 112, 396, 152, depth + 2);
this.renderSortieChecklist(x + 462, y + 112, 404, 170, depth + 2);
this.renderSortieUnitSummary(x + 34, y + 292, 832, 148, depth + 2);
this.renderSortieRewardHint(x + 34, y + 462, 548, 52, depth + 2);
this.renderSortieUnitSummary(x + 34, y + 292, 832, 176, depth + 2);
this.renderSortieRewardHint(x + 34, y + 480, 548, 52, depth + 2);
this.addSortieButton('군영으로', x + width - 298, y + height - 44, 124, () => {
soundDirector.playSelect();
@@ -1003,16 +1068,18 @@ export class CampScene extends Phaser.Scene {
bg.setStrokeStyle(1, palette.blue, 0.48);
const allies = this.sortieAllies();
const selectedCount = this.selectedSortieUnitIds.length;
const rowGap = allies.length > 4 ? 24 : 30;
const rowHeight = allies.length > 4 ? 22 : 26;
this.trackSortie(
this.add.text(x + 18, y + 14, `출전 무장 선택 ${selectedCount}/${Math.min(maxSortieUnits, allies.length)}`, this.textStyle(18, '#f2e3bf', true))
).setDepth(depth + 1);
this.trackSortie(this.add.text(x + width - 18, y + 17, '클릭해서 출전/대기 전환', this.textStyle(12, '#9fb0bf'))).setOrigin(1, 0).setDepth(depth + 1);
allies.forEach((unit, index) => {
const rowY = y + 48 + index * 30;
const rowY = y + 48 + index * rowGap;
const selected = this.isSortieSelected(unit.id);
const required = requiredSortieUnitIds.has(unit.id);
const row = this.trackSortie(this.add.rectangle(x + 18, rowY - 5, width - 36, 26, selected ? 0x172a22 : 0x151b24, selected ? 0.96 : 0.82));
const row = this.trackSortie(this.add.rectangle(x + 18, rowY - 5, width - 36, rowHeight, selected ? 0x172a22 : 0x151b24, selected ? 0.96 : 0.82));
row.setOrigin(0);
row.setDepth(depth + 1);
row.setStrokeStyle(1, selected ? palette.green : 0x53606c, selected ? 0.62 : 0.38);
@@ -1166,11 +1233,15 @@ export class CampScene extends Phaser.Scene {
private renderUnitColumn() {
const allies = this.currentUnits().filter((unit) => unit.faction === 'ally');
const availableHeight = 462;
const rowGap = allies.length > 1 ? Math.min(150, Math.floor(availableHeight / allies.length)) : 150;
const cardHeight = Math.min(126, Math.max(70, rowGap - 10));
const compact = cardHeight < 104;
allies.forEach((unit, index) => {
const x = 42;
const y = 120 + index * 150;
const y = 120 + index * rowGap;
const active = this.selectedUnitId === unit.id;
const bg = this.track(this.add.rectangle(x, y, 318, 126, active ? 0x25384a : 0x111922, active ? 0.96 : 0.86));
const bg = this.track(this.add.rectangle(x, y, 318, cardHeight, active ? 0x25384a : 0x111922, active ? 0.96 : 0.86));
bg.setOrigin(0);
bg.setStrokeStyle(2, active ? palette.gold : palette.blue, active ? 0.9 : 0.46);
bg.setInteractive({ useHandCursor: true });
@@ -1181,18 +1252,32 @@ export class CampScene extends Phaser.Scene {
});
const portraitKey = portraitByUnitId[unit.id];
const portraitCenterY = y + cardHeight / 2;
const portraitSize = compact ? 62 : 92;
if (portraitKey) {
const portrait = this.track(this.add.image(x + 58, y + 62, portraitKeys[portraitKey]));
portrait.setDisplaySize(92, 92);
const portrait = this.track(this.add.image(x + 58, portraitCenterY, portraitKeys[portraitKey]));
portrait.setDisplaySize(portraitSize, portraitSize);
} else {
this.renderUnitFallbackPortrait(x + 58, portraitCenterY, compact ? 31 : 44, unit);
}
this.track(this.add.text(x + 116, y + 20, `${unit.name} Lv ${unit.level}`, this.textStyle(21, '#f2e3bf', true)));
this.track(this.add.text(x + 116, y + 51, `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(14, '#d4dce6')));
this.track(this.add.text(x + 116, y + 78, `경험 ${unit.exp}/100`, this.textStyle(14, '#d8b15f', true)));
this.drawBar(x + 116, y + 104, 176, 8, unit.exp / 100, palette.gold);
const textX = compact ? x + 102 : x + 116;
this.track(this.add.text(textX, y + (compact ? 13 : 20), `${unit.name} Lv ${unit.level}`, this.textStyle(compact ? 17 : 21, '#f2e3bf', true)));
this.track(this.add.text(textX, y + (compact ? 38 : 51), `${unit.className} · HP ${unit.hp}/${unit.maxHp}`, this.textStyle(compact ? 12 : 14, '#d4dce6')));
this.track(this.add.text(textX, y + (compact ? 58 : 78), `경험 ${unit.exp}/100`, this.textStyle(compact ? 12 : 14, '#d8b15f', true)));
this.drawBar(textX, y + cardHeight - 18, compact ? 160 : 176, 8, unit.exp / 100, palette.gold);
});
}
private renderUnitFallbackPortrait(x: number, y: number, radius: number, unit: UnitData) {
const bg = this.track(this.add.circle(x, y, radius, 0x1f3140, 0.96));
bg.setStrokeStyle(2, palette.gold, 0.58);
const initial = this.track(this.add.text(x, y - radius * 0.34, unit.name.slice(0, 1), this.textStyle(Math.max(17, Math.floor(radius * 0.86)), '#f2e3bf', true)));
initial.setOrigin(0.5);
const label = this.track(this.add.text(x, y + radius * 0.34, unit.className, this.textStyle(Math.max(9, Math.floor(radius * 0.3)), '#d4dce6', true)));
label.setOrigin(0.5);
}
private renderReportPanel() {
const report = this.report;
if (!report) {
@@ -1234,6 +1319,8 @@ export class CampScene extends Phaser.Scene {
if (portraitKey) {
const portrait = this.track(this.add.image(x + 84, y + 90, portraitKeys[portraitKey]));
portrait.setDisplaySize(112, 112);
} else {
this.renderUnitFallbackPortrait(x + 84, y + 90, 48, unit);
}
this.track(this.add.text(x + 166, y + 22, `${unit.name} Lv ${unit.level}`, this.textStyle(27, '#f2e3bf', true)));
@@ -1280,15 +1367,18 @@ export class CampScene extends Phaser.Scene {
this.track(this.add.text(x + 24, y + 56, '출진 전 대화에서 선택지를 고르면 해당 장수들의 공명 경험치가 오릅니다.', this.textStyle(14, '#d4dce6')));
const dialogues = this.availableCampDialogues();
const compactDialogueList = dialogues.length > 3;
const dialogueRowGap = compactDialogueList ? 44 : 64;
const dialogueRowHeight = compactDialogueList ? 36 : 48;
if (!dialogues.some((dialogue) => dialogue.id === this.selectedDialogueId)) {
this.selectedDialogueId = dialogues[0]?.id ?? campDialogues[0].id;
}
dialogues.forEach((dialogue, index) => {
const completed = this.completedCampDialogues().includes(dialogue.id);
const rowY = y + 96 + index * 64;
const rowY = y + 96 + index * dialogueRowGap;
const selected = this.selectedDialogueId === dialogue.id;
const row = this.track(this.add.rectangle(x + 24, rowY, 320, 48, selected ? 0x25384a : 0x151f2a, selected ? 0.96 : 0.86));
const row = this.track(this.add.rectangle(x + 24, rowY, 320, dialogueRowHeight, 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 });
@@ -1297,9 +1387,9 @@ export class CampScene extends Phaser.Scene {
this.selectedDialogueId = dialogue.id;
this.render();
});
this.track(this.add.text(x + 38, rowY + 8, dialogue.title, this.textStyle(15, completed ? '#a8ffd0' : '#f2e3bf', true)));
this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 5 : 8), dialogue.title, this.textStyle(compactDialogueList ? 13 : 15, completed ? '#a8ffd0' : '#f2e3bf', true)));
const maxReward = dialogue.rewardExp + Math.max(...dialogue.choices.map((choice) => choice.rewardExp));
this.track(this.add.text(x + 38, rowY + 29, `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}~${maxReward}`, this.textStyle(12, '#9fb0bf')));
this.track(this.add.text(x + 38, rowY + (compactDialogueList ? 22 : 29), `${this.unitName(dialogue.unitIds[0])} · ${this.unitName(dialogue.unitIds[1])} 공명 +${dialogue.rewardExp}~${maxReward}`, this.textStyle(compactDialogueList ? 10 : 12, '#9fb0bf')));
});
this.renderSelectedDialogue(x + 372, y + 96, 416, 300);
@@ -1356,12 +1446,15 @@ export class CampScene extends Phaser.Scene {
}
private renderBondList(x: number, y: number, width: number) {
const bonds = this.currentBonds();
const compact = bonds.length > 3;
const rowGap = compact ? 25 : 36;
this.track(this.add.text(x, y, '공명', this.textStyle(18, '#f2e3bf', true)));
this.currentBonds().forEach((bond, index) => {
const rowY = y + 32 + index * 36;
bonds.forEach((bond, index) => {
const rowY = y + (compact ? 28 : 32) + index * rowGap;
const first = this.unitName(bond.unitIds[0]);
const second = this.unitName(bond.unitIds[1]);
this.track(this.add.text(x, rowY, `${first}·${second} Lv ${bond.level}`, this.textStyle(13, '#d4dce6')));
this.track(this.add.text(x, rowY, `${first}·${second} Lv ${bond.level}`, this.textStyle(compact ? 11 : 13, '#d4dce6')));
this.drawBar(x + 136, rowY + 6, width - 136, 7, bond.exp / 100, bond.battleExp > 0 ? palette.gold : palette.blue);
});
}

View File

@@ -271,6 +271,60 @@ export function applyCampBondExp(dialogueId: string, bondId: string, amount: num
return cloneReport(state.firstBattleReport);
}
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);
}
function ensureCampaignState() {
if (!campaignState) {
campaignState = readStoredCampaignState() ?? createInitialCampaignState();
@@ -433,3 +487,11 @@ function cloneBondSnapshot(bond: CampBondSnapshot): CampBondSnapshot {
unitIds: [...bond.unitIds] as [string, string]
};
}
function createCampBondSnapshot(bond: BattleBond): CampBondSnapshot {
return {
...bond,
unitIds: [...bond.unitIds] as [string, string],
battleExp: 0
};
}