Prepare battle scenario progression foundations

This commit is contained in:
2026-06-22 17:39:09 +09:00
parent 635b3a7a81
commit 49822831f5
5 changed files with 418 additions and 147 deletions

View File

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

View File

@@ -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<BattleScenarioId, BattleScenarioDefinition> = {
[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;
}

View File

@@ -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<string, BattleUsable> = {
aid: {
@@ -418,9 +418,9 @@ const defaultEnemyAiByClass: Partial<Record<UnitClassKey, EnemyAiBehavior>> = {
};
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<BattleCommand, string> = {
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);

View File

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

View File

@@ -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<string, number>;
completedCampDialogues: string[];
battleHistory: Record<string, CampaignBattleSettlement>;
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<Record<string, number>>((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<string, number>, rewards: string[], direction: 1 | -1) {
return rewards.reduce<Record<string, number>>((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) {