Compare commits
2 Commits
17dca6303c
...
06a2236a1d
| Author | SHA1 | Date |
|---|---|---|
|
|
06a2236a1d | |
|
|
fe361dc877 |
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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: 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,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 1–2 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 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);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export { MapNodeType } from './types';
|
||||
export { MapNodeType, MapLayerType } from './types';
|
||||
export type { MapNode, MapLayer, PointCrawlMap } from './types';
|
||||
|
||||
export { generatePointCrawlMap } from './generator';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue