refactor: simplify buildCombatState and fix formatting in encounter

system

Refactor `buildCombatState` to derive encounter data directly from
`runState` instead of requiring it as an argument. Also apply
consistent 2-space indentation and formatting to the encounter
lifecycle module.
This commit is contained in:
hypercross 2026-04-19 15:42:47 +08:00
parent 89d96d838b
commit 8142fbfa60
1 changed files with 151 additions and 139 deletions

View File

@ -1,9 +1,19 @@
import type { PointCrawlMap } from '../map/types'; import type { PointCrawlMap } from "../map/types";
import type { CombatState, EnemyEntity, PlayerEntity, EffectTable } from '../combat/types'; import type {
import type { EncounterData, EnemyData, EffectData, IntentData } from '../types'; CombatState,
import type { RunState } from './types'; EnemyEntity,
import { generateDeckFromInventory } from '../deck/factory'; PlayerEntity,
import { ReadonlyRNG } from '@/utils/rng'; EffectTable,
} from "../combat/types";
import type {
EncounterData,
EnemyData,
EffectData,
IntentData,
} from "../types";
import type { RunState } from "./types";
import { generateDeckFromInventory } from "../deck/factory";
import { ReadonlyRNG } from "@/utils/rng";
// -- Encounter assignment to nodes -- // -- Encounter assignment to nodes --
@ -12,15 +22,15 @@ import { ReadonlyRNG } from '@/utils/rng';
* Replaces any existing encounter on that node. * Replaces any existing encounter on that node.
*/ */
export function assignEncounterToNode( export function assignEncounterToNode(
map: PointCrawlMap, map: PointCrawlMap,
nodeId: string, nodeId: string,
encounter: EncounterData encounter: EncounterData,
): void { ): void {
const node = map.nodes.get(nodeId); const node = map.nodes.get(nodeId);
if (!node) { if (!node) {
throw new Error(`Node "${nodeId}" not found`); throw new Error(`Node "${nodeId}" not found`);
} }
node.encounter = encounter; node.encounter = encounter;
} }
/** /**
@ -28,19 +38,19 @@ export function assignEncounterToNode(
* Uses RNG for random selection; each encounter can be assigned multiple times. * Uses RNG for random selection; each encounter can be assigned multiple times.
*/ */
export function assignEncountersFromPool( export function assignEncountersFromPool(
map: PointCrawlMap, map: PointCrawlMap,
encounterPool: EncounterData[], encounterPool: EncounterData[],
rng: ReadonlyRNG rng: ReadonlyRNG,
): void { ): void {
if (encounterPool.length === 0) return; if (encounterPool.length === 0) return;
for (const node of map.nodes.values()) { for (const node of map.nodes.values()) {
if (node.type === 'start' || node.type === 'end') continue; if (node.type === "start" || node.type === "end") continue;
if (node.encounter) continue; if (node.encounter) continue;
const assigned = encounterPool[rng.nextInt(encounterPool.length)]; const assigned = encounterPool[rng.nextInt(encounterPool.length)];
node.encounter = assigned; node.encounter = assigned;
} }
} }
/** /**
@ -48,20 +58,20 @@ export function assignEncountersFromPool(
* Keys in the index should match encounter type strings (e.g. 'minion', 'elite'). * Keys in the index should match encounter type strings (e.g. 'minion', 'elite').
*/ */
export function assignAllEncounters( export function assignAllEncounters(
map: PointCrawlMap, map: PointCrawlMap,
encounterIndex: Map<string, EncounterData[]>, encounterIndex: Map<string, EncounterData[]>,
rng: ReadonlyRNG rng: ReadonlyRNG,
): void { ): void {
for (const node of map.nodes.values()) { for (const node of map.nodes.values()) {
if (node.type === 'start' || node.type === 'end') continue; if (node.type === "start" || node.type === "end") continue;
if (node.encounter) continue; if (node.encounter) continue;
const encounterType = node.type; const encounterType = node.type;
const pool = encounterIndex.get(encounterType); const pool = encounterIndex.get(encounterType);
if (!pool || pool.length === 0) continue; if (!pool || pool.length === 0) continue;
node.encounter = pool[rng.nextInt(pool.length)]; node.encounter = pool[rng.nextInt(pool.length)];
} }
} }
// -- CombatState construction -- // -- CombatState construction --
@ -72,111 +82,111 @@ export function assignAllEncounters(
* - Creates PlayerEntity with energy (3), deck from inventory, and HP from run state * - Creates PlayerEntity with energy (3), deck from inventory, and HP from run state
* - Sets initial phase to 'playerTurn', turn 1 * - Sets initial phase to 'playerTurn', turn 1
*/ */
export function buildCombatState( export function buildCombatState(runState: RunState): CombatState {
encounter: EncounterData, const encounter = getCurrentEncounterData(runState);
runState: RunState, if (!encounter)
): CombatState { throw new Error(`No encounter found for node ${runState.currentNodeId}`);
const enemies = createEnemyEntities(encounter);
const deck = generateDeckFromInventory(runState.inventory);
const player = createPlayerEntity(runState, deck);
return { const enemies = createEnemyEntities(encounter);
enemies, const deck = generateDeckFromInventory(runState.inventory);
player, const player = createPlayerEntity(runState, deck);
inventory: runState.inventory,
phase: 'playerTurn', return {
turnNumber: 1, enemies,
result: null, player,
loot: [], inventory: runState.inventory,
}; phase: "playerTurn",
turnNumber: 1,
result: null,
loot: [],
};
} }
/** /**
* Creates EnemyEntity instances from encounter enemy definitions. * Creates EnemyEntity instances from encounter enemy definitions.
* Each enemy gets: HP from encounter tuple, initial buffs from encounter, intents from enemy definition. * Each enemy gets: HP from encounter tuple, initial buffs from encounter, intents from enemy definition.
*/ */
export function createEnemyEntities( export function createEnemyEntities(encounter: EncounterData): EnemyEntity[] {
encounter: EncounterData, const enemies: EnemyEntity[] = [];
): EnemyEntity[] { let instanceCounter = 0;
const enemies: EnemyEntity[] = [];
let instanceCounter = 0;
for (const [enemyData, hp, encounterBuffs] of encounter.enemies) { for (const [enemyData, hp, encounterBuffs] of encounter.enemies) {
const instanceId = `${enemyData.id}-${instanceCounter++}`; const instanceId = `${enemyData.id}-${instanceCounter++}`;
const intents = buildIntentMap(enemyData); const intents = buildIntentMap(enemyData);
const initialIntent = findInitialIntent(enemyData); const initialIntent = findInitialIntent(enemyData);
const effects = buildEffectTable(encounterBuffs); const effects = buildEffectTable(encounterBuffs);
const entity: EnemyEntity = { const entity: EnemyEntity = {
id: instanceId, id: instanceId,
enemy: enemyData, enemy: enemyData,
hp, hp,
maxHp: hp, maxHp: hp,
isAlive: true, isAlive: true,
effects, effects,
intents, intents,
currentIntent: initialIntent, currentIntent: initialIntent,
}; };
enemies.push(entity); enemies.push(entity);
} }
return enemies; return enemies;
} }
/** /**
* Builds a map of intent ID -> IntentData for an enemy. * Builds a map of intent ID -> IntentData for an enemy.
*/ */
function buildIntentMap( function buildIntentMap(enemy: EnemyData): Record<string, IntentData> {
enemy: EnemyData, const intents: Record<string, IntentData> = {};
): Record<string, IntentData> { for (const intent of enemy.intents) {
const intents: Record<string, IntentData> = {}; intents[intent.id] = intent;
for (const intent of enemy.intents) { }
intents[intent.id] = intent; return intents;
}
return intents;
} }
/** /**
* Finds the initial intent ID for an enemy. * Finds the initial intent ID for an enemy.
*/ */
function findInitialIntent(enemy: EnemyData): IntentData { function findInitialIntent(enemy: EnemyData): IntentData {
for (const intent of enemy.intents) { for (const intent of enemy.intents) {
if (intent.initialIntent) { if (intent.initialIntent) {
return intent; return intent;
}
} }
if (enemy.intents.length === 0) { }
throw new Error(`Enemy "${enemy.id}" has no intents`); if (enemy.intents.length === 0) {
} throw new Error(`Enemy "${enemy.id}" has no intents`);
return enemy.intents[0]; }
return enemy.intents[0];
} }
/** /**
* Builds an EffectTable from encounter buff definitions. * Builds an EffectTable from encounter buff definitions.
*/ */
function buildEffectTable(buffs: readonly [EffectData, number][]): EffectTable { function buildEffectTable(buffs: readonly [EffectData, number][]): EffectTable {
const table: EffectTable = {}; const table: EffectTable = {};
for (const [effect, stacks] of buffs) { for (const [effect, stacks] of buffs) {
table[effect.id] = { data: effect, stacks }; table[effect.id] = { data: effect, stacks };
} }
return table; return table;
} }
/** /**
* Creates a PlayerEntity from the run state and deck. * Creates a PlayerEntity from the run state and deck.
*/ */
function createPlayerEntity(runState: RunState, deck: ReturnType<typeof generateDeckFromInventory>): PlayerEntity { function createPlayerEntity(
return { runState: RunState,
id: "player", deck: ReturnType<typeof generateDeckFromInventory>,
hp: runState.player.currentHp, ): PlayerEntity {
maxHp: runState.player.maxHp, return {
isAlive: runState.player.currentHp > 0, id: "player",
energy: 3, hp: runState.player.currentHp,
maxEnergy: 3, maxHp: runState.player.maxHp,
deck, isAlive: runState.player.currentHp > 0,
itemEffects: {}, energy: 3,
effects: {}, maxEnergy: 3,
}; deck,
itemEffects: {},
effects: {},
};
} }
// -- Encounter lifecycle -- // -- Encounter lifecycle --
@ -184,17 +194,19 @@ function createPlayerEntity(runState: RunState, deck: ReturnType<typeof generate
/** /**
* Gets the encounter data for the current node. * Gets the encounter data for the current node.
*/ */
export function getCurrentEncounterData(runState: RunState): EncounterData | undefined { export function getCurrentEncounterData(
const node = runState.map.nodes.get(runState.currentNodeId); runState: RunState,
return node?.encounter; ): EncounterData | undefined {
const node = runState.map.nodes.get(runState.currentNodeId);
return node?.encounter;
} }
/** /**
* Checks if the current node has a combat encounter. * Checks if the current node has a combat encounter.
*/ */
export function isCombatEncounter(runState: RunState): boolean { export function isCombatEncounter(runState: RunState): boolean {
const encounter = getCurrentEncounterData(runState); const encounter = getCurrentEncounterData(runState);
return encounter !== undefined && encounter.enemies.length > 0; return encounter !== undefined && encounter.enemies.length > 0;
} }
/** /**
@ -202,12 +214,12 @@ export function isCombatEncounter(runState: RunState): boolean {
* Returns the constructed CombatState, or null if no combat encounter. * Returns the constructed CombatState, or null if no combat encounter.
*/ */
export function startEncounter(runState: RunState): CombatState | null { export function startEncounter(runState: RunState): CombatState | null {
const encounter = getCurrentEncounterData(runState); const encounter = getCurrentEncounterData(runState);
if (!encounter || encounter.enemies.length === 0) { if (!encounter || encounter.enemies.length === 0) {
return null; return null;
} }
return buildCombatState(encounter, runState); return buildCombatState(runState);
} }
/** /**
@ -216,30 +228,30 @@ export function startEncounter(runState: RunState): CombatState | null {
* Marks the encounter as resolved. * Marks the encounter as resolved.
*/ */
export function resolveCombatEncounter( export function resolveCombatEncounter(
runState: RunState, runState: RunState,
combatState: CombatState combatState: CombatState,
): { success: true } | { success: false; reason: string } { ): { success: true } | { success: false; reason: string } {
if (runState.currentEncounter.resolved) { if (runState.currentEncounter.resolved) {
return { success: false, reason: '该遭遇已解决' }; return { success: false, reason: "该遭遇已解决" };
}
// Apply HP from combat state back to run state
runState.player.currentHp = Math.max(0, combatState.player.hp);
// Apply loot
for (const loot of combatState.loot) {
if (loot.type === "gold") {
runState.player.gold += loot.amount;
} }
// Item rewards are handled by the caller via addItem()
}
// Apply HP from combat state back to run state // Mark as resolved
runState.player.currentHp = Math.max(0, combatState.player.hp); runState.currentEncounter.resolved = true;
runState.currentEncounter.result = {
hpLost: runState.player.maxHp - runState.player.currentHp,
};
runState.resolvedNodeIds.add(runState.currentNodeId);
// Apply loot return { success: true };
for (const loot of combatState.loot) {
if (loot.type === 'gold') {
runState.player.gold += loot.amount;
}
// Item rewards are handled by the caller via addItem()
}
// Mark as resolved
runState.currentEncounter.resolved = true;
runState.currentEncounter.result = {
hpLost: runState.player.maxHp - runState.player.currentHp,
};
runState.resolvedNodeIds.add(runState.currentNodeId);
return { success: true };
} }