From 49822831f56f09ce398ba56f3e67aa09c4ab0a3f Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 22 Jun 2026 17:39:09 +0900 Subject: [PATCH] Prepare battle scenario progression foundations --- scripts/verify-flow.mjs | 25 +++ src/game/data/battles.ts | 69 +++++++- src/game/scenes/BattleScene.ts | 295 ++++++++++++++++++-------------- src/game/scenes/CampScene.ts | 3 + src/game/state/campaignState.ts | 173 ++++++++++++++++--- 5 files changed, 418 insertions(+), 147 deletions(-) diff --git a/scripts/verify-flow.mjs b/scripts/verify-flow.mjs index cd1157b..e0af259 100644 --- a/scripts/verify-flow.mjs +++ b/scripts/verify-flow.mjs @@ -76,6 +76,10 @@ try { throw new Error(`Debug battle state was not available: ${JSON.stringify(result.battleState)}`); } + if (result.battleState.battleId !== 'first-battle-zhuo-commandery') { + throw new Error(`Expected first battle scenario id: ${JSON.stringify(result.battleState)}`); + } + const actionTexturesLoaded = await page.evaluate(() => { const textures = window.__HEROS_GAME__?.textures; return [ @@ -170,12 +174,33 @@ try { if ( !campaignSaveAfterDialogue || campaignSaveAfterDialogue.step !== 'first-camp' || + campaignSaveAfterDialogue.latestBattleId !== 'first-battle-zhuo-commandery' || + !campaignSaveAfterDialogue.battleHistory?.['first-battle-zhuo-commandery'] || !campaignSaveAfterDialogue.completedCampDialogues?.length || Object.keys(campaignSaveAfterDialogue.inventory ?? {}).length === 0 ) { throw new Error(`Expected campaign save to persist camp progress: ${JSON.stringify(campaignSaveAfterDialogue)}`); } + const campaignSlotAfterDialogue = await page.evaluate(() => { + const raw = window.localStorage.getItem('heros-web:campaign-state:slot-1'); + return raw ? JSON.parse(raw) : undefined; + }); + if (!campaignSlotAfterDialogue || campaignSlotAfterDialogue.latestBattleId !== 'first-battle-zhuo-commandery') { + throw new Error(`Expected campaign slot 1 to persist progress: ${JSON.stringify(campaignSlotAfterDialogue)}`); + } + + await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene')); + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('TitleScene'); + }); + await page.mouse.click(962, 310); + await page.waitForFunction(() => { + const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; + return activeScenes.includes('CampScene'); + }); + 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 index 27b4344..b0c3697 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -9,8 +9,27 @@ import { type UnitData } from './scenario'; -export type BattleScenarioDefinition = { +export type BattleScenarioId = 'first-battle-zhuo-commandery'; + +export type BattleObjectiveKind = 'defeat-leader' | 'keep-unit-alive' | 'secure-terrain' | 'quick-victory'; + +export type BattleObjectiveDefinition = { id: string; + kind: BattleObjectiveKind; + label: string; + rewardGold: number; + unitId?: string; + terrain?: string; + maxTurn?: number; +}; + +export type BattleDefeatConditionDefinition = { + kind: 'unit-defeated'; + unitId: string; +}; + +export type BattleScenarioDefinition = { + id: BattleScenarioId; title: string; map: BattleMap; units: UnitData[]; @@ -19,6 +38,8 @@ export type BattleScenarioDefinition = { leaderUnitId: string; quickVictoryTurnLimit: number; baseVictoryGold: number; + objectives: BattleObjectiveDefinition[]; + defeatConditions: BattleDefeatConditionDefinition[]; itemRewards: string[]; victoryPages: StoryPage[]; nextCampScene: string; @@ -34,7 +55,53 @@ export const firstBattleScenario: BattleScenarioDefinition = { leaderUnitId: 'rebel-leader', quickVictoryTurnLimit: 8, baseVictoryGold: 300, + objectives: [ + { + id: 'leader', + kind: 'defeat-leader', + label: '황건 두령 격파', + rewardGold: 200, + unitId: 'rebel-leader' + }, + { + id: 'liu-bei', + kind: 'keep-unit-alive', + label: '유비 생존', + rewardGold: 100, + unitId: 'liu-bei' + }, + { + id: 'village', + kind: 'secure-terrain', + label: '마을 확보', + rewardGold: 150, + terrain: 'village' + }, + { + id: 'quick', + kind: 'quick-victory', + label: '8턴 안에 승리', + rewardGold: 120, + maxTurn: 8 + } + ], + defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }], itemRewards: ['콩 1', '탁주 1', '의용군 명성 +1'], victoryPages: firstBattleVictoryPages, nextCampScene: 'CampScene' }; + +export const defaultBattleScenarioId: BattleScenarioId = firstBattleScenario.id; + +export const battleScenarios: Record = { + [firstBattleScenario.id]: firstBattleScenario +}; + +export const defaultBattleScenario = battleScenarios[defaultBattleScenarioId]; + +export function getBattleScenario(id: string | undefined) { + if (id && id in battleScenarios) { + return battleScenarios[id as BattleScenarioId]; + } + return defaultBattleScenario; +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index b56bae7..3da6418 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -1,14 +1,7 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; -import { - firstBattleBonds, - firstBattleMap, - firstBattleUnits, - type BattleBond, - type UnitData, - type UnitStats -} from '../data/scenario'; -import { firstBattleScenario } from '../data/battles'; +import { type BattleBond, type UnitData, type UnitStats } from '../data/scenario'; +import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition } from '../data/battles'; import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules'; import { equipmentExpToNext, @@ -106,6 +99,10 @@ type MapMenuAction = 'endTurn' | 'save' | 'load' | 'roster' | 'bond' | 'situatio type EnemyAiBehavior = 'aggressive' | 'guard' | 'hold'; type BattleOutcome = 'victory' | 'defeat'; +type BattleSceneData = { + battleId?: string; +}; + type CommandButton = { command: BattleCommand; background: Phaser.GameObjects.Rectangle; @@ -289,11 +286,14 @@ type BattleSaveState = { const maxEquipmentLevel = 9; const maxCharacterLevel = 99; -const battleScenario = firstBattleScenario; +let battleScenario = defaultBattleScenario; +let battleMap = battleScenario.map; +let battleUnits = battleScenario.units; +let battleBonds = battleScenario.bonds; const legacyBattleSaveStorageKey = 'heros-web:first-battle-state'; -const battleSaveStorageKey = `heros-web:battle:${battleScenario.id}`; -const initialFirstBattleUnits = firstBattleUnits.map(cloneUnitData); -const initialFirstBattleBonds = firstBattleBonds.map(cloneBattleBond); +let battleSaveStorageKey = `heros-web:battle:${battleScenario.id}`; +let initialBattleUnits = battleUnits.map(cloneUnitData); +let initialBattleBonds = battleBonds.map(cloneBattleBond); const usableCatalog: Record = { aid: { @@ -418,9 +418,9 @@ const defaultEnemyAiByClass: Partial> = { }; const guardDetectionRange = 5; -const leaderUnitId = battleScenario.leaderUnitId; -const quickVictoryTurnLimit = battleScenario.quickVictoryTurnLimit; -const baseVictoryGold = battleScenario.baseVictoryGold; +let leaderUnitId = battleScenario.leaderUnitId; +let quickVictoryTurnLimit = battleScenario.quickVictoryTurnLimit; +let baseVictoryGold = battleScenario.baseVictoryGold; const commandLabels: Record = { attack: '공격', @@ -483,6 +483,19 @@ function cloneBattleBond(bond: BattleBond): BattleBond { }; } +function configureBattleScenario(scenario: BattleScenarioDefinition) { + battleScenario = scenario; + battleMap = scenario.map; + battleUnits = scenario.units; + battleBonds = scenario.bonds; + battleSaveStorageKey = `heros-web:battle:${scenario.id}`; + initialBattleUnits = battleUnits.map(cloneUnitData); + initialBattleBonds = battleBonds.map(cloneBattleBond); + leaderUnitId = scenario.leaderUnitId; + quickVictoryTurnLimit = scenario.quickVictoryTurnLimit; + baseVictoryGold = scenario.baseVictoryGold; +} + export class BattleScene extends Phaser.Scene { private layout!: BattleLayout; private phase: BattlePhase = 'idle'; @@ -532,6 +545,10 @@ export class BattleScene extends Phaser.Scene { super('BattleScene'); } + init(data?: BattleSceneData) { + configureBattleScenario(getBattleScenario(data?.battleId)); + } + create() { this.resetBattleData(); const campaign = getCampaignState(); @@ -540,7 +557,7 @@ export class BattleScene extends Phaser.Scene { } const { width, height } = this.scale; this.layout = this.createLayout(width, height); - const startUnit = firstBattleUnits.find((unit) => unit.id === 'liu-bei') ?? firstBattleUnits[0]; + const startUnit = battleUnits.find((unit) => unit.id === 'liu-bei') ?? battleUnits[0]; this.setCameraTilePosition( (startUnit?.x ?? 0) - Math.floor(this.layout.visibleColumns / 2), (startUnit?.y ?? 0) - Math.floor(this.layout.visibleRows / 2), @@ -585,8 +602,8 @@ export class BattleScene extends Phaser.Scene { } private resetBattleData() { - firstBattleUnits.splice(0, firstBattleUnits.length, ...initialFirstBattleUnits.map(cloneUnitData)); - firstBattleBonds.splice(0, firstBattleBonds.length, ...initialFirstBattleBonds.map(cloneBattleBond)); + battleUnits.splice(0, battleUnits.length, ...initialBattleUnits.map(cloneUnitData)); + battleBonds.splice(0, battleBonds.length, ...initialBattleBonds.map(cloneBattleBond)); } private createLayout(width: number, height: number): BattleLayout { @@ -595,9 +612,9 @@ export class BattleScene extends Phaser.Scene { const panelWidth = Phaser.Math.Clamp(Math.floor(width * 0.27), 320, 360); const mapWidth = width - panelWidth - gap - margin * 2; const mapHeight = height - margin * 2; - const visibleRows = Math.min(12, firstBattleMap.height); + const visibleRows = Math.min(12, battleMap.height); const rowBasedTileSize = Math.floor((mapHeight - 36) / visibleRows); - const visibleColumns = Math.min(firstBattleMap.width, Math.max(12, Math.floor((mapWidth - 16) / rowBasedTileSize))); + const visibleColumns = Math.min(battleMap.width, Math.max(12, Math.floor((mapWidth - 16) / rowBasedTileSize))); const tileSize = Math.floor(Math.min((mapHeight - 36) / visibleRows, (mapWidth - 16) / visibleColumns)); const gridWidth = tileSize * visibleColumns; const gridHeight = tileSize * visibleRows; @@ -637,7 +654,7 @@ export class BattleScene extends Phaser.Scene { 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); + background.setDisplaySize(battleMap.width * layout.tileSize, battleMap.height * layout.tileSize); background.setMask(mapMask); this.mapBackground = background; @@ -649,7 +666,7 @@ export class BattleScene extends Phaser.Scene { .setDepth(8); this.terrainTileViews = []; - firstBattleMap.terrain.forEach((row, y) => { + battleMap.terrain.forEach((row, y) => { row.forEach((terrain, x) => { const terrainRule = getTerrainRule(terrain); const tile = this.add.rectangle(0, 0, layout.tileSize, layout.tileSize, terrainRule.color, terrainRule.alpha); @@ -670,7 +687,7 @@ export class BattleScene extends Phaser.Scene { } private drawUnits() { - firstBattleUnits.forEach((unit) => { + battleUnits.forEach((unit) => { const sizeRatio = unit.faction === 'ally' ? 1.16 : 1.1; const textureBase = unitTexture[unit.id] ?? 'unit-rebel'; const sprite = this.add.sprite(this.tileCenterX(unit.x), this.tileCenterY(unit.y), textureBase, this.unitFrameIndex('south')); @@ -739,9 +756,9 @@ export class BattleScene extends Phaser.Scene { const { panelX, panelY, panelWidth, panelHeight } = this.layout; const maxWidth = panelWidth - 48; const maxHeight = 132; - const cellSize = Math.floor(Math.min(maxWidth / firstBattleMap.width, maxHeight / firstBattleMap.height)); - const width = firstBattleMap.width * cellSize; - const height = firstBattleMap.height * cellSize; + const cellSize = Math.floor(Math.min(maxWidth / battleMap.width, maxHeight / battleMap.height)); + const width = battleMap.width * cellSize; + const height = battleMap.height * cellSize; const x = panelX + Math.floor((panelWidth - width) / 2); const y = panelY + panelHeight - height - 24; this.miniMapLayout = { x, y, width, height, cellSize }; @@ -759,7 +776,7 @@ export class BattleScene extends Phaser.Scene { hitArea.on('pointerdown', (pointer: Phaser.Input.Pointer) => this.handleMiniMapPointer(pointer)); this.miniMapObjects.push(hitArea); - firstBattleMap.terrain.forEach((row, tileY) => { + battleMap.terrain.forEach((row, tileY) => { row.forEach((terrain, tileX) => { const rule = getTerrainRule(terrain); const cell = this.add.rectangle( @@ -778,7 +795,7 @@ export class BattleScene extends Phaser.Scene { }); }); - firstBattleUnits.forEach((unit) => { + battleUnits.forEach((unit) => { const dot = this.add.rectangle(0, 0, Math.max(3, cellSize), Math.max(3, cellSize), unit.faction === 'ally' ? 0x4aa9ff : 0xff715f, 0.96); dot.setOrigin(0.5); dot.setDepth(28); @@ -803,12 +820,12 @@ export class BattleScene extends Phaser.Scene { const tileX = Phaser.Math.Clamp( Math.floor((pointer.x - this.miniMapLayout.x) / this.miniMapLayout.cellSize), 0, - firstBattleMap.width - 1 + battleMap.width - 1 ); const tileY = Phaser.Math.Clamp( Math.floor((pointer.y - this.miniMapLayout.y) / this.miniMapLayout.cellSize), 0, - firstBattleMap.height - 1 + battleMap.height - 1 ); this.centerCameraOnTile(tileX, tileY); soundDirector.playSelect(); @@ -870,11 +887,11 @@ export class BattleScene extends Phaser.Scene { } private maxCameraTileX() { - return Math.max(0, firstBattleMap.width - this.layout.visibleColumns); + return Math.max(0, battleMap.width - this.layout.visibleColumns); } private maxCameraTileY() { - return Math.max(0, firstBattleMap.height - this.layout.visibleRows); + return Math.max(0, battleMap.height - this.layout.visibleRows); } private updateCameraView() { @@ -895,7 +912,7 @@ export class BattleScene extends Phaser.Scene { } }); - firstBattleUnits.forEach((unit) => this.positionUnitView(unit)); + battleUnits.forEach((unit) => this.positionUnitView(unit)); this.updateMiniMap(); } @@ -906,7 +923,7 @@ export class BattleScene extends Phaser.Scene { const { x, y, cellSize } = this.miniMapLayout; this.miniMapUnitDots.forEach((dot, unitId) => { - const unit = firstBattleUnits.find((candidate) => candidate.id === unitId); + const unit = battleUnits.find((candidate) => candidate.id === unitId); if (!unit) { return; } @@ -1115,7 +1132,7 @@ export class BattleScene extends Phaser.Scene { return; } - const terrain = firstBattleMap.terrain[nextY][nextX]; + const terrain = battleMap.terrain[nextY][nextX]; const stepCost = this.movementCost(unit, terrain); if (!Number.isFinite(stepCost)) { return; @@ -1146,7 +1163,7 @@ export class BattleScene extends Phaser.Scene { } private isInBounds(x: number, y: number) { - return x >= 0 && y >= 0 && x < firstBattleMap.width && y < firstBattleMap.height; + return x >= 0 && y >= 0 && x < battleMap.width && y < battleMap.height; } private tileKey(x: number, y: number) { @@ -1154,7 +1171,7 @@ export class BattleScene extends Phaser.Scene { } private isOccupied(x: number, y: number, exceptUnitId?: string) { - return firstBattleUnits.some((unit) => unit.id !== exceptUnitId && unit.hp > 0 && unit.x === x && unit.y === y); + return battleUnits.some((unit) => unit.id !== exceptUnitId && unit.hp > 0 && unit.x === x && unit.y === y); } private tileCenterX(x: number) { @@ -1692,19 +1709,21 @@ export class BattleScene extends Phaser.Scene { return true; } - const liuBei = firstBattleUnits.find((unit) => unit.id === 'liu-bei'); - if (!liuBei || liuBei.hp <= 0) { + const defeatTriggered = battleScenario.defeatConditions.some((condition) => { + return condition.kind === 'unit-defeated' && !this.isUnitAlive(condition.unitId); + }); + if (defeatTriggered) { this.completeBattle('defeat'); return true; } - const leader = firstBattleUnits.find((unit) => unit.id === leaderUnitId); + const leader = battleUnits.find((unit) => unit.id === leaderUnitId); if (leader && leader.hp <= 0) { this.completeBattle('victory'); return true; } - const enemiesAlive = firstBattleUnits.some((unit) => unit.faction === 'enemy' && unit.hp > 0); + const enemiesAlive = battleUnits.some((unit) => unit.faction === 'enemy' && unit.hp > 0); if (!enemiesAlive) { this.completeBattle('victory'); return true; @@ -1753,9 +1772,9 @@ export class BattleScene extends Phaser.Scene { const panelHeight = 660; const left = Math.floor((this.scale.width - panelWidth) / 2); const top = Math.floor((this.scale.height - panelHeight) / 2); - const allies = firstBattleUnits.filter((unit) => unit.faction === 'ally'); - const defeatedEnemies = firstBattleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp <= 0).length; - const totalEnemies = firstBattleUnits.filter((unit) => unit.faction === 'enemy').length; + const allies = battleUnits.filter((unit) => unit.faction === 'ally'); + const defeatedEnemies = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp <= 0).length; + const totalEnemies = battleUnits.filter((unit) => unit.faction === 'enemy').length; const aliveAllies = allies.filter((unit) => unit.hp > 0).length; const objectives = this.resultObjectives(outcome); @@ -1838,42 +1857,53 @@ export class BattleScene extends Phaser.Scene { } private resultObjectives(outcome: BattleOutcome): BattleObjectiveResult[] { - const leader = firstBattleUnits.find((unit) => unit.id === leaderUnitId); - const leaderDefeated = !leader || leader.hp <= 0; - const allAlliesSurvived = firstBattleUnits.filter((unit) => unit.faction === 'ally').every((unit) => unit.hp > 0); - const villageSecured = !this.hasEnemyOnVillageTile(); - const quickVictory = outcome === 'victory' && this.turnNumber <= quickVictoryTurnLimit; - - return [ - { - id: 'leader', - label: '황건 두령 격파', - achieved: outcome === 'victory' && leaderDefeated, - detail: leaderDefeated ? '두령 퇴각' : '두령 생존', - rewardGold: 200 - }, - { - id: 'liu-bei', - label: '유비 생존', - achieved: allAlliesSurvived || firstBattleUnits.some((unit) => unit.id === 'liu-bei' && unit.hp > 0), - detail: allAlliesSurvived ? '삼형제 생존' : '유비 생존', - rewardGold: 100 - }, - { - id: 'village', - label: '마을 확보', - achieved: outcome === 'victory' && villageSecured, - detail: villageSecured ? '적 점거 없음' : '적 잔존', - rewardGold: 150 - }, - { - id: 'quick', - label: `${quickVictoryTurnLimit}턴 안에 승리`, - achieved: quickVictory, - detail: `${this.turnNumber}턴 종료`, - rewardGold: 120 + return battleScenario.objectives.map((objective) => { + if (objective.kind === 'defeat-leader') { + const targetId = objective.unitId ?? leaderUnitId; + const defeated = this.isUnitDefeated(targetId); + return { + id: objective.id, + label: objective.label, + achieved: outcome === 'victory' && defeated, + detail: defeated ? `${this.unitName(targetId)} 격파` : `${this.unitName(targetId)} 생존`, + rewardGold: objective.rewardGold + }; } - ]; + + if (objective.kind === 'keep-unit-alive') { + const targetId = objective.unitId ?? 'liu-bei'; + const alive = this.isUnitAlive(targetId); + return { + id: objective.id, + label: objective.label, + achieved: alive, + detail: alive ? `${this.unitName(targetId)} 생존` : `${this.unitName(targetId)} 퇴각`, + rewardGold: objective.rewardGold + }; + } + + if (objective.kind === 'secure-terrain') { + const terrain = objective.terrain ?? 'village'; + const secured = !this.hasEnemyOnTerrain(terrain); + return { + id: objective.id, + label: objective.label, + achieved: outcome === 'victory' && secured, + detail: secured ? '점거한 적 없음' : '적이 점거 중', + rewardGold: objective.rewardGold + }; + } + + const maxTurn = objective.maxTurn ?? quickVictoryTurnLimit; + const achieved = outcome === 'victory' && this.turnNumber <= maxTurn; + return { + id: objective.id, + label: objective.label, + achieved, + detail: `${this.turnNumber}턴 종료`, + rewardGold: objective.rewardGold + }; + }); } private resultGold(outcome: BattleOutcome) { @@ -1885,7 +1915,7 @@ export class BattleScene extends Phaser.Scene { } private resultMvp() { - return firstBattleUnits + return battleUnits .filter((unit) => unit.faction === 'ally') .map((unit) => { const stats = this.statsFor(unit.id); @@ -1898,17 +1928,19 @@ export class BattleScene extends Phaser.Scene { private publishFirstBattleReport(outcome: BattleOutcome) { const objectives = this.resultObjectives(outcome); const mvp = this.resultMvp(); - const defeatedEnemies = firstBattleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp <= 0).length; - const totalEnemies = firstBattleUnits.filter((unit) => unit.faction === 'enemy').length; + const defeatedEnemies = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp <= 0).length; + const totalEnemies = battleUnits.filter((unit) => unit.faction === 'enemy').length; setFirstBattleReport({ + battleId: battleScenario.id, + battleTitle: battleScenario.title, outcome, turnNumber: this.turnNumber, rewardGold: this.resultGold(outcome), defeatedEnemies, totalEnemies, objectives: objectives.map((objective) => ({ ...objective })), - units: firstBattleUnits.map(cloneUnitData), + units: battleUnits.map(cloneUnitData), bonds: Array.from(this.bondStates.values()).map((bond) => ({ ...bond, unitIds: [...bond.unitIds] as [string, string] @@ -2075,7 +2107,7 @@ export class BattleScene extends Phaser.Scene { } private characterResultText(unit: UnitData) { - const initial = initialFirstBattleUnits.find((candidate) => candidate.id === unit.id); + const initial = initialBattleUnits.find((candidate) => candidate.id === unit.id); const gained = initial ? Math.max(0, this.characterProgressScore(unit.level, unit.exp) - this.characterProgressScore(initial.level, initial.exp)) : 0; const levelDelta = initial ? unit.level - initial.level : 0; const levelText = levelDelta > 0 ? ` / Lv +${levelDelta}` : ''; @@ -2083,7 +2115,7 @@ export class BattleScene extends Phaser.Scene { } private equipmentResultText(unit: UnitData) { - const initial = initialFirstBattleUnits.find((candidate) => candidate.id === unit.id); + const initial = initialBattleUnits.find((candidate) => candidate.id === unit.id); return equipmentSlots .map((slot) => { const state = unit.equipment[slot]; @@ -2133,7 +2165,7 @@ export class BattleScene extends Phaser.Scene { } private bondResultText(bond: BondState) { - const initial = initialFirstBattleBonds.find((candidate) => candidate.id === bond.id); + const initial = initialBattleBonds.find((candidate) => candidate.id === bond.id); const gained = initial ? Math.max(0, this.characterProgressScore(bond.level, bond.exp) - this.characterProgressScore(initial.level, initial.exp)) : bond.battleExp; const levelDelta = initial ? bond.level - initial.level : 0; const levelText = levelDelta > 0 ? ` / Lv +${levelDelta}` : ''; @@ -2171,7 +2203,7 @@ export class BattleScene extends Phaser.Scene { } private damageableTargets(attacker: UnitData, action: DamageCommand, usable?: BattleUsable) { - return firstBattleUnits.filter((target) => this.canUseDamageCommand(attacker, target, action, usable)); + return battleUnits.filter((target) => this.canUseDamageCommand(attacker, target, action, usable)); } private canAttack(attacker: UnitData, target: UnitData) { @@ -2201,7 +2233,7 @@ export class BattleScene extends Phaser.Scene { } private supportTargets(user: UnitData, usable: BattleUsable) { - return firstBattleUnits.filter((target) => this.canUseSupportCommand(user, target, usable)); + return battleUnits.filter((target) => this.canUseSupportCommand(user, target, usable)); } private canUseSupportCommand(user: UnitData, target: UnitData, usable: BattleUsable) { @@ -2224,7 +2256,7 @@ export class BattleScene extends Phaser.Scene { } private combatPreview(attacker: UnitData, defender: UnitData, action: DamageCommand, usable?: BattleUsable): CombatPreview { - const terrain = firstBattleMap.terrain[defender.y][defender.x]; + const terrain = battleMap.terrain[defender.y][defender.x]; const terrainRule = getTerrainRule(terrain); const attackPower = this.actionPower(attacker, action, usable); const defensePower = this.actionDefense(defender, action, terrainRule.defenseBonus); @@ -2526,12 +2558,12 @@ export class BattleScene extends Phaser.Scene { } private remainingAllyCount() { - return firstBattleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0 && !this.actedUnitIds.has(unit.id)).length; + return battleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0 && !this.actedUnitIds.has(unit.id)).length; } private createBondStates() { return new Map( - firstBattleBonds.map((bond) => [ + battleBonds.map((bond) => [ bond.id, { ...bond, @@ -2552,14 +2584,14 @@ export class BattleScene extends Phaser.Scene { private createEnemyHomeTiles() { return new Map( - firstBattleUnits + battleUnits .filter((unit) => unit.faction === 'enemy') .map((unit) => [unit.id, { x: unit.x, y: unit.y }] as const) ); } private createBattleStats() { - return new Map(firstBattleUnits.map((unit) => [unit.id, this.emptyBattleStats()] as const)); + return new Map(battleUnits.map((unit) => [unit.id, this.emptyBattleStats()] as const)); } private emptyBattleStats(): UnitBattleStats { @@ -2647,7 +2679,7 @@ export class BattleScene extends Phaser.Scene { } private checkBattleEvents() { - const leader = firstBattleUnits.find((unit) => unit.id === leaderUnitId); + const leader = battleUnits.find((unit) => unit.id === leaderUnitId); if (leader && leader.hp > 0 && leader.hp <= Math.ceil(leader.maxHp / 2)) { this.triggerBattleEvent('leader-wavering', '두령 동요', [`${leader.name}의 기세가 꺾였습니다.`, '두령을 몰아붙이면 황건적의 전열이 무너집니다.']); } @@ -2662,11 +2694,24 @@ export class BattleScene extends Phaser.Scene { } private hasEnemyOnVillageTile() { - return firstBattleUnits.some((unit) => { - return unit.faction === 'enemy' && unit.hp > 0 && firstBattleMap.terrain[unit.y][unit.x] === 'village'; + return this.hasEnemyOnTerrain('village'); + } + + private hasEnemyOnTerrain(terrain: string) { + return battleUnits.some((unit) => { + return unit.faction === 'enemy' && unit.hp > 0 && battleMap.terrain[unit.y][unit.x] === terrain; }); } + private isUnitAlive(unitId: string) { + const unit = battleUnits.find((candidate) => candidate.id === unitId); + return Boolean(unit && unit.hp > 0); + } + + private isUnitDefeated(unitId: string) { + return !this.isUnitAlive(unitId); + } + private handleRightClick(pointer: Phaser.Input.Pointer) { if (this.battleOutcome) { return; @@ -2896,7 +2941,7 @@ export class BattleScene extends Phaser.Scene { actedUnitIds: Array.from(this.actedUnitIds), attackIntents: this.attackIntents.map((intent) => ({ ...intent })), battleLog: [...this.battleLog], - units: firstBattleUnits.map((unit) => ({ + units: battleUnits.map((unit) => ({ id: unit.id, level: unit.level, exp: unit.exp, @@ -2954,7 +2999,7 @@ export class BattleScene extends Phaser.Scene { this.triggeredBattleEvents = new Set(state.triggeredBattleEvents ?? []); state.units.forEach((savedUnit) => { - const unit = firstBattleUnits.find((candidate) => candidate.id === savedUnit.id); + const unit = battleUnits.find((candidate) => candidate.id === savedUnit.id); if (!unit) { return; } @@ -2965,13 +3010,13 @@ export class BattleScene extends Phaser.Scene { unit.maxHp = savedUnit.maxHp; unit.attack = savedUnit.attack; unit.move = savedUnit.move; - unit.x = Phaser.Math.Clamp(savedUnit.x, 0, firstBattleMap.width - 1); - unit.y = Phaser.Math.Clamp(savedUnit.y, 0, firstBattleMap.height - 1); + unit.x = Phaser.Math.Clamp(savedUnit.x, 0, battleMap.width - 1); + unit.y = Phaser.Math.Clamp(savedUnit.y, 0, battleMap.height - 1); unit.equipment = this.cloneEquipment(savedUnit.equipment); this.restoreUnitView(unit, savedUnit.direction); }); - const cameraFocus = firstBattleUnits.find((unit) => unit.faction === 'ally' && unit.hp > 0) ?? firstBattleUnits[0]; + const cameraFocus = battleUnits.find((unit) => unit.faction === 'ally' && unit.hp > 0) ?? battleUnits[0]; if (cameraFocus) { this.centerCameraOnTile(cameraFocus.x, cameraFocus.y); } else { @@ -3078,7 +3123,7 @@ export class BattleScene extends Phaser.Scene { return; } - const enemies = firstBattleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0); + const enemies = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0); const messages: string[] = []; for (const enemy of enemies) { @@ -3166,7 +3211,7 @@ export class BattleScene extends Phaser.Scene { } private chooseTargetInRange(enemy: UnitData) { - return firstBattleUnits + return battleUnits .filter((unit) => unit.faction === 'ally' && unit.hp > 0 && this.canAttack(enemy, unit)) .sort((a, b) => a.hp - b.hp || this.tileDistance(enemy, a) - this.tileDistance(enemy, b))[0]; } @@ -3178,14 +3223,14 @@ export class BattleScene extends Phaser.Scene { .sort((a, b) => { const aDistance = Math.abs(a.x - target.x) + Math.abs(a.y - target.y); const bDistance = Math.abs(b.x - target.x) + Math.abs(b.y - target.y); - const aTerrain = getTerrainRule(firstBattleMap.terrain[a.y][a.x]).defenseBonus; - const bTerrain = getTerrainRule(firstBattleMap.terrain[b.y][b.x]).defenseBonus; + const aTerrain = getTerrainRule(battleMap.terrain[a.y][a.x]).defenseBonus; + const bTerrain = getTerrainRule(battleMap.terrain[b.y][b.x]).defenseBonus; return aDistance - bDistance || bTerrain - aTerrain || a.cost - b.cost; })[0]; } private nearestAliveUnit(unit: UnitData, faction: UnitData['faction']) { - return firstBattleUnits + return battleUnits .filter((candidate) => candidate.faction === faction && candidate.hp > 0) .sort((a, b) => this.tileDistance(unit, a) - this.tileDistance(unit, b))[0]; } @@ -4145,10 +4190,10 @@ export class BattleScene extends Phaser.Scene { private applyTerrainRecovery(faction: ActiveFaction) { const messages: string[] = []; - firstBattleUnits + battleUnits .filter((unit) => unit.faction === faction && unit.hp > 0) .forEach((unit) => { - const terrain = firstBattleMap.terrain[unit.y][unit.x]; + const terrain = battleMap.terrain[unit.y][unit.x]; const terrainRule = getTerrainRule(terrain); const hpGain = Math.min(terrainRule.recoveryHp ?? 0, unit.maxHp - unit.hp); const moraleBonus = terrainRule.moraleBonus ?? 0; @@ -4228,7 +4273,7 @@ export class BattleScene extends Phaser.Scene { private resetActedStyles() { this.unitViews.forEach((view, unitId) => { - const unit = firstBattleUnits.find((candidate) => candidate.id === unitId); + const unit = battleUnits.find((candidate) => candidate.id === unitId); if (unit && unit.hp <= 0) { this.applyDefeatedStyle(unit); return; @@ -4333,7 +4378,7 @@ export class BattleScene extends Phaser.Scene { } private findEnemyPressureTarget() { - const allies = firstBattleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0); + const allies = battleUnits.filter((unit) => unit.faction === 'ally' && unit.hp > 0); if (allies.length === 0) { return undefined; } @@ -4342,7 +4387,7 @@ export class BattleScene extends Phaser.Scene { } private nearestEnemyDistance(unit: UnitData) { - const distances = firstBattleUnits + const distances = battleUnits .filter((candidate) => candidate.faction === 'enemy' && candidate.hp > 0) .map((enemy) => this.tileDistance(unit, enemy)); return distances.length > 0 ? Math.min(...distances) : Number.POSITIVE_INFINITY; @@ -4415,7 +4460,7 @@ export class BattleScene extends Phaser.Scene { } private findNearestEnemy(unit: UnitData) { - const enemies = firstBattleUnits.filter((candidate) => candidate.faction === 'enemy' && candidate.hp > 0); + const enemies = battleUnits.filter((candidate) => candidate.faction === 'enemy' && candidate.hp > 0); return enemies.sort((a, b) => this.tileDistance(unit, a) - this.tileDistance(unit, b))[0]; } @@ -4912,9 +4957,9 @@ export class BattleScene extends Phaser.Scene { const left = panelX + 24; const width = panelWidth - 48; const top = panelY + 132; - const actedCount = firstBattleUnits.filter((unit) => unit.faction === 'ally' && this.actedUnitIds.has(unit.id)).length; - const allyCount = firstBattleUnits.filter((unit) => unit.faction === 'ally').length; - const enemyCount = firstBattleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length; + const actedCount = battleUnits.filter((unit) => unit.faction === 'ally' && this.actedUnitIds.has(unit.id)).length; + const allyCount = battleUnits.filter((unit) => unit.faction === 'ally').length; + const enemyCount = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length; const bestBond = Array.from(this.bondStates.values()).sort((a, b) => b.level - a.level)[0]; this.trackSideObject(this.add.text(left, top, '전황', { @@ -4941,7 +4986,7 @@ export class BattleScene extends Phaser.Scene { private renderTerrainDetail(x: number, y: number) { this.clearSidePanelContent(); - const terrain = firstBattleMap.terrain[y][x]; + const terrain = battleMap.terrain[y][x]; const terrainRule = getTerrainRule(terrain); const { panelX, panelY, panelWidth } = this.layout; const left = panelX + 24; @@ -5012,7 +5057,7 @@ export class BattleScene extends Phaser.Scene { } private unitName(unitId: string) { - return firstBattleUnits.find((unit) => unit.id === unitId)?.name ?? unitId; + return battleUnits.find((unit) => unit.id === unitId)?.name ?? unitId; } private renderRosterPanel(tab: RosterTab = this.rosterTab, message?: string) { @@ -5044,7 +5089,7 @@ export class BattleScene extends Phaser.Scene { tabText.setOrigin(0.5); }); - const units = firstBattleUnits.filter((unit) => unit.faction === tab); + const units = battleUnits.filter((unit) => unit.faction === tab); const listTop = top + 54; units.forEach((unit, index) => { this.renderRosterRow(unit, left, listTop + index * 50, width); @@ -5111,7 +5156,7 @@ export class BattleScene extends Phaser.Scene { const acted = this.actedUnitIds.has(unit.id); const factionColor = unit.faction === 'ally' ? palette.blue : 0xb86b55; const unitClass = getUnitClass(unit.classKey); - const terrain = firstBattleMap.terrain[unit.y][unit.x]; + const terrain = battleMap.terrain[unit.y][unit.x]; const terrainRule = getTerrainRule(terrain); const terrainRating = unitClass.terrainRatings[terrain]; @@ -5393,8 +5438,8 @@ export class BattleScene extends Phaser.Scene { y: this.cameraTileY, visibleColumns: this.layout.visibleColumns, visibleRows: this.layout.visibleRows, - mapWidth: firstBattleMap.width, - mapHeight: firstBattleMap.height + mapWidth: battleMap.width, + mapHeight: battleMap.height }, selectedUnitId: this.selectedUnit?.id ?? null, pendingMove: this.pendingMove @@ -5413,7 +5458,7 @@ export class BattleScene extends Phaser.Scene { selectedUsable: this.selectedUsable?.id ?? null, itemStocks: this.serializeItemStocks(), battleBuffs: Array.from(this.battleBuffs.values()).map((buff) => ({ ...buff })), - units: firstBattleUnits.map((unit) => ({ + units: battleUnits.map((unit) => ({ id: unit.id, name: unit.name, faction: unit.faction, @@ -5446,7 +5491,7 @@ export class BattleScene extends Phaser.Scene { } if (outcome === 'victory') { - firstBattleUnits + battleUnits .filter((unit) => unit.faction === 'enemy') .forEach((unit) => { unit.hp = 0; @@ -5454,7 +5499,7 @@ export class BattleScene extends Phaser.Scene { }); this.updateMiniMap(); } else { - const liuBei = firstBattleUnits.find((unit) => unit.id === 'liu-bei'); + const liuBei = battleUnits.find((unit) => unit.id === 'liu-bei'); if (liuBei) { liuBei.hp = 0; this.applyDefeatedStyle(liuBei); diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 1679529..76e04d1 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -1,6 +1,7 @@ import Phaser from 'phaser'; import { soundDirector } from '../audio/SoundDirector'; import { equipmentExpToNext, equipmentSlotLabels, equipmentSlots, getItem, type EquipmentSlot } from '../data/battleItems'; +import { defaultBattleScenario } from '../data/battles'; import { firstBattleBonds, firstBattleUnits, firstBattleVictoryPages, type PortraitKey, type UnitData } from '../data/scenario'; import { applyCampBondExp, @@ -119,6 +120,8 @@ export class CampScene extends Phaser.Scene { private createFallbackReport(): FirstBattleReport { const allies = firstBattleUnits.filter((unit) => unit.faction === 'ally'); return { + battleId: defaultBattleScenario.id, + battleTitle: defaultBattleScenario.title, outcome: 'victory', turnNumber: 1, rewardGold: 300, diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index d34f37b..3c71a45 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -20,6 +20,8 @@ export type CampMvpSnapshot = { }; export type FirstBattleReport = { + battleId: string; + battleTitle: string; outcome: 'victory' | 'defeat'; turnNumber: number; rewardGold: number; @@ -36,19 +38,62 @@ export type FirstBattleReport = { export type CampaignStep = 'new' | 'prologue' | 'first-battle' | 'first-camp' | 'first-victory-story'; +export type CampaignUnitProgressSnapshot = { + unitId: string; + name: string; + level: number; + exp: number; + hp: number; + maxHp: number; + equipment: UnitData['equipment']; +}; + +export type CampaignBondProgressSnapshot = { + id: string; + title: string; + level: number; + exp: number; + battleExp: number; +}; + +export type CampaignBattleSettlement = { + battleId: string; + battleTitle: string; + outcome: FirstBattleReport['outcome']; + rewardGold: number; + itemRewards: string[]; + objectives: BattleObjectiveSnapshot[]; + units: CampaignUnitProgressSnapshot[]; + bonds: CampaignBondProgressSnapshot[]; + completedAt: string; +}; + export type CampaignState = { version: 1; updatedAt: string; step: CampaignStep; + activeSaveSlot: number; gold: number; roster: UnitData[]; bonds: CampBondSnapshot[]; inventory: Record; completedCampDialogues: string[]; + battleHistory: Record; + latestBattleId?: string; firstBattleReport?: FirstBattleReport; }; export const campaignStorageKey = 'heros-web:campaign-state'; +export const campaignSaveSlotCount = 3; + +export type CampaignSaveSlotSummary = { + slot: number; + exists: boolean; + updatedAt?: string; + step?: CampaignStep; + gold?: number; + battleTitle?: string; +}; let campaignState: CampaignState | undefined; @@ -57,11 +102,13 @@ export function createInitialCampaignState(): CampaignState { version: 1, updatedAt: new Date().toISOString(), step: 'new', + activeSaveSlot: 1, gold: 0, roster: [], bonds: [], inventory: {}, - completedCampDialogues: [] + completedCampDialogues: [], + battleHistory: {} }; } @@ -77,17 +124,20 @@ export function getCampaignState() { export function setCampaignState(state: CampaignState) { campaignState = normalizeCampaignState(state); + campaignState.updatedAt = new Date().toISOString(); persistCampaignState(campaignState); return cloneCampaignState(campaignState); } -export function loadCampaignState() { - campaignState = readStoredCampaignState() ?? createInitialCampaignState(); +export function loadCampaignState(slot?: number) { + campaignState = readStoredCampaignState(slot) ?? readStoredCampaignState() ?? readLatestSlottedCampaignState() ?? createInitialCampaignState(); return cloneCampaignState(campaignState); } -export function saveCampaignState(state = ensureCampaignState()) { +export function saveCampaignState(state = ensureCampaignState(), slot = state.activeSaveSlot) { campaignState = normalizeCampaignState(state); + campaignState.activeSaveSlot = normalizeSlot(slot); + campaignState.updatedAt = new Date().toISOString(); persistCampaignState(campaignState); return cloneCampaignState(campaignState); } @@ -95,11 +145,32 @@ export function saveCampaignState(state = ensureCampaignState()) { export function resetCampaignState() { campaignState = createInitialCampaignState(); tryStorage()?.removeItem(campaignStorageKey); + for (let slot = 1; slot <= campaignSaveSlotCount; slot += 1) { + tryStorage()?.removeItem(campaignSaveSlotKey(slot)); + } return cloneCampaignState(campaignState); } export function hasCampaignSave() { - return Boolean(tryStorage()?.getItem(campaignStorageKey)); + return Boolean(tryStorage()?.getItem(campaignStorageKey) ?? readLatestSlottedCampaignState()); +} + +export function listCampaignSaveSlots(): CampaignSaveSlotSummary[] { + return Array.from({ length: campaignSaveSlotCount }, (_, index) => { + const slot = index + 1; + const state = readStoredCampaignState(slot); + if (!state) { + return { slot, exists: false }; + } + return { + slot, + exists: true, + updatedAt: state.updatedAt, + step: state.step, + gold: state.gold, + battleTitle: state.firstBattleReport?.battleTitle + }; + }); } export function markCampaignStep(step: CampaignStep) { @@ -112,16 +183,20 @@ export function markCampaignStep(step: CampaignStep) { export function setFirstBattleReport(report: FirstBattleReport) { const state = ensureCampaignState(); - const previousRewardGold = state.firstBattleReport?.rewardGold ?? 0; const reportClone = cloneReport(report); + const battleId = reportClone.battleId; + const previousSettlement = state.battleHistory[battleId]; state.firstBattleReport = reportClone; state.step = reportClone.outcome === 'victory' ? 'first-camp' : 'first-battle'; - state.gold = Math.max(0, state.gold - previousRewardGold + reportClone.rewardGold); + state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + 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.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1); + state.inventory = applyRewardDelta(state.inventory, reportClone.itemRewards, 1); + state.battleHistory[battleId] = createBattleSettlement(reportClone); + state.latestBattleId = battleId; state.updatedAt = new Date().toISOString(); persistCampaignState(state); @@ -171,19 +246,22 @@ function ensureCampaignState() { 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 }; + normalized.updatedAt = normalized.updatedAt || new Date().toISOString(); + normalized.activeSaveSlot = normalizeSlot(normalized.activeSaveSlot); + normalized.gold = normalized.gold ?? 0; + normalized.roster = (normalized.roster ?? []).map(cloneUnit); + normalized.bonds = (normalized.bonds ?? []).map(cloneBondSnapshot); + normalized.completedCampDialogues = [...new Set(normalized.completedCampDialogues ?? [])]; + normalized.inventory = { ...(normalized.inventory ?? {}) }; + normalized.battleHistory = normalized.battleHistory ?? {}; if (normalized.firstBattleReport) { normalized.firstBattleReport = cloneReport(normalized.firstBattleReport); } return normalized; } -function readStoredCampaignState() { - const raw = tryStorage()?.getItem(campaignStorageKey); +function readStoredCampaignState(slot?: number) { + const raw = tryStorage()?.getItem(slot ? campaignSaveSlotKey(slot) : campaignStorageKey); if (!raw) { return undefined; } @@ -200,7 +278,9 @@ function readStoredCampaignState() { } function persistCampaignState(state: CampaignState) { - tryStorage()?.setItem(campaignStorageKey, JSON.stringify(state)); + const storage = tryStorage(); + storage?.setItem(campaignStorageKey, JSON.stringify(state)); + storage?.setItem(campaignSaveSlotKey(state.activeSaveSlot), JSON.stringify(state)); } function tryStorage() { @@ -210,6 +290,24 @@ function tryStorage() { return window.localStorage; } +function campaignSaveSlotKey(slot: number) { + return `${campaignStorageKey}:slot-${normalizeSlot(slot)}`; +} + +function normalizeSlot(slot: number | undefined) { + if (!slot || Number.isNaN(slot)) { + return 1; + } + return Math.min(campaignSaveSlotCount, Math.max(1, Math.floor(slot))); +} + +function readLatestSlottedCampaignState() { + const latest = listCampaignSaveSlots() + .filter((slot) => slot.exists && slot.updatedAt) + .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))[0]; + return latest ? readStoredCampaignState(latest.slot) : undefined; +} + function advanceBond(bond: CampBondSnapshot, amount: number) { bond.battleExp += amount; bond.exp += amount; @@ -219,12 +317,45 @@ function advanceBond(bond: CampBondSnapshot, amount: number) { } } -function inventoryFromRewards(rewards: string[]) { - return rewards.reduce>((inventory, reward) => { +function createBattleSettlement(report: FirstBattleReport): CampaignBattleSettlement { + return { + battleId: report.battleId, + battleTitle: report.battleTitle, + outcome: report.outcome, + rewardGold: report.rewardGold, + itemRewards: [...report.itemRewards], + objectives: report.objectives.map((objective) => ({ ...objective })), + units: report.units + .filter((unit) => unit.faction === 'ally') + .map((unit) => ({ + unitId: unit.id, + name: unit.name, + level: unit.level, + exp: unit.exp, + hp: unit.hp, + maxHp: unit.maxHp, + equipment: cloneUnit(unit).equipment + })), + bonds: report.bonds.map((bond) => ({ + id: bond.id, + title: bond.title, + level: bond.level, + exp: bond.exp, + battleExp: bond.battleExp + })), + completedAt: report.createdAt + }; +} + +function applyRewardDelta(inventory: Record, rewards: string[], direction: 1 | -1) { + return rewards.reduce>((nextInventory, reward) => { const { label, amount } = parseRewardLabel(reward); - inventory[label] = (inventory[label] ?? 0) + amount; - return inventory; - }, {}); + nextInventory[label] = Math.max(0, (nextInventory[label] ?? 0) + amount * direction); + if (nextInventory[label] === 0) { + delete nextInventory[label]; + } + return nextInventory; + }, { ...inventory }); } function parseRewardLabel(reward: string) {