diff --git a/src/samples/slay-the-spire-like/index.ts b/src/samples/slay-the-spire-like/index.ts index 5dff9c7..a009c8e 100644 --- a/src/samples/slay-the-spire-like/index.ts +++ b/src/samples/slay-the-spire-like/index.ts @@ -19,7 +19,7 @@ export { } from './grid-inventory'; // Map -export { MapNodeType } from './map'; +export { MapNodeType, MapLayerType } from './map'; export type { MapNode, MapLayer, PointCrawlMap } from './map'; export { generatePointCrawlMap, getNode, getChildren, getParents, hasPath, findAllPaths } from './map'; diff --git a/src/samples/slay-the-spire-like/map/generator.ts b/src/samples/slay-the-spire-like/map/generator.ts index d0afc3f..319bd8f 100644 --- a/src/samples/slay-the-spire-like/map/generator.ts +++ b/src/samples/slay-the-spire-like/map/generator.ts @@ -1,6 +1,6 @@ import { Mulberry32RNG, type RNG } from '@/utils/rng'; import encounterDesertCsv, { type EncounterDesert } from '../data/encounterDesert.csv'; -import { MapNodeType } from './types'; +import { MapNodeType, MapLayerType } from './types'; import type { MapLayer, MapNode, PointCrawlMap } from './types'; /** Cache for parsed encounters by type */ @@ -20,12 +20,12 @@ function indexEncounters(): void { /** Map from MapNodeType to encounter type key */ const NODE_TYPE_TO_ENCOUNTER: Partial> = { - [MapNodeType.Combat]: 'enemy', + [MapNodeType.Minion]: 'enemy', [MapNodeType.Elite]: 'elite', - [MapNodeType.Boss]: 'boss', [MapNodeType.Event]: 'event', - [MapNodeType.NPC]: 'npc', - [MapNodeType.Shelter]: 'shelter', + [MapNodeType.Camp]: 'shelter', + [MapNodeType.Shop]: 'npc', + [MapNodeType.Curio]: 'shelter', }; /** @@ -43,32 +43,35 @@ function pickEncounterForNode(type: MapNodeType, rng: RNG): EncounterDesert | un 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, -}; +/** Total number of layers */ +const TOTAL_LAYERS = 10; /** - * How many nodes each layer should have. - * Diamond-ish shape: 1→2→3→4→5→5→5→5→4→3→2→1 + * Layer structure definition. + * Pattern: Start → Wild → Wild → Settlement → Wild → Wild → Settlement → Wild → Wild → End */ -const LAYER_WIDTHS = [1, 2, 3, 4, 5, 5, 5, 5, 5, 4, 3, 2, 1]; +const LAYER_STRUCTURE: Array<{ layerType: MapLayerType | 'start' | 'end'; count: number }> = [ + { layerType: 'start', count: 1 }, + { layerType: MapLayerType.Wild, count: 3 }, + { layerType: MapLayerType.Wild, count: 3 }, + { layerType: MapLayerType.Settlement, count: 4 }, + { layerType: MapLayerType.Wild, count: 3 }, + { layerType: MapLayerType.Wild, count: 3 }, + { layerType: MapLayerType.Settlement, count: 4 }, + { layerType: MapLayerType.Wild, count: 3 }, + { layerType: MapLayerType.Wild, count: 3 }, + { layerType: 'end', count: 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 + * Structure: + * - 10 layers: Start → Wild×2 → Settlement → Wild×2 → Settlement → Wild×2 → End + * - Wild layers have exactly 3 nodes (minion/elite/event) + * - Settlement layers have exactly 4 nodes (camp/shop/curio + 1 random) + * - Each settlement layer has at least 1 of each: camp, shop, curio + * - Wild nodes connect to 1 wild node or 2 settlement nodes * * @param seed Random seed for reproducibility */ @@ -81,13 +84,12 @@ export function generatePointCrawlMap(seed?: number): PointCrawlMap { // 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 structure = LAYER_STRUCTURE[i]; const nodeIds: string[] = []; - for (let j = 0; j < count; j++) { + for (let j = 0; j < structure.count; j++) { const id = `node-${i}-${j}`; - const type = layerType ?? pickLayerNodeType(i, rng); + const type = resolveNodeType(structure.layerType, j, structure.count, rng); const encounter = pickEncounterForNode(type, rng); const node: MapNode = { id, @@ -100,116 +102,240 @@ export function generatePointCrawlMap(seed?: number): PointCrawlMap { nodeIds.push(id); } - layers.push({ index: i, nodeIds }); + layers.push({ index: i, nodeIds, layerType: structure.layerType }); } - // Step 2: generate edges between each pair of consecutive layers + // Step 2: generate edges between 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); + const sourceLayer = layers[i]; + const targetLayer = layers[i + 1]; + generateLayerEdges(sourceLayer, targetLayer, 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. + * Resolves the node type based on layer type and position. */ -function pickLayerNodeType(_layerIndex: number, rng: RNG): MapNodeType { - return rng.nextInt(4) === 0 ? MapNodeType.Elite : MapNodeType.Combat; +function resolveNodeType( + layerType: MapLayerType | 'start' | 'end', + _nodeIndex: number, + _layerCount: number, + rng: RNG +): MapNodeType { + switch (layerType) { + case 'start': + return MapNodeType.Start; + case 'end': + return MapNodeType.End; + case MapLayerType.Wild: + return pickWildNodeType(rng); + case MapLayerType.Settlement: + // This will be overridden by assignSettlementTypes + return MapNodeType.Camp; // placeholder + default: + return MapNodeType.Minion; + } } /** - * 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) - * - Nodes only connect to nearby nodes (by index) to avoid crossing paths - * - * Strategy to avoid crossings: - * - Partition target nodes among source nodes (no overlap) - * - Each source can only connect to targets in its assigned partition - * - This guarantees no crossings by construction + * Picks a random type for a wild node. + * 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; +} + +/** + * 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 { + // Shuffle node order to randomize which position gets which type + const shuffledIndices = [0, 1, 2, 3].sort(() => rng.next() - 0.5); + + // 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]; + } + + // Assign random type to 4th position + const randomType = requiredTypes[rng.nextInt(3)]; + const node = nodes.get(nodeIds[shuffledIndices[3]])!; + node.type = randomType; +} + +/** + * Generates edges between two consecutive layers based on layer types. */ function generateLayerEdges( - sourceIds: string[], - targetIds: string[], + sourceLayer: MapLayer, + targetLayer: MapLayer, 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); + // Assign settlement types when creating settlement layer + if (targetLayer.layerType === MapLayerType.Settlement) { + assignSettlementTypes(targetLayer.nodeIds, nodes, rng); + } - const pickRandom = (arr: string[]): string => arr[rng.nextInt(arr.length)]; + const sourceType = sourceLayer.layerType; + const targetType = targetLayer.layerType; - // Partition targets among sources (no overlap) - // Each source gets a contiguous range of targets - const getTargetRange = (srcIndex: number): { start: number; end: number } => { - if (sourceIds.length === 1) { - return { start: 0, end: targetIds.length }; - } + if (sourceType === 'start' && targetType === MapLayerType.Wild) { + connectStartToWild(sourceLayer, targetLayer, nodes); + } else if (sourceType === MapLayerType.Wild && targetType === MapLayerType.Wild) { + connectWildToWild(sourceLayer, targetLayer, nodes, rng); + } else if (sourceType === MapLayerType.Wild && targetType === MapLayerType.Settlement) { + connectWildToSettlement(sourceLayer, targetLayer, nodes, rng); + } else if (sourceType === MapLayerType.Settlement && targetType === MapLayerType.Wild) { + connectSettlementToWild(sourceLayer, targetLayer, nodes, rng); + } else if (sourceType === MapLayerType.Wild && targetType === 'end') { + connectWildToEnd(sourceLayer, targetLayer, nodes); + } +} - // Calculate proportional boundaries - const start = Math.floor((srcIndex * targetIds.length) / sourceIds.length); - const end = Math.floor(((srcIndex + 1) * targetIds.length) / sourceIds.length); - return { start, end }; - }; +/** + * Start connects to all 3 wild nodes in the first wild layer. + */ +function connectStartToWild( + startLayer: MapLayer, + wildLayer: MapLayer, + nodes: Map +): void { + const startNode = nodes.get(startLayer.nodeIds[0])!; + startNode.childIds = [...wildLayer.nodeIds]; +} - // --- Pass 1: give each source 1–2 targets within its partition --- - const uncovered = new Set(targetIds); +/** + * Each wild node connects to exactly 1 wild node in the next layer (1-to-1 mapping). + * Uses direct ordering to avoid crossing edges. + */ +function connectWildToWild( + sourceLayer: MapLayer, + targetLayer: MapLayer, + nodes: Map, + _rng: RNG +): 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 s = 0; s < sourceIds.length; s++) { - const srcId = sourceIds[s]; - const range = getTargetRange(s); - const availableInPartition = []; +/** + * Each wild node connects to 2 settlement nodes. + * Ensures all 4 settlement nodes are covered. + * Total: 3 wilds × 2 = 6 edges, 4 settlements to cover + */ +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 - // Collect available targets in this partition - for (let t = range.start; t < range.end; t++) { - availableInPartition.push(targetIds[t]); - } + const settlementOrder = [0, 1, 2, 3].sort(() => rng.next() - 0.5); - // Decide branches (1 or 2), but limited by available targets - const maxBranches = Math.min(2, availableInPartition.length); - if (maxBranches === 0) continue; + // Initial assignment: each wild gets 1 unique settlement + const assignments: Set[] = [ + new Set([settlementOrder[0]]), + new Set([settlementOrder[1]]), + new Set([settlementOrder[2]]), + ]; - const branches = rng.nextInt(maxBranches) + 1; + // 4th settlement goes to a random wild + const wildForFourth = rng.nextInt(3); + assignments[wildForFourth].add(settlementOrder[3]); - // Shuffle and pick - const shuffled = [...availableInPartition].sort(() => rng.next() - 0.5); - const selected = shuffled.slice(0, branches); - - for (const tgtId of selected) { - nodes.get(srcId)!.childIds.push(tgtId); - sourceBranches.set(srcId, sourceBranches.get(srcId)! + 1); - targetIncoming.set(tgtId, targetIncoming.get(tgtId)! + 1); - uncovered.delete(tgtId); + // 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); } } - // --- Pass 2: cover any remaining uncovered targets --- - // Since partitions don't overlap, we must assign to the owning source - for (const tgtId of uncovered) { - const tgtIndex = targetIds.indexOf(tgtId); + // 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); + } - // Find which source partition this target belongs to - let owningSource = 0; - for (let s = 0; s < sourceIds.length; s++) { - const range = getTargetRange(s); - if (tgtIndex >= range.start && tgtIndex < range.end) { - owningSource = s; - break; - } - } + // 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]); + } +} - const srcId = sourceIds[owningSource]; - nodes.get(srcId)!.childIds.push(tgtId); - sourceBranches.set(srcId, sourceBranches.get(srcId)! + 1); - targetIncoming.set(tgtId, targetIncoming.get(tgtId)! + 1); +/** + * Settlement nodes connect to wild nodes: + * - 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; + + const settlementAssignments: number[][] = [ + [wildIdx(0)], + [wildIdx(0), wildIdx(1)], + [wildIdx(1), wildIdx(2)], + [wildIdx(2)], + ]; + + for (let i = 0; i < 4; i++) { + const srcNode = nodes.get(settlementLayer.nodeIds[i])!; + srcNode.childIds = settlementAssignments[i].map(idx => wildLayer.nodeIds[idx]); + } +} + +/** + * All 3 wild nodes in the last wild layer connect to End. + */ +function connectWildToEnd( + wildLayer: MapLayer, + endLayer: MapLayer, + nodes: Map +): 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]; } } diff --git a/src/samples/slay-the-spire-like/map/index.ts b/src/samples/slay-the-spire-like/map/index.ts index a6666ba..e9a1af1 100644 --- a/src/samples/slay-the-spire-like/map/index.ts +++ b/src/samples/slay-the-spire-like/map/index.ts @@ -1,4 +1,4 @@ -export { MapNodeType } from './types'; +export { MapNodeType, MapLayerType } from './types'; export type { MapNode, MapLayer, PointCrawlMap } from './types'; export { generatePointCrawlMap } 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 3ab6baa..bf1cc13 100644 --- a/src/samples/slay-the-spire-like/map/types.ts +++ b/src/samples/slay-the-spire-like/map/types.ts @@ -3,12 +3,21 @@ */ export enum MapNodeType { Start = 'start', - Combat = 'combat', - Event = 'event', + End = 'end', + Minion = 'minion', Elite = 'elite', - Shelter = 'shelter', - NPC = 'npc', - Boss = 'boss', + Event = 'event', + Camp = 'camp', + Shop = 'shop', + Curio = 'curio', +} + +/** + * Semantic type of a layer. + */ +export enum MapLayerType { + Wild = 'wild', + Settlement = 'settlement', } /** @@ -38,6 +47,8 @@ export interface MapLayer { index: number; /** Ordered IDs of nodes in this layer */ nodeIds: string[]; + /** Semantic type of the layer */ + layerType: MapLayerType | 'start' | 'end'; } /** 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 605f3da..064234c 100644 --- a/tests/samples/slay-the-spire-like/map/generator.test.ts +++ b/tests/samples/slay-the-spire-like/map/generator.test.ts @@ -1,150 +1,210 @@ 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'; +import { generatePointCrawlMap, hasPath } from '@/samples/slay-the-spire-like/map/generator'; +import { MapNodeType, MapLayerType } from '@/samples/slay-the-spire-like/map/types'; describe('generatePointCrawlMap', () => { - it('should generate a map with 13 layers', () => { + it('should generate a map with 10 layers', () => { const map = generatePointCrawlMap(123); - expect(map.layers.length).toBe(13); + expect(map.layers.length).toBe(10); }); - it('should have correct fixed layer types', () => { + it('should have correct layer structure', () => { + const map = generatePointCrawlMap(123); + const expectedStructure = [ + 'start', + MapLayerType.Wild, + MapLayerType.Wild, + MapLayerType.Settlement, + MapLayerType.Wild, + MapLayerType.Wild, + MapLayerType.Settlement, + MapLayerType.Wild, + MapLayerType.Wild, + 'end', + ]; + + for (let i = 0; i < expectedStructure.length; i++) { + expect(map.layers[i].layerType).toBe(expectedStructure[i]); + } + }); + + it('should have correct node counts per layer', () => { + const map = generatePointCrawlMap(123); + const expectedCounts = [1, 3, 3, 4, 3, 3, 4, 3, 3, 1]; + + for (let i = 0; i < expectedCounts.length; i++) { + expect(map.layers[i].nodeIds.length).toBe(expectedCounts[i]); + } + }); + + it('should have Start and End nodes with correct types', () => { const map = generatePointCrawlMap(123); const startNode = map.nodes.get('node-0-0'); - const bossNode = map.nodes.get('node-12-0'); + const endNode = map.nodes.get('node-9-0'); expect(startNode?.type).toBe(MapNodeType.Start); - expect(bossNode?.type).toBe(MapNodeType.Boss); + expect(endNode?.type).toBe(MapNodeType.End); }); - it('should assign encounters to nodes based on encounterDesert.csv', () => { - const map = generatePointCrawlMap(456); + it('should have wild layers with minion/elite/event types', () => { + const map = generatePointCrawlMap(123); + const wildLayerIndices = [1, 2, 4, 5, 7, 8]; + const validWildTypes = new Set([MapNodeType.Minion, MapNodeType.Elite, MapNodeType.Event]); - // 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'); - } + for (const layerIdx of wildLayerIndices) { + const layer = map.layers[layerIdx]; + for (const nodeId of layer.nodeIds) { + const node = map.nodes.get(nodeId); + expect(node).toBeDefined(); + expect(validWildTypes.has(node!.type)).toBe(true); } } }); - it('should only connect nodes to nearby nodes to avoid crossing paths', () => { + it('should have settlement layers with at least 1 camp, 1 shop, 1 curio', () => { + const map = generatePointCrawlMap(123); + const settlementLayerIndices = [3, 6]; + + for (const layerIdx of settlementLayerIndices) { + const layer = map.layers[layerIdx]; + const nodeTypes = layer.nodeIds.map(id => map.nodes.get(id)!.type); + + expect(nodeTypes).toContain(MapNodeType.Camp); + expect(nodeTypes).toContain(MapNodeType.Shop); + expect(nodeTypes).toContain(MapNodeType.Curio); + expect(nodeTypes.length).toBe(4); + } + }); + + it('should have Start connected to all 3 wild nodes', () => { const map = generatePointCrawlMap(42); + const startNode = map.nodes.get('node-0-0'); + const wildLayer = map.layers[1]; - // Check each edge between consecutive layers - for (let i = 0; i < map.layers.length - 1; i++) { - const sourceLayer = map.layers[i]; - const targetLayer = map.layers[i + 1]; + expect(startNode?.childIds.length).toBe(3); + expect(startNode?.childIds).toEqual(expect.arrayContaining(wildLayer.nodeIds)); + }); - for (const srcId of sourceLayer.nodeIds) { + it('should have each wild node connect to 1 wild node in wild→wild layers', () => { + const map = generatePointCrawlMap(42); + const wildToWildTransitions = [ + { src: 1, tgt: 2 }, + { src: 4, tgt: 5 }, + { src: 7, tgt: 8 }, + ]; + + for (const transition of wildToWildTransitions) { + const srcLayer = map.layers[transition.src]; + for (const srcId of srcLayer.nodeIds) { const srcNode = map.nodes.get(srcId); - expect(srcNode).toBeDefined(); + expect(srcNode?.childIds.length).toBe(1); + const childLayer = map.layers[transition.tgt]; + expect(childLayer.nodeIds).toContain(srcNode!.childIds[0]); + } + } + }); - const srcIndex = sourceLayer.nodeIds.indexOf(srcId); + it('should have each wild node connect to 2 settlement nodes in wild→settlement layers', () => { + const map = generatePointCrawlMap(42); + const wildToSettlementTransitions = [ + { src: 2, tgt: 3 }, + { src: 5, tgt: 6 }, + ]; - for (const tgtId of srcNode!.childIds) { - const tgtIndex = targetLayer.nodeIds.indexOf(tgtId); - - // Calculate the "scaled" source index to compare with target index - // This accounts for layers with different widths - const scaledSrcIndex = srcIndex * (targetLayer.nodeIds.length / sourceLayer.nodeIds.length); - const distance = Math.abs(tgtIndex - scaledSrcIndex); - - // The distance should be within a reasonable radius - // Allow some tolerance for edge cases when covering uncovered targets - const maxAllowedDistance = Math.max(2, Math.floor(targetLayer.nodeIds.length / 2)); - expect(distance).toBeLessThanOrEqual(maxAllowedDistance); + for (const transition of wildToSettlementTransitions) { + const srcLayer = map.layers[transition.src]; + for (const srcId of srcLayer.nodeIds) { + const srcNode = map.nodes.get(srcId); + expect(srcNode?.childIds.length).toBe(2); + const childLayer = map.layers[transition.tgt]; + for (const childId of srcNode!.childIds) { + expect(childLayer.nodeIds).toContain(childId); } } } }); - it('should not have crossing edges between consecutive layers', () => { + it('should have settlement nodes connect correctly (1-2-2-1 pattern)', () => { + const map = generatePointCrawlMap(42); + 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]; + + // First and last settlement connect to 1 wild + const firstSettlement = map.nodes.get(srcLayer.nodeIds[0]); + expect(firstSettlement?.childIds.length).toBe(1); + const lastSettlement = map.nodes.get(srcLayer.nodeIds[3]); + expect(lastSettlement?.childIds.length).toBe(1); + + // Middle two settlements connect to 2 wilds + for (let i = 1; i <= 2; i++) { + const midSettlement = map.nodes.get(srcLayer.nodeIds[i]); + expect(midSettlement?.childIds.length).toBe(2); + for (const childId of midSettlement!.childIds) { + expect(tgtLayer.nodeIds).toContain(childId); + } + } + } + }); + + it('should have all 3 wild nodes connect to End', () => { + const map = generatePointCrawlMap(42); + const lastWildLayer = map.layers[8]; + const endNode = map.nodes.get('node-9-0'); + + for (const wildId of lastWildLayer.nodeIds) { + const wildNode = map.nodes.get(wildId); + expect(wildNode?.childIds).toEqual([endNode!.id]); + } + }); + + it('should have all nodes reachable from Start and can reach End', () => { + const map = generatePointCrawlMap(123); + const startId = 'node-0-0'; + const endId = 'node-9-0'; + + for (const nodeId of map.nodes.keys()) { + if (nodeId === startId || nodeId === endId) continue; + expect(hasPath(map, startId, nodeId)).toBe(true); + expect(hasPath(map, nodeId, endId)).toBe(true); + } + }); + + it('should not have crossing edges in wild→wild transitions', () => { const map = generatePointCrawlMap(12345); + const wildToWildTransitions = [ + { src: 1, tgt: 2 }, + { src: 4, tgt: 5 }, + { src: 7, tgt: 8 }, + ]; - // Check each pair of consecutive layers for crossing edges - for (let i = 0; i < map.layers.length - 1; i++) { - const sourceLayer = map.layers[i]; - const targetLayer = map.layers[i + 1]; + for (const transition of wildToWildTransitions) { + const srcLayer = map.layers[transition.src]; + const tgtLayer = map.layers[transition.tgt]; - // Collect all edges as pairs of indices + // Collect edges as pairs of indices const edges: Array<{ srcIndex: number; tgtIndex: number }> = []; - for (let s = 0; s < sourceLayer.nodeIds.length; s++) { - const srcNode = map.nodes.get(sourceLayer.nodeIds[s]); + 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 = targetLayer.nodeIds.indexOf(tgtId); + const t = tgtLayer.nodeIds.indexOf(tgtId); edges.push({ srcIndex: s, tgtIndex: t }); } } - // Check for crossings: edge (s1, t1) and (s2, t2) cross if - // s1 < s2 but t1 > t2 (or vice versa) + // 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]; - // Skip if they share a source (not a crossing) if (s1 === s2) continue; + if (t1 === t2) continue; const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2); expect(crosses).toBe(false); @@ -152,4 +212,19 @@ describe('generatePointCrawlMap', () => { } } }); + + it('should assign encounters to nodes', () => { + const map = generatePointCrawlMap(456); + + let nodesWithEncounter = 0; + for (const node of map.nodes.values()) { + if (node.encounter) { + nodesWithEncounter++; + expect(node.encounter.name).toBeTruthy(); + expect(node.encounter.description).toBeTruthy(); + } + } + + expect(nodesWithEncounter).toBeGreaterThan(0); + }); });