Compare commits

...

2 Commits

Author SHA1 Message Date
hypercross 06a2236a1d refactor: redesign map gen 2026-04-13 14:56:33 +08:00
hypercross fe361dc877 fix: avoid paths corssing each other 2026-04-13 12:56:39 +08:00
5 changed files with 465 additions and 157 deletions

View File

@ -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';

View File

@ -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<Record<MapNodeType, string>> = {
[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<Record<number, MapNodeType>> = {
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: 123455554321
* 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 12 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,86 +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 12 edges to target nodes
* - Every target node has at least one incoming edge (no dead ends)
* 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<string, 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);
// 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<string, MapNode>,
rng: RNG
): void {
const sourceBranches = new Map<string, number>(); // id → current outgoing count
const targetIncoming = new Map<string, number>(); // 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 12 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);
// Assign settlement types when creating settlement layer
if (targetLayer.layerType === MapLayerType.Settlement) {
assignSettlementTypes(targetLayer.nodeIds, nodes, rng);
}
const sourceType = sourceLayer.layerType;
const targetType = targetLayer.layerType;
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);
}
}
/**
* Start connects to all 3 wild nodes in the first wild layer.
*/
function connectStartToWild(
startLayer: MapLayer,
wildLayer: MapLayer,
nodes: Map<string, MapNode>
): void {
const startNode = nodes.get(startLayer.nodeIds[0])!;
startNode.childIds = [...wildLayer.nodeIds];
}
/**
* 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<string, MapNode>,
_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]];
}
}
/**
* 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<string, MapNode>,
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
const settlementOrder = [0, 1, 2, 3].sort(() => rng.next() - 0.5);
// Initial assignment: each wild gets 1 unique settlement
const assignments: Set<number>[] = [
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);
}
}
// --- 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);
// 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]);
}
}
/**
* 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<string, MapNode>,
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<string, MapNode>
): 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];
}
}

View File

@ -1,4 +1,4 @@
export { MapNodeType } from './types';
export { MapNodeType, MapLayerType } from './types';
export type { MapNode, MapLayer, PointCrawlMap } from './types';
export { generatePointCrawlMap } from './generator';

View File

@ -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';
}
/**

View File

@ -1,89 +1,230 @@
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', () => {
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]);
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 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];
expect(startNode?.childIds.length).toBe(3);
expect(startNode?.childIds).toEqual(expect.arrayContaining(wildLayer.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?.childIds.length).toBe(1);
const childLayer = map.layers[transition.tgt];
expect(childLayer.nodeIds).toContain(srcNode!.childIds[0]);
}
}
});
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 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 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 },
];
for (const transition of wildToWildTransitions) {
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);
// 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);
let nodesWithEncounter = 0;
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');
}
nodesWithEncounter++;
expect(node.encounter.name).toBeTruthy();
expect(node.encounter.description).toBeTruthy();
}
}
expect(nodesWithEncounter).toBeGreaterThan(0);
});
});