From 635b3a7a81b28e9bdb8ef8e4ad8c920abce6bf08 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 22 Jun 2026 17:05:59 +0900 Subject: [PATCH] Solidify campaign progression state --- scripts/verify-flow.mjs | 15 +++ src/game/data/battles.ts | 40 +++++++ src/game/scenes/BattleScene.ts | 35 ++++-- src/game/scenes/CampScene.ts | 74 ++++++++++-- src/game/scenes/TitleScene.ts | 30 ++++- src/game/state/campaignState.ts | 206 ++++++++++++++++++++++++++++++-- 6 files changed, 369 insertions(+), 31 deletions(-) create mode 100644 src/game/data/battles.ts diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index eb9c6ba..cd1157b 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -13,6 +13,8 @@ try { const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); await page.goto(targetUrl, { waitUntil: 'networkidle' }); + await page.evaluate(() => window.localStorage.clear()); + await page.reload({ waitUntil: 'networkidle' }); await page.waitForSelector('canvas'); await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined); await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined); @@ -161,6 +163,19 @@ try { throw new Error(`Expected camp dialogue to award bond exp: ${JSON.stringify(campStateAfterDialogue)}`); } + const campaignSaveAfterDialogue = await page.evaluate(() => { + const raw = window.localStorage.getItem('heros-web:campaign-state'); + return raw ? JSON.parse(raw) : undefined; + }); + if ( + !campaignSaveAfterDialogue || + campaignSaveAfterDialogue.step !== 'first-camp' || + !campaignSaveAfterDialogue.completedCampDialogues?.length || + Object.keys(campaignSaveAfterDialogue.inventory ?? {}).length === 0 + ) { + throw new Error(`Expected campaign save to persist camp progress: ${JSON.stringify(campaignSaveAfterDialogue)}`); + } + await page.evaluate(() => window.__HEROS_DEBUG__?.goToBattle()); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts new file mode 100644 index 0000000..27b4344 --- /dev/null +++ b/src/game/data/battles.ts @@ -0,0 +1,40 @@ +import { + firstBattleBonds, + firstBattleMap, + firstBattleUnits, + firstBattleVictoryPages, + type BattleBond, + type BattleMap, + type StoryPage, + type UnitData +} from './scenario'; + +export type BattleScenarioDefinition = { + id: string; + title: string; + map: BattleMap; + units: UnitData[]; + bonds: BattleBond[]; + mapTextureKey: string; + leaderUnitId: string; + quickVictoryTurnLimit: number; + baseVictoryGold: number; + itemRewards: string[]; + victoryPages: StoryPage[]; + nextCampScene: string; +}; + +export const firstBattleScenario: BattleScenarioDefinition = { + id: 'first-battle-zhuo-commandery', + title: '탁현의 전투', + map: firstBattleMap, + units: firstBattleUnits, + bonds: firstBattleBonds, + mapTextureKey: 'battle-map-first', + leaderUnitId: 'rebel-leader', + quickVictoryTurnLimit: 8, + baseVictoryGold: 300, + itemRewards: ['콩 1', '탁주 1', '의용군 명성 +1'], + victoryPages: firstBattleVictoryPages, + nextCampScene: 'CampScene' +}; diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 8b03a38..b56bae7 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -8,6 +8,7 @@ import { type UnitData, type UnitStats } from '../data/scenario'; +import { firstBattleScenario } from '../data/battles'; import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules'; import { equipmentExpToNext, @@ -16,7 +17,7 @@ import { getItem, type EquipmentSlot } from '../data/battleItems'; -import { setFirstBattleReport } from '../state/campaignState'; +import { getCampaignState, markCampaignStep, saveCampaignState, setFirstBattleReport, type CampaignStep } from '../state/campaignState'; import { palette } from '../ui/palette'; const unitTexture: Record = { @@ -269,6 +270,8 @@ type SavedUnitState = Pick> = { }; const guardDetectionRange = 5; -const leaderUnitId = 'rebel-leader'; -const quickVictoryTurnLimit = 8; -const baseVictoryGold = 300; +const leaderUnitId = battleScenario.leaderUnitId; +const quickVictoryTurnLimit = battleScenario.quickVictoryTurnLimit; +const baseVictoryGold = battleScenario.baseVictoryGold; const commandLabels: Record = { attack: '공격', @@ -529,6 +534,10 @@ export class BattleScene extends Phaser.Scene { create() { this.resetBattleData(); + const campaign = getCampaignState(); + if (campaign.step === 'new' || campaign.step === 'prologue' || campaign.step === 'first-battle') { + markCampaignStep('first-battle'); + } const { width, height } = this.scale; this.layout = this.createLayout(width, height); const startUnit = firstBattleUnits.find((unit) => unit.id === 'liu-bei') ?? firstBattleUnits[0]; @@ -625,7 +634,7 @@ export class BattleScene extends Phaser.Scene { this.mapMask = maskShape.createGeometryMask(); const mapMask = this.mapMask; - const background = this.add.image(0, 0, 'battle-map-first'); + const background = this.add.image(0, 0, battleScenario.mapTextureKey); background.setOrigin(0); background.setDepth(0); background.setDisplaySize(firstBattleMap.width * layout.tileSize, firstBattleMap.height * layout.tileSize); @@ -1912,7 +1921,7 @@ export class BattleScene extends Phaser.Scene { defeats: mvp.stats.defeats } : undefined, - itemRewards: outcome === 'victory' ? ['콩 1', '탁주 1', '의용군 명성 +1'] : [], + itemRewards: outcome === 'victory' ? [...battleScenario.itemRewards] : [], completedCampDialogues: [], createdAt: new Date().toISOString() }); @@ -2838,13 +2847,15 @@ export class BattleScene extends Phaser.Scene { } private hasBattleSave() { - return Boolean(window.localStorage.getItem(battleSaveStorageKey)); + return Boolean(window.localStorage.getItem(battleSaveStorageKey) ?? window.localStorage.getItem(legacyBattleSaveStorageKey)); } private saveBattleState() { try { const state = this.createBattleSaveState(); window.localStorage.setItem(battleSaveStorageKey, JSON.stringify(state)); + window.localStorage.removeItem(legacyBattleSaveStorageKey); + saveCampaignState(); this.renderSituationPanel(`전투 상태를 저장했습니다.\n${this.formatSavedAt(state.savedAt)}`); } catch { this.renderSituationPanel('전투 상태를 저장하지 못했습니다.'); @@ -2852,7 +2863,7 @@ export class BattleScene extends Phaser.Scene { } private loadBattleState() { - const raw = window.localStorage.getItem(battleSaveStorageKey); + const raw = window.localStorage.getItem(battleSaveStorageKey) ?? window.localStorage.getItem(legacyBattleSaveStorageKey); if (!raw) { this.renderSituationPanel('불러올 저장 데이터가 없습니다.'); return; @@ -2863,6 +2874,9 @@ export class BattleScene extends Phaser.Scene { if (!state || state.version !== 1 || !Array.isArray(state.units)) { throw new Error('Invalid battle save'); } + if (state.battleId && state.battleId !== battleScenario.id) { + throw new Error('Battle save does not belong to this scenario'); + } this.applyBattleSaveState(state); this.renderSituationPanel(`저장된 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`); } catch { @@ -2873,6 +2887,8 @@ export class BattleScene extends Phaser.Scene { private createBattleSaveState(): BattleSaveState { return { version: 1, + battleId: battleScenario.id, + campaignStep: getCampaignState().step, savedAt: new Date().toISOString(), turnNumber: this.turnNumber, activeFaction: this.activeFaction, @@ -5366,6 +5382,7 @@ export class BattleScene extends Phaser.Scene { getDebugState() { return { scene: this.scene.key, + battleId: battleScenario.id, turnNumber: this.turnNumber, activeFaction: this.activeFaction, phase: this.phase, diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index df18184..1679529 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -2,7 +2,14 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems'; import { firstBattleBonds, firstBattleUnits, firstBattleVictoryPages, type PortraitKey, type UnitData } from '../data/scenario'; -import { applyCampBondExp, getFirstBattleReport, type CampBondSnapshot, type FirstBattleReport } from '../state/campaignState'; +import { + applyCampBondExp, + getCampaignState, + getFirstBattleReport, + markCampaignStep, + type CampaignState, + type FirstBattleReport +} from '../state/campaignState'; import { palette } from '../ui/palette'; type CampTab = 'status' | 'dialogue' | 'supplies'; @@ -68,6 +75,7 @@ const campDialogues: CampDialogue[] = [ ]; export class CampScene extends Phaser.Scene { + private campaign?: CampaignState; private report?: FirstBattleReport; private selectedUnitId = 'liu-bei'; private activeTab: CampTab = 'status'; @@ -80,8 +88,9 @@ export class CampScene extends Phaser.Scene { } create() { - this.report = getFirstBattleReport() ?? this.createFallbackReport(); - this.selectedUnitId = this.report.units.find((unit) => unit.faction === 'ally')?.id ?? 'liu-bei'; + this.campaign = getCampaignState(); + this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport(); + this.selectedUnitId = this.currentUnits().find((unit) => unit.faction === 'ally')?.id ?? 'liu-bei'; soundDirector.playMusic('militia-theme'); const { width, height } = this.scale; @@ -130,7 +139,8 @@ export class CampScene extends Phaser.Scene { } const achieved = this.report.objectives.filter((objective) => objective.achieved).length; - return `첫 전투 ${this.report.turnNumber}턴 승리 · 군자금 ${this.report.rewardGold} · 목표 ${achieved}/${this.report.objectives.length} · 격파 ${this.report.defeatedEnemies}/${this.report.totalEnemies}`; + const gold = this.campaign?.gold ?? this.report.rewardGold; + return `첫 전투 ${this.report.turnNumber}턴 승리 · 군자금 ${gold} · 목표 ${achieved}/${this.report.objectives.length} · 격파 ${this.report.defeatedEnemies}/${this.report.totalEnemies}`; } private renderStaticButtons() { @@ -139,6 +149,7 @@ export class CampScene extends Phaser.Scene { this.addTabButton('정비', 'supplies', 918, 38); this.addCommandButton('다음 이야기', 1080, 38, 150, () => { soundDirector.playSelect(); + markCampaignStep('first-victory-story'); this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' }); }); } @@ -206,7 +217,7 @@ export class CampScene extends Phaser.Scene { } private renderUnitColumn() { - const allies = this.report?.units.filter((unit) => unit.faction === 'ally') ?? []; + const allies = this.currentUnits().filter((unit) => unit.faction === 'ally'); allies.forEach((unit, index) => { const x = 42; const y = 120 + index * 150; @@ -294,7 +305,7 @@ export class CampScene extends Phaser.Scene { this.track(this.add.text(x + 24, y + 56, '대화를 보면 해당 장수들의 공명 경험치가 오릅니다.', this.textStyle(14, '#d4dce6'))); campDialogues.forEach((dialogue, index) => { - const completed = this.report?.completedCampDialogues.includes(dialogue.id) ?? false; + const completed = this.completedCampDialogues().includes(dialogue.id); const rowY = y + 96 + index * 64; 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)); @@ -316,7 +327,7 @@ export class CampScene extends Phaser.Scene { private renderSelectedDialogue(x: number, y: number, width: number, height: number) { const dialogue = campDialogues.find((candidate) => candidate.id === this.selectedDialogueId) ?? campDialogues[0]; - const completed = this.report?.completedCampDialogues.includes(dialogue.id) ?? false; + const completed = this.completedCampDialogues().includes(dialogue.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); @@ -346,7 +357,7 @@ export class CampScene extends Phaser.Scene { private renderBondList(x: number, y: number, width: number) { this.track(this.add.text(x, y, '공명', this.textStyle(18, '#f2e3bf', true))); - this.report?.bonds.forEach((bond, index) => { + this.currentBonds().forEach((bond, index) => { const rowY = y + 32 + index * 36; const first = this.unitName(bond.unitIds[0]); const second = this.unitName(bond.unitIds[1]); @@ -364,13 +375,13 @@ export class CampScene extends Phaser.Scene { this.track(this.add.text(x + 24, y + 22, '정비와 보급', this.textStyle(24, '#f2e3bf', true))); this.track(this.add.text(x + 24, y + 58, '획득한 보상과 장비 성장 상태를 확인합니다. 장비 교체와 상점은 다음 확장에서 붙일 수 있습니다.', this.textStyle(14, '#d4dce6'))); - const rewards = this.report?.itemRewards.length ? this.report.itemRewards : ['콩 1', '탁주 1']; + const rewards = this.inventoryLabels(); rewards.forEach((reward, index) => { this.renderSupplyBox(reward, x + 24 + index * 160, y + 104); }); this.track(this.add.text(x + 24, y + 190, '부대 장비 요약', this.textStyle(20, '#f2e3bf', true))); - this.report?.units + this.currentUnits() .filter((unit) => unit.faction === 'ally') .forEach((unit, index) => { const rowY = y + 232 + index * 58; @@ -418,6 +429,7 @@ export class CampScene extends Phaser.Scene { const updated = applyCampBondExp(dialogue.id, dialogue.bondId, dialogue.rewardExp); if (updated) { this.report = updated; + this.campaign = getCampaignState(); } else if (this.report && !this.report.completedCampDialogues.includes(dialogue.id)) { this.report.completedCampDialogues.push(dialogue.id); const bond = this.report.bonds.find((candidate) => candidate.id === dialogue.bondId); @@ -455,11 +467,41 @@ export class CampScene extends Phaser.Scene { } private selectedUnit() { - return this.report?.units.find((unit) => unit.id === this.selectedUnitId); + return this.currentUnits().find((unit) => unit.id === this.selectedUnitId); } private unitName(unitId: string) { - return this.report?.units.find((unit) => unit.id === unitId)?.name ?? unitId; + return this.currentUnits().find((unit) => unit.id === unitId)?.name ?? unitId; + } + + private currentUnits() { + if (this.campaign?.roster.length) { + return this.campaign.roster; + } + return this.report?.units ?? []; + } + + private currentBonds() { + if (this.campaign?.bonds.length) { + return this.campaign.bonds; + } + return this.report?.bonds ?? []; + } + + private completedCampDialogues() { + if (this.campaign) { + return this.campaign.completedCampDialogues; + } + return this.report?.completedCampDialogues ?? []; + } + + private inventoryLabels() { + const inventory = this.campaign?.inventory ?? {}; + const entries = Object.entries(inventory).filter(([, amount]) => amount > 0); + if (entries.length > 0) { + return entries.map(([label, amount]) => `${label} ${amount}`); + } + return this.report?.itemRewards.length ? this.report.itemRewards : ['콩 1', '탁주 1']; } private drawBar(x: number, y: number, width: number, height: number, ratio: number, color: number) { @@ -494,6 +536,14 @@ export class CampScene extends Phaser.Scene { activeTab: this.activeTab, selectedUnitId: this.selectedUnitId, selectedDialogueId: this.selectedDialogueId, + campaign: this.campaign + ? { + step: this.campaign.step, + gold: this.campaign.gold, + inventory: this.campaign.inventory, + completedCampDialogues: this.campaign.completedCampDialogues + } + : null, report: this.report ? { rewardGold: this.report.rewardGold, diff --git a/src/game/scenes/TitleScene.ts b/src/game/scenes/TitleScene.ts index 1cfa91f..da2cf75 100644 --- a/src/game/scenes/TitleScene.ts +++ b/src/game/scenes/TitleScene.ts @@ -1,5 +1,7 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; +import { firstBattleVictoryPages } from '../data/scenario'; +import { hasCampaignSave, loadCampaignState, startNewCampaign } from '../state/campaignState'; import { palette } from '../ui/palette'; export class TitleScene extends Phaser.Scene { @@ -160,8 +162,9 @@ export class TitleScene extends Phaser.Scene { this.add.rectangle(menuX, menuY - 126, 186, 2, palette.gold, 0.7); this.add.rectangle(menuX, menuY + 126, 186, 2, palette.gold, 0.36); + const canContinue = hasCampaignSave(); this.createMenuButton(menuX, menuY - 70, '새 게임', true, () => this.startGame()); - this.createMenuButton(menuX, menuY, '이어하기', false, () => undefined); + this.createMenuButton(menuX, menuY, '이어하기', canContinue, () => this.continueGame()); this.createMenuButton(menuX, menuY + 70, '설정', true, () => this.openSettingsPanel(menuX, menuY)); } @@ -285,6 +288,31 @@ export class TitleScene extends Phaser.Scene { soundDirector.start(); soundDirector.resume(); soundDirector.playSelect(); + startNewCampaign(); + this.scene.start('StoryScene'); + } + + private continueGame() { + soundDirector.start(); + soundDirector.resume(); + soundDirector.playSelect(); + + const campaign = loadCampaignState(); + if (campaign.step === 'first-camp') { + this.scene.start('CampScene'); + return; + } + + if (campaign.step === 'first-battle') { + this.scene.start('BattleScene'); + return; + } + + if (campaign.step === 'first-victory-story') { + this.scene.start('StoryScene', { pages: firstBattleVictoryPages, nextScene: 'TitleScene' }); + return; + } + this.scene.start('StoryScene'); } } diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 194e3d7..d34f37b 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -34,40 +34,228 @@ export type FirstBattleReport = { createdAt: string; }; -let firstBattleReport: FirstBattleReport | undefined; +export type CampaignStep = 'new' | 'prologue' | 'first-battle' | 'first-camp' | 'first-victory-story'; + +export type CampaignState = { + version: 1; + updatedAt: string; + step: CampaignStep; + gold: number; + roster: UnitData[]; + bonds: CampBondSnapshot[]; + inventory: Record; + completedCampDialogues: string[]; + firstBattleReport?: FirstBattleReport; +}; + +export const campaignStorageKey = 'heros-web:campaign-state'; + +let campaignState: CampaignState | undefined; + +export function createInitialCampaignState(): CampaignState { + return { + version: 1, + updatedAt: new Date().toISOString(), + step: 'new', + gold: 0, + roster: [], + bonds: [], + inventory: {}, + completedCampDialogues: [] + }; +} + +export function startNewCampaign() { + const state = createInitialCampaignState(); + state.step = 'prologue'; + return setCampaignState(state); +} + +export function getCampaignState() { + return cloneCampaignState(ensureCampaignState()); +} + +export function setCampaignState(state: CampaignState) { + campaignState = normalizeCampaignState(state); + persistCampaignState(campaignState); + return cloneCampaignState(campaignState); +} + +export function loadCampaignState() { + campaignState = readStoredCampaignState() ?? createInitialCampaignState(); + return cloneCampaignState(campaignState); +} + +export function saveCampaignState(state = ensureCampaignState()) { + campaignState = normalizeCampaignState(state); + persistCampaignState(campaignState); + return cloneCampaignState(campaignState); +} + +export function resetCampaignState() { + campaignState = createInitialCampaignState(); + tryStorage()?.removeItem(campaignStorageKey); + return cloneCampaignState(campaignState); +} + +export function hasCampaignSave() { + return Boolean(tryStorage()?.getItem(campaignStorageKey)); +} + +export function markCampaignStep(step: CampaignStep) { + const state = ensureCampaignState(); + state.step = step; + state.updatedAt = new Date().toISOString(); + persistCampaignState(state); + return cloneCampaignState(state); +} export function setFirstBattleReport(report: FirstBattleReport) { - firstBattleReport = cloneReport(report); + const state = ensureCampaignState(); + const previousRewardGold = state.firstBattleReport?.rewardGold ?? 0; + const reportClone = cloneReport(report); + + state.firstBattleReport = reportClone; + state.step = reportClone.outcome === 'victory' ? 'first-camp' : 'first-battle'; + state.gold = Math.max(0, state.gold - previousRewardGold + reportClone.rewardGold); + state.roster = reportClone.units.filter((unit) => unit.faction === 'ally').map(cloneUnit); + state.bonds = reportClone.bonds.map(cloneBondSnapshot); + state.completedCampDialogues = [...reportClone.completedCampDialogues]; + state.inventory = inventoryFromRewards(reportClone.itemRewards); + state.updatedAt = new Date().toISOString(); + + persistCampaignState(state); } export function getFirstBattleReport() { - return firstBattleReport ? cloneReport(firstBattleReport) : undefined; + const report = ensureCampaignState().firstBattleReport; + return report ? cloneReport(report) : undefined; } export function applyCampBondExp(dialogueId: string, bondId: string, amount: number) { - if (!firstBattleReport) { + const state = ensureCampaignState(); + if (!state.firstBattleReport) { return undefined; } - const bond = firstBattleReport.bonds.find((candidate) => candidate.id === bondId); - if (!bond) { + if (!state.completedCampDialogues.includes(dialogueId)) { + state.completedCampDialogues.push(dialogueId); + } + if (!state.firstBattleReport.completedCampDialogues.includes(dialogueId)) { + state.firstBattleReport.completedCampDialogues.push(dialogueId); + } + + const campaignBond = state.bonds.find((candidate) => candidate.id === bondId); + const reportBond = state.firstBattleReport.bonds.find((candidate) => candidate.id === bondId); + if (!campaignBond || !reportBond) { return undefined; } - if (!firstBattleReport.completedCampDialogues.includes(dialogueId)) { - firstBattleReport.completedCampDialogues.push(dialogueId); + advanceBond(campaignBond, amount); + reportBond.level = campaignBond.level; + reportBond.exp = campaignBond.exp; + reportBond.battleExp = campaignBond.battleExp; + state.updatedAt = new Date().toISOString(); + + persistCampaignState(state); + return cloneReport(state.firstBattleReport); +} + +function ensureCampaignState() { + if (!campaignState) { + campaignState = readStoredCampaignState() ?? createInitialCampaignState(); + } + return campaignState; +} + +function normalizeCampaignState(state: CampaignState): CampaignState { + const normalized = cloneCampaignState(state); + normalized.version = 1; + normalized.updatedAt = new Date().toISOString(); + normalized.roster = normalized.roster.map(cloneUnit); + normalized.bonds = normalized.bonds.map(cloneBondSnapshot); + normalized.completedCampDialogues = [...new Set(normalized.completedCampDialogues)]; + normalized.inventory = { ...normalized.inventory }; + if (normalized.firstBattleReport) { + normalized.firstBattleReport = cloneReport(normalized.firstBattleReport); + } + return normalized; +} + +function readStoredCampaignState() { + const raw = tryStorage()?.getItem(campaignStorageKey); + if (!raw) { + return undefined; } + try { + const parsed = JSON.parse(raw) as CampaignState; + if (!parsed || parsed.version !== 1) { + return undefined; + } + return normalizeCampaignState(parsed); + } catch { + return undefined; + } +} + +function persistCampaignState(state: CampaignState) { + tryStorage()?.setItem(campaignStorageKey, JSON.stringify(state)); +} + +function tryStorage() { + if (typeof window === 'undefined') { + return undefined; + } + return window.localStorage; +} + +function advanceBond(bond: CampBondSnapshot, amount: number) { bond.battleExp += amount; bond.exp += amount; while (bond.exp >= 100) { bond.exp -= 100; bond.level = Math.min(100, bond.level + 1); } +} - return cloneReport(firstBattleReport); +function inventoryFromRewards(rewards: string[]) { + return rewards.reduce>((inventory, reward) => { + const { label, amount } = parseRewardLabel(reward); + inventory[label] = (inventory[label] ?? 0) + amount; + return inventory; + }, {}); +} + +function parseRewardLabel(reward: string) { + const normalized = reward.replace(/\s+/g, ' ').trim(); + const match = normalized.match(/^(.*?)(?:\s*[+xX]?\s*(\d+))$/); + + if (!match) { + return { label: normalized, amount: 1 }; + } + + return { + label: match[1].trim() || normalized, + amount: Number(match[2] ?? 1) + }; +} + +function cloneCampaignState(state: CampaignState): CampaignState { + return JSON.parse(JSON.stringify(state)) as CampaignState; } function cloneReport(report: FirstBattleReport): FirstBattleReport { return JSON.parse(JSON.stringify(report)) as FirstBattleReport; } + +function cloneUnit(unit: UnitData): UnitData { + return JSON.parse(JSON.stringify(unit)) as UnitData; +} + +function cloneBondSnapshot(bond: CampBondSnapshot): CampBondSnapshot { + return { + ...bond, + unitIds: [...bond.unitIds] as [string, string] + }; +}