@@ -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 e nd)
* - Layer 3 = all events, layer 6 = sh elters, 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 → E nd
* - Wild layers have exactly 3 nodes (minion/ eli te/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 < 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 ) ;
// 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 < string , MapNode >
) : 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 < 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 ] ] ;
}
}
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 < 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
// 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 branche s ( 1 or 2), but limited by available targets
const maxBranches = Math . min ( 2 , availableInPartition . length ) ;
if ( maxBranches === 0 ) continue ;
// Initial assignment: each wild get s 1 unique settlement
const assignments : Set < number > [ ] = [
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, w e 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 convergenc e ( 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 < 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 ] ;
}
}