From aedf82d2643bf3878a17cec268b4ba68dc228030 Mon Sep 17 00:00:00 2001 From: hypercross Date: Fri, 17 Apr 2026 15:14:01 +0800 Subject: [PATCH] fix: type issues --- .../data/desert/card.csv.d.ts | 2 +- .../data/desert/encounter.csv | 2 +- .../data/desert/encounter.csv.d.ts | 2 +- .../slay-the-spire-like/data/desert/enemy.csv | 2 +- .../data/desert/enemy.csv.d.ts | 2 +- .../data/desert/intent.csv.d.ts | 6 +- .../system/progress/encounter.ts | 268 ++++++++++++++++++ .../system/progress/index.ts | 13 + .../slay-the-spire-like/system/types.ts | 10 +- 9 files changed, 294 insertions(+), 13 deletions(-) create mode 100644 src/samples/slay-the-spire-like/system/progress/encounter.ts diff --git a/src/samples/slay-the-spire-like/data/desert/card.csv.d.ts b/src/samples/slay-the-spire-like/data/desert/card.csv.d.ts index 2a014e4..2d29738 100644 --- a/src/samples/slay-the-spire-like/data/desert/card.csv.d.ts +++ b/src/samples/slay-the-spire-like/data/desert/card.csv.d.ts @@ -8,7 +8,7 @@ type CardTable = readonly { readonly costType: "energy" | "uses" | "none"; readonly costCount: number; readonly targetType: "single" | "none"; - readonly effects: readonly ["onPlay" | "onDraw" | "onDiscard", "self" | "target" | "all" | "random", Effect, number]; + readonly effects: ["onPlay" | "onDraw" | "onDiscard", "self" | "target" | "all" | "random", Effect, number][]; }[]; export type Card = CardTable[number]; diff --git a/src/samples/slay-the-spire-like/data/desert/encounter.csv b/src/samples/slay-the-spire-like/data/desert/encounter.csv index 5d3b9fd..e4ec9a0 100644 --- a/src/samples/slay-the-spire-like/data/desert/encounter.csv +++ b/src/samples/slay-the-spire-like/data/desert/encounter.csv @@ -7,7 +7,7 @@ # enemies: array of [enemyId; initialHp; buffs[]] id,type,name,description,enemies,dialogue -string,'minion'|'elite'|'event'|'shop'|'camp'|'curio',string,string,[data: @enemy; hp: int; [effect: @effect;stacks: int]][],string +string,'minion'|'elite'|'event'|'shop'|'camp'|'curio',string,string,[data: @enemy; hp: int; effects: [effect: @effect;stacks: int][]][],string cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[仙人掌怪;12;[]];[仙人掌怪;12;[]], snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[蛇;10;[]], mummy_cactus,minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。,[木乃伊;14;[]];[仙人掌怪;12;[]], diff --git a/src/samples/slay-the-spire-like/data/desert/encounter.csv.d.ts b/src/samples/slay-the-spire-like/data/desert/encounter.csv.d.ts index c4b5a93..f35b098 100644 --- a/src/samples/slay-the-spire-like/data/desert/encounter.csv.d.ts +++ b/src/samples/slay-the-spire-like/data/desert/encounter.csv.d.ts @@ -6,7 +6,7 @@ type EncounterTable = readonly { readonly type: "minion" | "elite" | "event" | "shop" | "camp" | "curio"; readonly name: string; readonly description: string; - readonly enemies: readonly [readonly data: Enemy, readonly hp: number, readonly [readonly effect: Effect, readonly stacks: number]]; + readonly enemies: [data: Enemy, hp: number, effects: [effect: Effect, stacks: number][]][]; readonly dialogue: string; }[]; diff --git a/src/samples/slay-the-spire-like/data/desert/enemy.csv b/src/samples/slay-the-spire-like/data/desert/enemy.csv index f4b7b0a..86566d4 100644 --- a/src/samples/slay-the-spire-like/data/desert/enemy.csv +++ b/src/samples/slay-the-spire-like/data/desert/enemy.csv @@ -1,4 +1,4 @@ -id,name,intentIds,description +id,name,intents,description string,string,@intent[],string 仙人掌怪,仙人掌怪,[仙人掌怪-boost;仙人掌怪-defend;仙人掌怪-attack],防+强化。【尖刺X】:对攻击者造成X点伤害。 蛇,蛇,[蛇-poison;蛇-attack;蛇-boost],攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。 diff --git a/src/samples/slay-the-spire-like/data/desert/enemy.csv.d.ts b/src/samples/slay-the-spire-like/data/desert/enemy.csv.d.ts index c5bd698..f63035e 100644 --- a/src/samples/slay-the-spire-like/data/desert/enemy.csv.d.ts +++ b/src/samples/slay-the-spire-like/data/desert/enemy.csv.d.ts @@ -3,7 +3,7 @@ import type { Intent } from './intent.csv'; type EnemyTable = readonly { readonly id: string; readonly name: string; - readonly intentIds: readonly Intent[]; + readonly intents: Intent[]; readonly description: string; }[]; diff --git a/src/samples/slay-the-spire-like/data/desert/intent.csv.d.ts b/src/samples/slay-the-spire-like/data/desert/intent.csv.d.ts index 1316bc2..44a4c97 100644 --- a/src/samples/slay-the-spire-like/data/desert/intent.csv.d.ts +++ b/src/samples/slay-the-spire-like/data/desert/intent.csv.d.ts @@ -5,9 +5,9 @@ type IntentTable = readonly { readonly id: string; readonly enemy: Enemy; readonly initialIntent: boolean; - readonly nextIntents: readonly Intent[]; - readonly brokenIntent: readonly Intent[]; - readonly effects: readonly ["self" | "player" | "team", Effect, number]; + readonly nextIntents: Intent[]; + readonly brokenIntent: Intent[]; + readonly effects: ["self" | "player" | "team", Effect, number][]; }[]; export type Intent = IntentTable[number]; diff --git a/src/samples/slay-the-spire-like/system/progress/encounter.ts b/src/samples/slay-the-spire-like/system/progress/encounter.ts new file mode 100644 index 0000000..57ea484 --- /dev/null +++ b/src/samples/slay-the-spire-like/system/progress/encounter.ts @@ -0,0 +1,268 @@ +import type { PointCrawlMap, MapNode } from '../map/types'; +import type { CombatState, EnemyEntity, PlayerEntity, EffectTable } from '../combat/types'; +import type { EncounterData, EnemyData, EffectData, IntentData } from '../types'; +import type { RunState, GameItemMeta } from './types'; +import type { GridInventory } from '../grid-inventory/types'; +import { generateDeckFromInventory } from '../deck/factory'; +import { ReadonlyRNG } from '@/utils/rng'; +import { createRegion } from '@/core/region'; + +// -- Encounter assignment to nodes -- + +/** + * Assigns an encounter from a pool to a specific node. + * Replaces any existing encounter on that node. + */ +export function assignEncounterToNode( + map: PointCrawlMap, + nodeId: string, + encounter: EncounterData +): void { + const node = map.nodes.get(nodeId); + if (!node) { + throw new Error(`Node "${nodeId}" not found`); + } + node.encounter = encounter; +} + +/** + * Assigns encounters from a typed pool to all unassigned nodes of matching type. + * Uses RNG for random selection; each encounter can be assigned multiple times. + */ +export function assignEncountersFromPool( + map: PointCrawlMap, + encounterPool: EncounterData[], + rng: ReadonlyRNG +): void { + if (encounterPool.length === 0) return; + + for (const node of map.nodes.values()) { + if (node.type === 'start' || node.type === 'end') continue; + if (node.encounter) continue; + + const assigned = encounterPool[rng.nextInt(encounterPool.length)]; + node.encounter = assigned; + } +} + +/** + * Batch-assigns encounters for all node types from a typed index. + * Keys in the index should match encounter type strings (e.g. 'minion', 'elite'). + */ +export function assignAllEncounters( + map: PointCrawlMap, + encounterIndex: Map, + rng: ReadonlyRNG +): void { + for (const node of map.nodes.values()) { + if (node.type === 'start' || node.type === 'end') continue; + if (node.encounter) continue; + + const encounterType = node.type; + const pool = encounterIndex.get(encounterType); + if (!pool || pool.length === 0) continue; + + node.encounter = pool[rng.nextInt(pool.length)]; + } +} + +// -- CombatState construction -- + +/** + * Builds a full CombatState from an encounter and the current run state. + * - Creates EnemyEntity instances with HP, initial buffs, and intents + * - Creates PlayerEntity with energy (3), deck from inventory, and HP from run state + * - Sets initial phase to 'playerTurn', turn 1 + */ +export function buildCombatState( + encounter: EncounterData, + runState: RunState, + intentPool: IntentData[] = [] +): CombatState { + const intentIndex = buildIntentIndex(intentPool); + const enemies = createEnemyEntities(encounter, intentIndex); + const deck = generateDeckFromInventory(runState.inventory); + const player = createPlayerEntity(runState, deck); + + return { + enemies, + player, + inventory: runState.inventory, + phase: 'playerTurn', + turnNumber: 1, + result: null, + loot: [], + }; +} + +/** + * Builds an index of intents by their ID for fast lookup. + */ +function buildIntentIndex(intentPool: IntentData[]): Map { + const index = new Map(); + for (const intent of intentPool) { + index.set(intent.id, intent); + } + return index; +} + +/** + * Creates EnemyEntity instances from encounter enemy definitions. + * Each enemy gets: initial HP from enemy data, initial buffs from encounter, intents from enemy definition. + */ +export function createEnemyEntities( + encounter: EncounterData, + intentIndex: Map +): EnemyEntity[] { + const enemies: EnemyEntity[] = []; + let instanceCounter = 0; + + for (const [enemyData, count, encounterBuffs] of encounter.enemies) { + for (let i = 0; i < count; i++) { + const instanceId = `${enemyData.id}-${instanceCounter++}`; + const intents = buildIntentMap(enemyData, intentIndex); + const initialIntentId = findInitialIntent(enemyData, intentIndex); + const effects = buildEffectTable(encounterBuffs); + + const entity: EnemyEntity = { + id: instanceId, + enemy: enemyData, + hp: enemyData.hp, + maxHp: enemyData.hp, + isAlive: true, + effects, + intents, + currentIntentId: initialIntentId, + }; + enemies.push(entity); + } + } + + return enemies; +} + +/** + * Builds a map of intent ID -> IntentData for an enemy. + */ +function buildIntentMap( + enemy: EnemyData, + intentIndex: Map +): Record { + const intents: Record = {}; + for (const intentId of enemy.intentIds) { + const intent = intentIndex.get(intentId); + if (intent) { + intents[intentId] = intent; + } + } + return intents; +} + +/** + * Finds the initial intent ID for an enemy. + */ +function findInitialIntent( + enemy: EnemyData, + intentIndex: Map +): string { + for (const intentId of enemy.intentIds) { + const intent = intentIndex.get(intentId); + if (intent?.initialIntent) { + return intentId; + } + } + // Fallback: first intent + return enemy.intentIds[0] ?? ''; +} + +/** + * Builds an EffectTable from encounter buff definitions. + */ +function buildEffectTable(buffs: readonly [EffectData, number][]): EffectTable { + const table: EffectTable = {}; + for (const [effect, stacks] of buffs) { + table[effect.id] = { data: effect, stacks }; + } + return table; +} + +/** + * Creates a PlayerEntity from the run state and deck. + */ +function createPlayerEntity(runState: RunState, deck: ReturnType): PlayerEntity { + return { + hp: runState.player.currentHp, + maxHp: runState.player.maxHp, + isAlive: runState.player.currentHp > 0, + energy: 3, + maxEnergy: 3, + deck, + itemEffects: {}, + effects: {}, + }; +} + +// -- Encounter lifecycle -- + +/** + * Gets the encounter data for the current node. + */ +export function getCurrentEncounterData(runState: RunState): EncounterData | undefined { + const node = runState.map.nodes.get(runState.currentNodeId); + return node?.encounter; +} + +/** + * Checks if the current node has a combat encounter. + */ +export function isCombatEncounter(runState: RunState): boolean { + const encounter = getCurrentEncounterData(runState); + return encounter !== undefined && encounter.enemies.length > 0; +} + +/** + * Starts the encounter at the current node. + * Returns the constructed CombatState, or null if no combat encounter. + */ +export function startEncounter(runState: RunState, intentPool: IntentData[] = []): CombatState | null { + const encounter = getCurrentEncounterData(runState); + if (!encounter || encounter.enemies.length === 0) { + return null; + } + + return buildCombatState(encounter, runState, intentPool); +} + +/** + * Resolves a completed combat and applies rewards to the run state. + * Handles: gold loot, item rewards, HP changes. + * Marks the encounter as resolved. + */ +export function resolveCombatEncounter( + runState: RunState, + combatState: CombatState +): { success: true } | { success: false; reason: string } { + if (runState.currentEncounter.resolved) { + 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() + } + + // 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 }; +} diff --git a/src/samples/slay-the-spire-like/system/progress/index.ts b/src/samples/slay-the-spire-like/system/progress/index.ts index 4e3afd0..4d72106 100644 --- a/src/samples/slay-the-spire-like/system/progress/index.ts +++ b/src/samples/slay-the-spire-like/system/progress/index.ts @@ -18,6 +18,19 @@ export type { RunState, } from './types'; +// Re-export encounter construction functions +export { + assignEncounterToNode, + assignEncountersFromPool, + assignAllEncounters, + buildCombatState, + createEnemyEntities, + getCurrentEncounterData, + isCombatEncounter, + startEncounter, + resolveCombatEncounter, +} from './encounter'; + // -- Constants -- const INVENTORY_WIDTH = 6; diff --git a/src/samples/slay-the-spire-like/system/types.ts b/src/samples/slay-the-spire-like/system/types.ts index 9fa436b..506da9a 100644 --- a/src/samples/slay-the-spire-like/system/types.ts +++ b/src/samples/slay-the-spire-like/system/types.ts @@ -9,6 +9,7 @@ export type EffectLifecycle = "instant" | "temporary" | "lingering" | "permanent export type EnemyData = { readonly id: string; readonly name: string; + readonly intents: readonly IntentData[]; readonly description: string; }; @@ -36,17 +37,16 @@ export type EncounterData = { readonly type: EncounterType; readonly name: string; readonly description: string; - readonly enemies: readonly [EnemyData, number, readonly [effect: EffectData, stacks: number]]; + readonly enemies: readonly [data: EnemyData, hp: number, effects: [EffectData, stacks: number][]][]; readonly dialogue: string; }; export type IntentData = { + readonly id: string; readonly enemy: EnemyData; - readonly intentId: string; readonly initialIntent: boolean; - readonly nextIntents: readonly string[]; - readonly brokenIntent: readonly string[]; - readonly initBuffs: readonly [EffectData, stacks: number]; + readonly nextIntents: readonly IntentData[]; + readonly brokenIntent: readonly IntentData[]; readonly effects: readonly [EffectTarget, EffectData, number][]; };