From 5d1dc487f857e52d325cc6faea0f7734b4405872 Mon Sep 17 00:00:00 2001 From: hyper Date: Mon, 13 Apr 2026 17:24:57 +0800 Subject: [PATCH] refactor: map gen? --- .../slay-the-spire-like/map/generator.ts | 254 +++++++++--------- src/samples/slay-the-spire-like/map/index.ts | 2 +- src/samples/slay-the-spire-like/map/types.ts | 22 ++ .../slay-the-spire-like/map/generator.test.ts | 74 +++++ 4 files changed, 230 insertions(+), 122 deletions(-) diff --git a/src/samples/slay-the-spire-like/map/generator.ts b/src/samples/slay-the-spire-like/map/generator.ts index 319bd8f..8ff8583 100644 --- a/src/samples/slay-the-spire-like/map/generator.ts +++ b/src/samples/slay-the-spire-like/map/generator.ts @@ -1,21 +1,21 @@ import { Mulberry32RNG, type RNG } from '@/utils/rng'; import encounterDesertCsv, { type EncounterDesert } from '../data/encounterDesert.csv'; import { MapNodeType, MapLayerType } from './types'; -import type { MapLayer, MapNode, PointCrawlMap } from './types'; +import type { MapLayer, MapNode, PointCrawlMap, MapGenerationConfig } from './types'; -/** Cache for parsed encounters by type */ -const encountersByType = new Map(); - -function indexEncounters(): void { - if (encountersByType.size > 0) return; +/** Pre-indexed encounters by type */ +const encountersByType = buildEncounterIndex(); +function buildEncounterIndex(): Map { + const index = new Map(); for (const encounter of encounterDesertCsv) { const type = encounter.type; - if (!encountersByType.has(type)) { - encountersByType.set(type, []); + if (!index.has(type)) { + index.set(type, []); } - encountersByType.get(type)!.push(encounter); + index.get(type)!.push(encounter); } + return index; } /** Map from MapNodeType to encounter type key */ @@ -28,23 +28,17 @@ const NODE_TYPE_TO_ENCOUNTER: Partial> = { [MapNodeType.Curio]: '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 */ -const TOTAL_LAYERS = 10; +/** Default map generation configuration */ +const DEFAULT_CONFIG: MapGenerationConfig = { + totalLayers: 10, + wildLayerNodeCount: 3, + settlementLayerNodeCount: 4, + wildNodeTypeWeights: { + [MapNodeType.Minion]: 50, + [MapNodeType.Elite]: 25, + [MapNodeType.Event]: 25, + }, +}; /** * Layer structure definition. @@ -63,6 +57,35 @@ const LAYER_STRUCTURE: Array<{ layerType: MapLayerType | 'start' | 'end'; count: { layerType: 'end', count: 1 }, ]; +/** + * Fisher-Yates shuffle algorithm for unbiased random permutation. + * Mutates the array in place and returns it. + */ +function fisherYatesShuffle(array: T[], rng: RNG): T[] { + for (let i = array.length - 1; i > 0; i--) { + const j = rng.nextInt(i + 1); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; +} + +/** + * Picks a random encounter for the given node type. + * Returns undefined if no matching encounter exists. + */ +function pickEncounterForNode(type: MapNodeType, rng: RNG): EncounterDesert | undefined { + 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 */ +const TOTAL_LAYERS = 10; + /** * Generates a random point crawl map with layered directional graph. * @@ -86,6 +109,7 @@ export function generatePointCrawlMap(seed?: number): PointCrawlMap { for (let i = 0; i < TOTAL_LAYERS; i++) { const structure = LAYER_STRUCTURE[i]; const nodeIds: string[] = []; + const layerNodes: MapNode[] = []; for (let j = 0; j < structure.count; j++) { const id = `node-${i}-${j}`; @@ -100,19 +124,32 @@ export function generatePointCrawlMap(seed?: number): PointCrawlMap { }; nodes.set(id, node); nodeIds.push(id); + layerNodes.push(node); } - layers.push({ index: i, nodeIds, layerType: structure.layerType }); + layers.push({ index: i, nodeIds, layerType: structure.layerType, nodes: layerNodes }); } // Step 2: generate edges between consecutive layers + const parentIndex = new Map(); + for (let i = 0; i < TOTAL_LAYERS - 1; i++) { const sourceLayer = layers[i]; const targetLayer = layers[i + 1]; generateLayerEdges(sourceLayer, targetLayer, nodes, rng); + + // Build reverse index: for each target node, record its parents + for (const srcNode of sourceLayer.nodes) { + for (const childId of srcNode.childIds) { + if (!parentIndex.has(childId)) { + parentIndex.set(childId, []); + } + parentIndex.get(childId)!.push(srcNode.id); + } + } } - return { layers, nodes, seed: actualSeed }; + return { layers, nodes, seed: actualSeed, parentIndex }; } /** @@ -140,35 +177,35 @@ function resolveNodeType( } /** - * Picks a random type for a wild node. - * minion: 50%, elite: 25%, event: 25% + * Picks a random type for a wild node based on configured weights. + * Default: minion: 50%, elite: 25%, event: 25% */ function pickWildNodeType(rng: RNG): MapNodeType { - const roll = rng.nextInt(4); - if (roll === 0) return MapNodeType.Elite; - if (roll === 1) return MapNodeType.Event; - return MapNodeType.Minion; + const weights = DEFAULT_CONFIG.wildNodeTypeWeights; + const roll = rng.nextInt(100); + + if (roll < weights[MapNodeType.Minion]) return MapNodeType.Minion; + if (roll < weights[MapNodeType.Minion] + weights[MapNodeType.Elite]) return MapNodeType.Elite; + return MapNodeType.Event; } /** * Assigns settlement node types ensuring at least 1 of each: camp, shop, curio. * The 4th node is randomly chosen from the three. */ -function assignSettlementTypes(nodeIds: string[], nodes: Map, rng: RNG): void { +function assignSettlementTypes(nodeIds: string[], nodes: MapNode[], rng: RNG): void { // Shuffle node order to randomize which position gets which type - const shuffledIndices = [0, 1, 2, 3].sort(() => rng.next() - 0.5); + const shuffledIndices = fisherYatesShuffle([0, 1, 2, 3], rng); // Assign camp, shop, curio to first 3 shuffled positions const requiredTypes = [MapNodeType.Camp, MapNodeType.Shop, MapNodeType.Curio]; for (let i = 0; i < 3; i++) { - const node = nodes.get(nodeIds[shuffledIndices[i]])!; - node.type = requiredTypes[i]; + nodes[shuffledIndices[i]].type = requiredTypes[i]; } // Assign random type to 4th position const randomType = requiredTypes[rng.nextInt(3)]; - const node = nodes.get(nodeIds[shuffledIndices[3]])!; - node.type = randomType; + nodes[shuffledIndices[3]].type = randomType; } /** @@ -182,22 +219,22 @@ function generateLayerEdges( ): void { // Assign settlement types when creating settlement layer if (targetLayer.layerType === MapLayerType.Settlement) { - assignSettlementTypes(targetLayer.nodeIds, nodes, rng); + assignSettlementTypes(targetLayer.nodeIds, targetLayer.nodes, rng); } const sourceType = sourceLayer.layerType; const targetType = targetLayer.layerType; if (sourceType === 'start' && targetType === MapLayerType.Wild) { - connectStartToWild(sourceLayer, targetLayer, nodes); + connectStartToWild(sourceLayer, targetLayer); } else if (sourceType === MapLayerType.Wild && targetType === MapLayerType.Wild) { - connectWildToWild(sourceLayer, targetLayer, nodes, rng); + connectWildToWild(sourceLayer, targetLayer); } else if (sourceType === MapLayerType.Wild && targetType === MapLayerType.Settlement) { - connectWildToSettlement(sourceLayer, targetLayer, nodes, rng); + connectWildToSettlement(sourceLayer, targetLayer, rng); } else if (sourceType === MapLayerType.Settlement && targetType === MapLayerType.Wild) { - connectSettlementToWild(sourceLayer, targetLayer, nodes, rng); + connectSettlementToWild(sourceLayer, targetLayer, rng); } else if (sourceType === MapLayerType.Wild && targetType === 'end') { - connectWildToEnd(sourceLayer, targetLayer, nodes); + connectWildToEnd(sourceLayer, targetLayer); } } @@ -206,10 +243,9 @@ function generateLayerEdges( */ function connectStartToWild( startLayer: MapLayer, - wildLayer: MapLayer, - nodes: Map + wildLayer: MapLayer ): void { - const startNode = nodes.get(startLayer.nodeIds[0])!; + const startNode = startLayer.nodes[0]; startNode.childIds = [...wildLayer.nodeIds]; } @@ -219,15 +255,12 @@ function connectStartToWild( */ function connectWildToWild( sourceLayer: MapLayer, - targetLayer: MapLayer, - nodes: Map, - _rng: RNG + targetLayer: MapLayer ): void { // Direct 1-to-1 mapping: wild[i] → wild[i] // This guarantees no crossings since order is preserved - for (let i = 0; i < 3; i++) { - const srcNode = nodes.get(sourceLayer.nodeIds[i])!; - srcNode.childIds = [targetLayer.nodeIds[i]]; + for (let i = 0; i < sourceLayer.nodes.length; i++) { + sourceLayer.nodes[i].childIds = [targetLayer.nodeIds[i]]; } } @@ -239,53 +272,25 @@ function connectWildToWild( function connectWildToSettlement( wildLayer: MapLayer, settlementLayer: MapLayer, - nodes: Map, rng: RNG ): void { - // Strategy: create a mapping where each wild gets exactly 2 settlements - // and all 4 settlements are covered - // Example pattern: wild[0]→{s0,s1}, wild[1]→{s1,s2}, wild[2]→{s2,s3} - // But we want randomness, so: - // 1. Shuffle settlements - // 2. Assign first 3 settlements to wilds 0,1,2 (guarantee coverage) - // 3. 4th settlement goes to a random wild - // 4. Each wild picks one more from remaining available + // Non-crossing connection pattern: each wild connects to 2 adjacent settlements. + // Base pattern: w[0]→{s[0],s[1]}, w[1]→{s[1],s[2]}, w[2]→{s[2],s[3]} + // This creates a "chain" where middle settlements are shared. + // + // Variation: randomly flip to reverse pattern w[0]→{s[2],s[3]}, w[1]→{s[1],s[2]}, w[2]→{s[0],s[1]} + // Both patterns guarantee no crossings. - const settlementOrder = [0, 1, 2, 3].sort(() => rng.next() - 0.5); + const reverse = rng.next() < 0.5; - // Initial assignment: each wild gets 1 unique settlement - const assignments: Set[] = [ - new Set([settlementOrder[0]]), - new Set([settlementOrder[1]]), - new Set([settlementOrder[2]]), - ]; - - // 4th settlement goes to a random wild - const wildForFourth = rng.nextInt(3); - assignments[wildForFourth].add(settlementOrder[3]); - - // Now each wild needs exactly 2 settlements - // Find which wilds still need 1 more - const needMore: number[] = []; - for (let i = 0; i < 3; i++) { - if (assignments[i].size < 2) { - needMore.push(i); - } - } - - // These wilds pick from settlements that already have coverage - // to create convergence (multiple wilds → same settlement) - for (const wildIdx of needMore) { - // Pick a random settlement (excluding the one already assigned) - const available = [0, 1, 2, 3].filter(s => !assignments[wildIdx].has(s)); - const pick = available[rng.nextInt(available.length)]; - assignments[wildIdx].add(pick); - } - - // Assign childIds - for (let i = 0; i < 3; i++) { - const srcNode = nodes.get(wildLayer.nodeIds[i])!; - srcNode.childIds = [...assignments[i]].map(idx => settlementLayer.nodeIds[idx]); + if (reverse) { + wildLayer.nodes[0].childIds = [settlementLayer.nodeIds[2], settlementLayer.nodeIds[3]]; + wildLayer.nodes[1].childIds = [settlementLayer.nodeIds[1], settlementLayer.nodeIds[2]]; + wildLayer.nodes[2].childIds = [settlementLayer.nodeIds[0], settlementLayer.nodeIds[1]]; + } else { + wildLayer.nodes[0].childIds = [settlementLayer.nodeIds[0], settlementLayer.nodeIds[1]]; + wildLayer.nodes[1].childIds = [settlementLayer.nodeIds[1], settlementLayer.nodeIds[2]]; + wildLayer.nodes[2].childIds = [settlementLayer.nodeIds[2], settlementLayer.nodeIds[3]]; } } @@ -294,33 +299,31 @@ function connectWildToSettlement( * - First and last settlement connect to 1 wild each * - Middle two settlements connect to 2 wilds each * Total: 1 + 2 + 2 + 1 = 6 edges, 3 wild nodes to cover - * + * * Uses a non-crossing pattern: settlements and wilds are connected * in order to avoid edge crossings. */ function connectSettlementToWild( settlementLayer: MapLayer, wildLayer: MapLayer, - nodes: Map, rng: RNG ): void { - // Non-crossing pattern with circular shift option - // Base pattern: s0→w0, s1→w0,w1, s2→w1,w2, s3→w2 - // Apply circular shift to wilds for variety - - const shift = rng.nextInt(3); - const wildIdx = (i: number) => (i + shift) % 3; + // Non-crossing pattern: s0→w0, s1→w0,w1, s2→w1,w2, s3→w2 + // Variation: randomly flip to reverse pattern s0→w2, s1→w1,w2, s2→w0,w1, s3→w0 + // Both patterns guarantee no crossings. - const settlementAssignments: number[][] = [ - [wildIdx(0)], - [wildIdx(0), wildIdx(1)], - [wildIdx(1), wildIdx(2)], - [wildIdx(2)], - ]; + const reverse = rng.next() < 0.5; - for (let i = 0; i < 4; i++) { - const srcNode = nodes.get(settlementLayer.nodeIds[i])!; - srcNode.childIds = settlementAssignments[i].map(idx => wildLayer.nodeIds[idx]); + if (reverse) { + settlementLayer.nodes[0].childIds = [wildLayer.nodeIds[2]]; + settlementLayer.nodes[1].childIds = [wildLayer.nodeIds[1], wildLayer.nodeIds[2]]; + settlementLayer.nodes[2].childIds = [wildLayer.nodeIds[0], wildLayer.nodeIds[1]]; + settlementLayer.nodes[3].childIds = [wildLayer.nodeIds[0]]; + } else { + settlementLayer.nodes[0].childIds = [wildLayer.nodeIds[0]]; + settlementLayer.nodes[1].childIds = [wildLayer.nodeIds[0], wildLayer.nodeIds[1]]; + settlementLayer.nodes[2].childIds = [wildLayer.nodeIds[1], wildLayer.nodeIds[2]]; + settlementLayer.nodes[3].childIds = [wildLayer.nodeIds[2]]; } } @@ -329,13 +332,11 @@ function connectSettlementToWild( */ function connectWildToEnd( wildLayer: MapLayer, - endLayer: MapLayer, - nodes: Map + endLayer: MapLayer ): void { - const endNode = nodes.get(endLayer.nodeIds[0])!; - for (let i = 0; i < 3; i++) { - const srcNode = nodes.get(wildLayer.nodeIds[i])!; - srcNode.childIds = [endNode.id]; + const endNode = endLayer.nodes[0]; + for (let i = 0; i < wildLayer.nodes.length; i++) { + wildLayer.nodes[i].childIds = [endNode.id]; } } @@ -355,6 +356,17 @@ export function getChildren(map: PointCrawlMap, node: MapNode): MapNode[] { /** Returns parent nodes of the given node (reverse lookup). */ export function getParents(map: PointCrawlMap, node: MapNode): MapNode[] { + // Use pre-built reverse index if available + if (map.parentIndex) { + const parentIds = map.parentIndex.get(node.id); + if (!parentIds) return []; + + return parentIds + .map(id => map.nodes.get(id)) + .filter((n): n is MapNode => n !== undefined); + } + + // Fallback: scan parent layer (legacy support) const parents: MapNode[] = []; const parentLayer = map.layers[node.layerIndex - 1]; if (!parentLayer) return parents; diff --git a/src/samples/slay-the-spire-like/map/index.ts b/src/samples/slay-the-spire-like/map/index.ts index e9a1af1..1cc561e 100644 --- a/src/samples/slay-the-spire-like/map/index.ts +++ b/src/samples/slay-the-spire-like/map/index.ts @@ -1,5 +1,5 @@ export { MapNodeType, MapLayerType } from './types'; -export type { MapNode, MapLayer, PointCrawlMap } from './types'; +export type { MapNode, MapLayer, PointCrawlMap, MapGenerationConfig } 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 index bf1cc13..5797bd3 100644 --- a/src/samples/slay-the-spire-like/map/types.ts +++ b/src/samples/slay-the-spire-like/map/types.ts @@ -49,6 +49,8 @@ export interface MapLayer { nodeIds: string[]; /** Semantic type of the layer */ layerType: MapLayerType | 'start' | 'end'; + /** Direct references to nodes in this layer (for performance) */ + nodes: MapNode[]; } /** @@ -61,4 +63,24 @@ export interface PointCrawlMap { nodes: Map; /** RNG seed used for generation (for reproducibility) */ seed: number; + /** Reverse index: nodeId → parent node IDs (for fast getParent lookup) */ + parentIndex?: Map; +} + +/** + * Configuration for map generation. + */ +export interface MapGenerationConfig { + /** Total number of layers (including start and end) */ + totalLayers: number; + /** Number of nodes in each wild layer */ + wildLayerNodeCount: number; + /** Number of nodes in each settlement layer */ + settlementLayerNodeCount: number; + /** Probability weights for wild node types (should sum to 100) */ + wildNodeTypeWeights: { + [MapNodeType.Minion]: number; + [MapNodeType.Elite]: number; + [MapNodeType.Event]: 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 index 064234c..459b7de 100644 --- a/tests/samples/slay-the-spire-like/map/generator.test.ts +++ b/tests/samples/slay-the-spire-like/map/generator.test.ts @@ -213,6 +213,80 @@ describe('generatePointCrawlMap', () => { } }); + it('should not have crossing edges in wild→settlement transitions', () => { + const map = generatePointCrawlMap(12345); + const wildToSettlementTransitions = [ + { src: 2, tgt: 3 }, + { src: 5, tgt: 6 }, + ]; + + for (const transition of wildToSettlementTransitions) { + const srcLayer = map.layers[transition.src]; + const tgtLayer = map.layers[transition.tgt]; + + // Collect edges as pairs of indices + const edges: Array<{ srcIndex: number; tgtIndex: number }> = []; + for (let s = 0; s < srcLayer.nodeIds.length; s++) { + const srcNode = map.nodes.get(srcLayer.nodeIds[s]); + for (const tgtId of srcNode!.childIds) { + const t = tgtLayer.nodeIds.indexOf(tgtId); + edges.push({ srcIndex: s, tgtIndex: t }); + } + } + + // Check for crossings + for (let e1 = 0; e1 < edges.length; e1++) { + for (let e2 = e1 + 1; e2 < edges.length; e2++) { + const { srcIndex: s1, tgtIndex: t1 } = edges[e1]; + const { srcIndex: s2, tgtIndex: t2 } = edges[e2]; + + if (s1 === s2) continue; + if (t1 === t2) continue; + + const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2); + expect(crosses).toBe(false); + } + } + } + }); + + it('should not have crossing edges in settlement→wild transitions', () => { + const map = generatePointCrawlMap(12345); + const settlementToWildTransitions = [ + { src: 3, tgt: 4 }, + { src: 6, tgt: 7 }, + ]; + + for (const transition of settlementToWildTransitions) { + const srcLayer = map.layers[transition.src]; + const tgtLayer = map.layers[transition.tgt]; + + // Collect edges as pairs of indices + const edges: Array<{ srcIndex: number; tgtIndex: number }> = []; + for (let s = 0; s < srcLayer.nodeIds.length; s++) { + const srcNode = map.nodes.get(srcLayer.nodeIds[s]); + for (const tgtId of srcNode!.childIds) { + const t = tgtLayer.nodeIds.indexOf(tgtId); + edges.push({ srcIndex: s, tgtIndex: t }); + } + } + + // Check for crossings + for (let e1 = 0; e1 < edges.length; e1++) { + for (let e2 = e1 + 1; e2 < edges.length; e2++) { + const { srcIndex: s1, tgtIndex: t1 } = edges[e1]; + const { srcIndex: s2, tgtIndex: t2 } = edges[e2]; + + if (s1 === s2) continue; + if (t1 === t2) continue; + + const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2); + expect(crosses).toBe(false); + } + } + } + }); + it('should assign encounters to nodes', () => { const map = generatePointCrawlMap(456);