From 88eeee6ab751aef40dcf4e869332a299891a71ca Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 13 Apr 2026 11:55:57 +0800 Subject: [PATCH] feat: add encounter map --- .../data/encounterDesert.csv | 26 ++ .../data/encounterDesert.csv.d.ts | 10 + src/samples/slay-the-spire-like/data/index.ts | 3 + .../slay-the-spire-like/map/generator.ts | 263 ++++++++++++++++++ src/samples/slay-the-spire-like/map/index.ts | 5 + src/samples/slay-the-spire-like/map/types.ts | 53 ++++ .../slay-the-spire-like/map/generator.test.ts | 89 ++++++ 7 files changed, 449 insertions(+) create mode 100644 src/samples/slay-the-spire-like/data/encounterDesert.csv create mode 100644 src/samples/slay-the-spire-like/data/encounterDesert.csv.d.ts create mode 100644 src/samples/slay-the-spire-like/map/generator.ts create mode 100644 src/samples/slay-the-spire-like/map/index.ts create mode 100644 src/samples/slay-the-spire-like/map/types.ts create mode 100644 tests/samples/slay-the-spire-like/map/generator.test.ts diff --git a/src/samples/slay-the-spire-like/data/encounterDesert.csv b/src/samples/slay-the-spire-like/data/encounterDesert.csv new file mode 100644 index 0000000..82a397f --- /dev/null +++ b/src/samples/slay-the-spire-like/data/encounterDesert.csv @@ -0,0 +1,26 @@ +# npc encounter (2): offer random trades, could be merchants or healer or something +# shelter (2): offer consumable restock and heal +# enemy (10): minor enemies +# elite (4): dangerous enemies +# boss (1): boss enemy +type,name,description +'npc'|'enemy'|'elite'|'boss'|'event'|'shelter',string,string +enemy,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。 +enemy,蛇,概念:攻+强化。给玩家塞入蛇毒牌(消耗。一回合弃掉超过1张蛇毒时,受到6伤害)。 +enemy,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。 +enemy,枪手,概念:单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】。 +enemy,风卷草,概念:防+强化。【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。 +enemy,秃鹫,概念:攻+防。造成伤害后玩家获得秃鹫之眼(当你受到伤害时自动从手牌打出受到秃鹫的攻击)。 +enemy,沙蝎,概念:攻+强化。【尾刺X】:玩家回合结束时受到沙蝎的X点攻击。受伤时失去等量【尾刺】。 +enemy,幼沙虫,概念:防+强化。每回合第一次受伤时,玩家失去1点能量。 +enemy,蜥蜴,概念:攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。 +enemy,沙匪,概念:攻特化。洗牌时,将一个随机物品的牌全部弃掉。 +elite,风暴之灵,【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。(静电:在手里时受【电击】伤害+1) +elite,骑马枪手,【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。 +elite,沙虫王,召唤幼体沙虫;每当玩家弃掉一张牌,恢复1生命。 +elite,沙漠守卫,召唤木乃伊;会复活木乃伊2次。 +boss,法老之灵,沙漠区域最终Boss。 +npc,沙漠商人,商店:可以恢复生命、出售装备、附魔物品。 +npc,绿洲篝火,篝火:可以恢复生命、补充药水使用次数、获得下次战斗Buff。 +npc,迷失的旅人,提供任务:完成特定地点遭遇以获得独特奖励。 +event,海市蜃楼,随机遭遇:可能获得宝藏或遭遇陷阱,使用d6双阶段结构结算。 \ No newline at end of file diff --git a/src/samples/slay-the-spire-like/data/encounterDesert.csv.d.ts b/src/samples/slay-the-spire-like/data/encounterDesert.csv.d.ts new file mode 100644 index 0000000..e9f594f --- /dev/null +++ b/src/samples/slay-the-spire-like/data/encounterDesert.csv.d.ts @@ -0,0 +1,10 @@ +type EncounterDesertTable = readonly { + readonly type: "npc" | "enemy" | "elite" | "boss" | "event" | "shelter"; + readonly name: string; + readonly description: string; +}[]; + +export type EncounterDesert = EncounterDesertTable[number]; + +declare const data: EncounterDesertTable; +export default data; diff --git a/src/samples/slay-the-spire-like/data/index.ts b/src/samples/slay-the-spire-like/data/index.ts index 55c011c..f45ffea 100644 --- a/src/samples/slay-the-spire-like/data/index.ts +++ b/src/samples/slay-the-spire-like/data/index.ts @@ -1,3 +1,6 @@ import heroItemFighter1Csv from './heroItemFighter1.csv'; +import encounterDesertCsv from './encounterDesert.csv'; export const heroItemFighter1Data = heroItemFighter1Csv; +export const encounterDesertData = encounterDesertCsv; +export { default as encounterDesertCsv, type EncounterDesert } from './encounterDesert.csv'; diff --git a/src/samples/slay-the-spire-like/map/generator.ts b/src/samples/slay-the-spire-like/map/generator.ts new file mode 100644 index 0000000..ab75f53 --- /dev/null +++ b/src/samples/slay-the-spire-like/map/generator.ts @@ -0,0 +1,263 @@ +import { Mulberry32RNG, type RNG } from '@/utils/rng'; +import encounterDesertCsv, { type EncounterDesert } from '../data/encounterDesert.csv'; +import { MapNodeType } from './types'; +import type { MapLayer, MapNode, PointCrawlMap } from './types'; + +/** Cache for parsed encounters by type */ +const encountersByType = new Map(); + +function indexEncounters(): void { + if (encountersByType.size > 0) return; + + for (const encounter of encounterDesertCsv) { + const type = encounter.type; + if (!encountersByType.has(type)) { + encountersByType.set(type, []); + } + encountersByType.get(type)!.push(encounter); + } +} + +/** Map from MapNodeType to encounter type key */ +const NODE_TYPE_TO_ENCOUNTER: Partial> = { + [MapNodeType.Combat]: 'enemy', + [MapNodeType.Elite]: 'elite', + [MapNodeType.Boss]: 'boss', + [MapNodeType.Event]: 'event', + [MapNodeType.NPC]: 'npc', + [MapNodeType.Shelter]: 'shelter', +}; + +/** + * Picks a random encounter for the given node type. + * Returns undefined if no matching encounter exists. + */ +function pickEncounterForNode(type: MapNodeType, rng: RNG): EncounterDesert | undefined { + indexEncounters(); + const encounterType = NODE_TYPE_TO_ENCOUNTER[type]; + if (!encounterType) return undefined; + + const pool = encountersByType.get(encounterType); + if (!pool || pool.length === 0) return undefined; + + return pool[rng.nextInt(pool.length)]; +} + +/** Total number of layers (start + 11 intermediate + end) */ +const TOTAL_LAYERS = 13; + +/** Node type for each layer. Undefined layers use combat/elite mix. */ +const LAYER_TYPE: Partial> = { + 0: MapNodeType.Start, + 3: MapNodeType.Event, + 6: MapNodeType.Shelter, + 9: MapNodeType.NPC, + 12: MapNodeType.Boss, +}; + +/** + * How many nodes each layer should have. + * Diamond-ish shape: 1→2→3→4→5→5→5→5→4→3→2→1 + */ +const LAYER_WIDTHS = [1, 2, 3, 4, 5, 5, 5, 5, 5, 4, 3, 2, 1]; + +/** + * Generates a random point crawl map with layered directional graph. + * + * Invariants: + * - 13 layers (index 0 = start, index 12 = boss end) + * - Layer 3 = all events, layer 6 = shelters, layer 9 = NPCs + * - Every node has 1–2 outgoing edges to the next layer + * - Every node is reachable from start and can reach the end + * + * @param seed Random seed for reproducibility + */ +export function generatePointCrawlMap(seed?: number): PointCrawlMap { + const rng = new Mulberry32RNG(seed ?? Date.now()); + const actualSeed = rng.getSeed(); + + const layers: MapLayer[] = []; + const nodes = new Map(); + + // Step 1: create layers and nodes + for (let i = 0; i < TOTAL_LAYERS; i++) { + const count = LAYER_WIDTHS[i]; + const layerType = LAYER_TYPE[i]; + const nodeIds: string[] = []; + + for (let j = 0; j < count; j++) { + const id = `node-${i}-${j}`; + const type = layerType ?? pickLayerNodeType(i, rng); + const encounter = pickEncounterForNode(type, rng); + const node: MapNode = { + id, + layerIndex: i, + type, + childIds: [], + ...(encounter ? { encounter: { name: encounter.name, description: encounter.description } } : {}), + }; + nodes.set(id, node); + nodeIds.push(id); + } + + layers.push({ index: i, nodeIds }); + } + + // Step 2: generate edges between each pair of consecutive layers + for (let i = 0; i < TOTAL_LAYERS - 1; i++) { + const sourceIds = layers[i].nodeIds; + const targetIds = layers[i + 1].nodeIds; + generateLayerEdges(sourceIds, targetIds, nodes, rng); + } + + return { layers, nodes, seed: actualSeed }; +} + +/** + * Picks a node type for a general (non-fixed) layer. + * Elite nodes appear ~25% of the time, combat for the rest. + */ +function pickLayerNodeType(_layerIndex: number, rng: RNG): MapNodeType { + return rng.nextInt(4) === 0 ? MapNodeType.Elite : MapNodeType.Combat; +} + +/** + * Generates edges between two consecutive layers. + * + * Constraints: + * - Each source node gets 1–2 edges to target nodes + * - Every target node has at least one incoming edge (no dead ends) + */ +function generateLayerEdges( + sourceIds: string[], + targetIds: string[], + nodes: Map, + rng: RNG +): void { + const sourceBranches = new Map(); // id → current outgoing count + const targetIncoming = new Map(); // id → current incoming count + for (const id of sourceIds) sourceBranches.set(id, 0); + for (const id of targetIds) targetIncoming.set(id, 0); + + // --- Pass 1: give each source 1–2 targets, prioritising uncovered targets --- + const uncovered = new Set(targetIds); + + for (const srcId of sourceIds) { + const branches = rng.nextInt(2) + 1; // 1 or 2 + + for (let b = 0; b < branches; b++) { + if (uncovered.size > 0) { + // Pick a random uncovered target + const arr = Array.from(uncovered); + const idx = rng.nextInt(arr.length); + const tgtId = arr[idx]; + nodes.get(srcId)!.childIds.push(tgtId); + sourceBranches.set(srcId, sourceBranches.get(srcId)! + 1); + targetIncoming.set(tgtId, targetIncoming.get(tgtId)! + 1); + uncovered.delete(tgtId); + } else if (sourceBranches.get(srcId)! < 2) { + // All targets covered; pick any random target + const tgtId = targetIds[rng.nextInt(targetIds.length)]; + nodes.get(srcId)!.childIds.push(tgtId); + sourceBranches.set(srcId, sourceBranches.get(srcId)! + 1); + targetIncoming.set(tgtId, targetIncoming.get(tgtId)! + 1); + } + } + } + + // --- Pass 2: cover any remaining uncovered targets --- + for (const tgtId of uncovered) { + // Find a source that still has room (< 2 branches) + const available = sourceIds.filter(id => sourceBranches.get(id)! < 2); + if (available.length > 0) { + const srcId = available[rng.nextInt(available.length)]; + nodes.get(srcId)!.childIds.push(tgtId); + sourceBranches.set(srcId, sourceBranches.get(srcId)! + 1); + targetIncoming.set(tgtId, targetIncoming.get(tgtId)! + 1); + } else { + // All sources are at 2 branches; force-add to a random source + const srcId = sourceIds[rng.nextInt(sourceIds.length)]; + nodes.get(srcId)!.childIds.push(tgtId); + targetIncoming.set(tgtId, targetIncoming.get(tgtId)! + 1); + } + } +} + +// -- Query helpers -- + +/** Returns the node with the given ID, or undefined. */ +export function getNode(map: PointCrawlMap, nodeId: string): MapNode | undefined { + return map.nodes.get(nodeId); +} + +/** Returns child nodes of the given node. */ +export function getChildren(map: PointCrawlMap, node: MapNode): MapNode[] { + return node.childIds + .map(id => map.nodes.get(id)) + .filter((n): n is MapNode => n !== undefined); +} + +/** Returns parent nodes of the given node (reverse lookup). */ +export function getParents(map: PointCrawlMap, node: MapNode): MapNode[] { + const parents: MapNode[] = []; + const parentLayer = map.layers[node.layerIndex - 1]; + if (!parentLayer) return parents; + + for (const parentId of parentLayer.nodeIds) { + const parent = map.nodes.get(parentId); + if (parent?.childIds.includes(node.id)) { + parents.push(parent); + } + } + return parents; +} + +/** + * Returns true if there is a directed path from `fromId` to `toId`. + */ +export function hasPath(map: PointCrawlMap, fromId: string, toId: string): boolean { + const visited = new Set(); + const stack = [fromId]; + + while (stack.length > 0) { + const current = stack.pop()!; + if (current === toId) return true; + if (visited.has(current)) continue; + visited.add(current); + + const node = map.nodes.get(current); + if (node) { + for (const childId of node.childIds) { + if (!visited.has(childId)) stack.push(childId); + } + } + } + + return false; +} + +/** + * Finds all directed paths from `fromId` to `toId`. + * Returns arrays of node IDs representing each path. + * Beware: can be exponential in large maps. + */ +export function findAllPaths(map: PointCrawlMap, fromId: string, toId: string): string[][] { + const paths: string[][] = []; + const dfs = (current: string, path: string[]) => { + if (current === toId) { + paths.push([...path, current]); + return; + } + const node = map.nodes.get(current); + if (!node) return; + + path.push(current); + for (const childId of node.childIds) { + dfs(childId, path); + } + path.pop(); + }; + + dfs(fromId, []); + return paths; +} diff --git a/src/samples/slay-the-spire-like/map/index.ts b/src/samples/slay-the-spire-like/map/index.ts new file mode 100644 index 0000000..a6666ba --- /dev/null +++ b/src/samples/slay-the-spire-like/map/index.ts @@ -0,0 +1,5 @@ +export { MapNodeType } from './types'; +export type { MapNode, MapLayer, PointCrawlMap } from './types'; + +export { generatePointCrawlMap } from './generator'; +export { getNode, getChildren, getParents, hasPath, findAllPaths } from './generator'; diff --git a/src/samples/slay-the-spire-like/map/types.ts b/src/samples/slay-the-spire-like/map/types.ts new file mode 100644 index 0000000..3ab6baa --- /dev/null +++ b/src/samples/slay-the-spire-like/map/types.ts @@ -0,0 +1,53 @@ +/** + * Types of nodes that can appear on the point crawl map. + */ +export enum MapNodeType { + Start = 'start', + Combat = 'combat', + Event = 'event', + Elite = 'elite', + Shelter = 'shelter', + NPC = 'npc', + Boss = 'boss', +} + +/** + * A single node on the map. + */ +export interface MapNode { + /** Unique identifier */ + id: string; + /** Which layer this node belongs to */ + layerIndex: number; + /** Semantic type of the node */ + type: MapNodeType; + /** IDs of nodes in the next layer this node connects to */ + childIds: string[]; + /** Encounter data assigned to this node (from encounter CSV) */ + encounter?: { + name: string; + description: string; + }; +} + +/** + * A horizontal layer of nodes at the same progression stage. + */ +export interface MapLayer { + /** Layer index (0 = start, last = end) */ + index: number; + /** Ordered IDs of nodes in this layer */ + nodeIds: string[]; +} + +/** + * A fully generated point crawl map. + */ +export interface PointCrawlMap { + /** Layers from start to end */ + layers: MapLayer[]; + /** All nodes keyed by ID */ + nodes: Map; + /** RNG seed used for generation (for reproducibility) */ + seed: number; +} diff --git a/tests/samples/slay-the-spire-like/map/generator.test.ts b/tests/samples/slay-the-spire-like/map/generator.test.ts new file mode 100644 index 0000000..70aee68 --- /dev/null +++ b/tests/samples/slay-the-spire-like/map/generator.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { generatePointCrawlMap } from '@/samples/slay-the-spire-like/map/generator'; +import { MapNodeType } from '@/samples/slay-the-spire-like/map/types'; + +describe('generatePointCrawlMap', () => { + it('should generate a map with 13 layers', () => { + const map = generatePointCrawlMap(123); + expect(map.layers.length).toBe(13); + }); + + it('should have correct fixed layer types', () => { + const map = generatePointCrawlMap(123); + const startNode = map.nodes.get('node-0-0'); + const bossNode = map.nodes.get('node-12-0'); + + expect(startNode?.type).toBe(MapNodeType.Start); + expect(bossNode?.type).toBe(MapNodeType.Boss); + }); + + it('should assign encounters to nodes based on encounterDesert.csv', () => { + const map = generatePointCrawlMap(456); + + // Check that nodes have encounters assigned + let combatWithEncounter = 0; + let eliteWithEncounter = 0; + let bossWithEncounter = 0; + let eventWithEncounter = 0; + let npcWithEncounter = 0; + let shelterWithEncounter = 0; + + for (const node of map.nodes.values()) { + if (node.type === MapNodeType.Combat && node.encounter) { + combatWithEncounter++; + expect(node.encounter.name).toBeTruthy(); + expect(node.encounter.description).toBeTruthy(); + } + if (node.type === MapNodeType.Elite && node.encounter) { + eliteWithEncounter++; + expect(node.encounter.name).toBeTruthy(); + expect(node.encounter.description).toBeTruthy(); + } + if (node.type === MapNodeType.Boss && node.encounter) { + bossWithEncounter++; + expect(node.encounter.name).toBeTruthy(); + expect(node.encounter.description).toBeTruthy(); + } + if (node.type === MapNodeType.Event && node.encounter) { + eventWithEncounter++; + expect(node.encounter.name).toBeTruthy(); + expect(node.encounter.description).toBeTruthy(); + } + if (node.type === MapNodeType.NPC && node.encounter) { + npcWithEncounter++; + expect(node.encounter.name).toBeTruthy(); + expect(node.encounter.description).toBeTruthy(); + } + if (node.type === MapNodeType.Shelter && node.encounter) { + shelterWithEncounter++; + expect(node.encounter.name).toBeTruthy(); + expect(node.encounter.description).toBeTruthy(); + } + } + + // Should have assigned at least some encounters + const totalWithEncounters = + combatWithEncounter + + eliteWithEncounter + + bossWithEncounter + + eventWithEncounter + + npcWithEncounter + + shelterWithEncounter; + + expect(totalWithEncounters).toBeGreaterThan(0); + }); + + it('should use correct encounter types for each node type', () => { + const map = generatePointCrawlMap(789); + + for (const node of map.nodes.values()) { + if (node.encounter) { + // Encounter should match node type conceptually + // Combat nodes should have enemy encounters, elites should have elite encounters, etc. + if (node.type === MapNodeType.Boss) { + expect(node.encounter.description).toContain('Boss'); + } + } + } + }); +});