refactor: map gen?

This commit is contained in:
hyper 2026-04-13 17:24:57 +08:00
parent 06a2236a1d
commit 5d1dc487f8
4 changed files with 230 additions and 122 deletions

View File

@ -1,21 +1,21 @@
import { Mulberry32RNG, type RNG } from '@/utils/rng'; import { Mulberry32RNG, type RNG } from '@/utils/rng';
import encounterDesertCsv, { type EncounterDesert } from '../data/encounterDesert.csv'; import encounterDesertCsv, { type EncounterDesert } from '../data/encounterDesert.csv';
import { MapNodeType, MapLayerType } from './types'; import { MapNodeType, MapLayerType } from './types';
import type { MapLayer, MapNode, PointCrawlMap } from './types'; import type { MapLayer, MapNode, PointCrawlMap, MapGenerationConfig } from './types';
/** Cache for parsed encounters by type */ /** Pre-indexed encounters by type */
const encountersByType = new Map<string, EncounterDesert[]>(); const encountersByType = buildEncounterIndex();
function indexEncounters(): void {
if (encountersByType.size > 0) return;
function buildEncounterIndex(): Map<string, EncounterDesert[]> {
const index = new Map<string, EncounterDesert[]>();
for (const encounter of encounterDesertCsv) { for (const encounter of encounterDesertCsv) {
const type = encounter.type; const type = encounter.type;
if (!encountersByType.has(type)) { if (!index.has(type)) {
encountersByType.set(type, []); index.set(type, []);
} }
encountersByType.get(type)!.push(encounter); index.get(type)!.push(encounter);
} }
return index;
} }
/** Map from MapNodeType to encounter type key */ /** Map from MapNodeType to encounter type key */
@ -28,23 +28,17 @@ const NODE_TYPE_TO_ENCOUNTER: Partial<Record<MapNodeType, string>> = {
[MapNodeType.Curio]: 'shelter', [MapNodeType.Curio]: 'shelter',
}; };
/** /** Default map generation configuration */
* Picks a random encounter for the given node type. const DEFAULT_CONFIG: MapGenerationConfig = {
* Returns undefined if no matching encounter exists. totalLayers: 10,
*/ wildLayerNodeCount: 3,
function pickEncounterForNode(type: MapNodeType, rng: RNG): EncounterDesert | undefined { settlementLayerNodeCount: 4,
indexEncounters(); wildNodeTypeWeights: {
const encounterType = NODE_TYPE_TO_ENCOUNTER[type]; [MapNodeType.Minion]: 50,
if (!encounterType) return undefined; [MapNodeType.Elite]: 25,
[MapNodeType.Event]: 25,
const pool = encountersByType.get(encounterType); },
if (!pool || pool.length === 0) return undefined; };
return pool[rng.nextInt(pool.length)];
}
/** Total number of layers */
const TOTAL_LAYERS = 10;
/** /**
* Layer structure definition. * Layer structure definition.
@ -63,6 +57,35 @@ const LAYER_STRUCTURE: Array<{ layerType: MapLayerType | 'start' | 'end'; count:
{ layerType: 'end', count: 1 }, { layerType: 'end', count: 1 },
]; ];
/**
* Fisher-Yates shuffle algorithm for unbiased random permutation.
* Mutates the array in place and returns it.
*/
function fisherYatesShuffle<T>(array: T[], rng: RNG): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = rng.nextInt(i + 1);
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
/**
* Picks a random encounter for the given node type.
* Returns undefined if no matching encounter exists.
*/
function pickEncounterForNode(type: MapNodeType, rng: RNG): EncounterDesert | undefined {
const encounterType = NODE_TYPE_TO_ENCOUNTER[type];
if (!encounterType) return undefined;
const pool = encountersByType.get(encounterType);
if (!pool || pool.length === 0) return undefined;
return pool[rng.nextInt(pool.length)];
}
/** Total number of layers */
const TOTAL_LAYERS = 10;
/** /**
* Generates a random point crawl map with layered directional graph. * Generates a random point crawl map with layered directional graph.
* *
@ -86,6 +109,7 @@ export function generatePointCrawlMap(seed?: number): PointCrawlMap {
for (let i = 0; i < TOTAL_LAYERS; i++) { for (let i = 0; i < TOTAL_LAYERS; i++) {
const structure = LAYER_STRUCTURE[i]; const structure = LAYER_STRUCTURE[i];
const nodeIds: string[] = []; const nodeIds: string[] = [];
const layerNodes: MapNode[] = [];
for (let j = 0; j < structure.count; j++) { for (let j = 0; j < structure.count; j++) {
const id = `node-${i}-${j}`; const id = `node-${i}-${j}`;
@ -100,19 +124,32 @@ export function generatePointCrawlMap(seed?: number): PointCrawlMap {
}; };
nodes.set(id, node); nodes.set(id, node);
nodeIds.push(id); nodeIds.push(id);
layerNodes.push(node);
} }
layers.push({ index: i, nodeIds, layerType: structure.layerType }); layers.push({ index: i, nodeIds, layerType: structure.layerType, nodes: layerNodes });
} }
// Step 2: generate edges between consecutive layers // Step 2: generate edges between consecutive layers
const parentIndex = new Map<string, string[]>();
for (let i = 0; i < TOTAL_LAYERS - 1; i++) { for (let i = 0; i < TOTAL_LAYERS - 1; i++) {
const sourceLayer = layers[i]; const sourceLayer = layers[i];
const targetLayer = layers[i + 1]; const targetLayer = layers[i + 1];
generateLayerEdges(sourceLayer, targetLayer, nodes, rng); generateLayerEdges(sourceLayer, targetLayer, nodes, rng);
// Build reverse index: for each target node, record its parents
for (const srcNode of sourceLayer.nodes) {
for (const childId of srcNode.childIds) {
if (!parentIndex.has(childId)) {
parentIndex.set(childId, []);
}
parentIndex.get(childId)!.push(srcNode.id);
}
}
} }
return { layers, nodes, seed: actualSeed }; return { layers, nodes, seed: actualSeed, parentIndex };
} }
/** /**
@ -140,35 +177,35 @@ function resolveNodeType(
} }
/** /**
* Picks a random type for a wild node. * Picks a random type for a wild node based on configured weights.
* minion: 50%, elite: 25%, event: 25% * Default: minion: 50%, elite: 25%, event: 25%
*/ */
function pickWildNodeType(rng: RNG): MapNodeType { function pickWildNodeType(rng: RNG): MapNodeType {
const roll = rng.nextInt(4); const weights = DEFAULT_CONFIG.wildNodeTypeWeights;
if (roll === 0) return MapNodeType.Elite; const roll = rng.nextInt(100);
if (roll === 1) return MapNodeType.Event;
return MapNodeType.Minion; if (roll < weights[MapNodeType.Minion]) return MapNodeType.Minion;
if (roll < weights[MapNodeType.Minion] + weights[MapNodeType.Elite]) return MapNodeType.Elite;
return MapNodeType.Event;
} }
/** /**
* Assigns settlement node types ensuring at least 1 of each: camp, shop, curio. * Assigns settlement node types ensuring at least 1 of each: camp, shop, curio.
* The 4th node is randomly chosen from the three. * The 4th node is randomly chosen from the three.
*/ */
function assignSettlementTypes(nodeIds: string[], nodes: Map<string, MapNode>, rng: RNG): void { function assignSettlementTypes(nodeIds: string[], nodes: MapNode[], rng: RNG): void {
// Shuffle node order to randomize which position gets which type // Shuffle node order to randomize which position gets which type
const shuffledIndices = [0, 1, 2, 3].sort(() => rng.next() - 0.5); const shuffledIndices = fisherYatesShuffle([0, 1, 2, 3], rng);
// Assign camp, shop, curio to first 3 shuffled positions // Assign camp, shop, curio to first 3 shuffled positions
const requiredTypes = [MapNodeType.Camp, MapNodeType.Shop, MapNodeType.Curio]; const requiredTypes = [MapNodeType.Camp, MapNodeType.Shop, MapNodeType.Curio];
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
const node = nodes.get(nodeIds[shuffledIndices[i]])!; nodes[shuffledIndices[i]].type = requiredTypes[i];
node.type = requiredTypes[i];
} }
// Assign random type to 4th position // Assign random type to 4th position
const randomType = requiredTypes[rng.nextInt(3)]; const randomType = requiredTypes[rng.nextInt(3)];
const node = nodes.get(nodeIds[shuffledIndices[3]])!; nodes[shuffledIndices[3]].type = randomType;
node.type = randomType;
} }
/** /**
@ -182,22 +219,22 @@ function generateLayerEdges(
): void { ): void {
// Assign settlement types when creating settlement layer // Assign settlement types when creating settlement layer
if (targetLayer.layerType === MapLayerType.Settlement) { if (targetLayer.layerType === MapLayerType.Settlement) {
assignSettlementTypes(targetLayer.nodeIds, nodes, rng); assignSettlementTypes(targetLayer.nodeIds, targetLayer.nodes, rng);
} }
const sourceType = sourceLayer.layerType; const sourceType = sourceLayer.layerType;
const targetType = targetLayer.layerType; const targetType = targetLayer.layerType;
if (sourceType === 'start' && targetType === MapLayerType.Wild) { if (sourceType === 'start' && targetType === MapLayerType.Wild) {
connectStartToWild(sourceLayer, targetLayer, nodes); connectStartToWild(sourceLayer, targetLayer);
} else if (sourceType === MapLayerType.Wild && targetType === MapLayerType.Wild) { } else if (sourceType === MapLayerType.Wild && targetType === MapLayerType.Wild) {
connectWildToWild(sourceLayer, targetLayer, nodes, rng); connectWildToWild(sourceLayer, targetLayer);
} else if (sourceType === MapLayerType.Wild && targetType === MapLayerType.Settlement) { } else if (sourceType === MapLayerType.Wild && targetType === MapLayerType.Settlement) {
connectWildToSettlement(sourceLayer, targetLayer, nodes, rng); connectWildToSettlement(sourceLayer, targetLayer, rng);
} else if (sourceType === MapLayerType.Settlement && targetType === MapLayerType.Wild) { } else if (sourceType === MapLayerType.Settlement && targetType === MapLayerType.Wild) {
connectSettlementToWild(sourceLayer, targetLayer, nodes, rng); connectSettlementToWild(sourceLayer, targetLayer, rng);
} else if (sourceType === MapLayerType.Wild && targetType === 'end') { } else if (sourceType === MapLayerType.Wild && targetType === 'end') {
connectWildToEnd(sourceLayer, targetLayer, nodes); connectWildToEnd(sourceLayer, targetLayer);
} }
} }
@ -206,10 +243,9 @@ function generateLayerEdges(
*/ */
function connectStartToWild( function connectStartToWild(
startLayer: MapLayer, startLayer: MapLayer,
wildLayer: MapLayer, wildLayer: MapLayer
nodes: Map<string, MapNode>
): void { ): void {
const startNode = nodes.get(startLayer.nodeIds[0])!; const startNode = startLayer.nodes[0];
startNode.childIds = [...wildLayer.nodeIds]; startNode.childIds = [...wildLayer.nodeIds];
} }
@ -219,15 +255,12 @@ function connectStartToWild(
*/ */
function connectWildToWild( function connectWildToWild(
sourceLayer: MapLayer, sourceLayer: MapLayer,
targetLayer: MapLayer, targetLayer: MapLayer
nodes: Map<string, MapNode>,
_rng: RNG
): void { ): void {
// Direct 1-to-1 mapping: wild[i] → wild[i] // Direct 1-to-1 mapping: wild[i] → wild[i]
// This guarantees no crossings since order is preserved // This guarantees no crossings since order is preserved
for (let i = 0; i < 3; i++) { for (let i = 0; i < sourceLayer.nodes.length; i++) {
const srcNode = nodes.get(sourceLayer.nodeIds[i])!; sourceLayer.nodes[i].childIds = [targetLayer.nodeIds[i]];
srcNode.childIds = [targetLayer.nodeIds[i]];
} }
} }
@ -239,53 +272,25 @@ function connectWildToWild(
function connectWildToSettlement( function connectWildToSettlement(
wildLayer: MapLayer, wildLayer: MapLayer,
settlementLayer: MapLayer, settlementLayer: MapLayer,
nodes: Map<string, MapNode>,
rng: RNG rng: RNG
): void { ): void {
// Strategy: create a mapping where each wild gets exactly 2 settlements // Non-crossing connection pattern: each wild connects to 2 adjacent settlements.
// and all 4 settlements are covered // Base pattern: w[0]→{s[0],s[1]}, w[1]→{s[1],s[2]}, w[2]→{s[2],s[3]}
// Example pattern: wild[0]→{s0,s1}, wild[1]→{s1,s2}, wild[2]→{s2,s3} // This creates a "chain" where middle settlements are shared.
// But we want randomness, so: //
// 1. Shuffle settlements // Variation: randomly flip to reverse pattern w[0]→{s[2],s[3]}, w[1]→{s[1],s[2]}, w[2]→{s[0],s[1]}
// 2. Assign first 3 settlements to wilds 0,1,2 (guarantee coverage) // Both patterns guarantee no crossings.
// 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); const reverse = rng.next() < 0.5;
// Initial assignment: each wild gets 1 unique settlement if (reverse) {
const assignments: Set<number>[] = [ wildLayer.nodes[0].childIds = [settlementLayer.nodeIds[2], settlementLayer.nodeIds[3]];
new Set([settlementOrder[0]]), wildLayer.nodes[1].childIds = [settlementLayer.nodeIds[1], settlementLayer.nodeIds[2]];
new Set([settlementOrder[1]]), wildLayer.nodes[2].childIds = [settlementLayer.nodeIds[0], settlementLayer.nodeIds[1]];
new Set([settlementOrder[2]]), } else {
]; wildLayer.nodes[0].childIds = [settlementLayer.nodeIds[0], settlementLayer.nodeIds[1]];
wildLayer.nodes[1].childIds = [settlementLayer.nodeIds[1], settlementLayer.nodeIds[2]];
// 4th settlement goes to a random wild wildLayer.nodes[2].childIds = [settlementLayer.nodeIds[2], settlementLayer.nodeIds[3]];
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]);
} }
} }
@ -294,33 +299,31 @@ function connectWildToSettlement(
* - First and last settlement connect to 1 wild each * - First and last settlement connect to 1 wild each
* - Middle two settlements connect to 2 wilds each * - Middle two settlements connect to 2 wilds each
* Total: 1 + 2 + 2 + 1 = 6 edges, 3 wild nodes to cover * Total: 1 + 2 + 2 + 1 = 6 edges, 3 wild nodes to cover
* *
* Uses a non-crossing pattern: settlements and wilds are connected * Uses a non-crossing pattern: settlements and wilds are connected
* in order to avoid edge crossings. * in order to avoid edge crossings.
*/ */
function connectSettlementToWild( function connectSettlementToWild(
settlementLayer: MapLayer, settlementLayer: MapLayer,
wildLayer: MapLayer, wildLayer: MapLayer,
nodes: Map<string, MapNode>,
rng: RNG rng: RNG
): void { ): void {
// Non-crossing pattern with circular shift option // Non-crossing pattern: s0→w0, s1→w0,w1, s2→w1,w2, s3→w2
// Base pattern: s0→w0, s1→w0,w1, s2→w1,w2, s3→w2 // Variation: randomly flip to reverse pattern s0→w2, s1→w1,w2, s2→w0,w1, s3→w0
// Apply circular shift to wilds for variety // Both patterns guarantee no crossings.
const shift = rng.nextInt(3);
const wildIdx = (i: number) => (i + shift) % 3;
const settlementAssignments: number[][] = [ const reverse = rng.next() < 0.5;
[wildIdx(0)],
[wildIdx(0), wildIdx(1)],
[wildIdx(1), wildIdx(2)],
[wildIdx(2)],
];
for (let i = 0; i < 4; i++) { if (reverse) {
const srcNode = nodes.get(settlementLayer.nodeIds[i])!; settlementLayer.nodes[0].childIds = [wildLayer.nodeIds[2]];
srcNode.childIds = settlementAssignments[i].map(idx => wildLayer.nodeIds[idx]); settlementLayer.nodes[1].childIds = [wildLayer.nodeIds[1], wildLayer.nodeIds[2]];
settlementLayer.nodes[2].childIds = [wildLayer.nodeIds[0], wildLayer.nodeIds[1]];
settlementLayer.nodes[3].childIds = [wildLayer.nodeIds[0]];
} else {
settlementLayer.nodes[0].childIds = [wildLayer.nodeIds[0]];
settlementLayer.nodes[1].childIds = [wildLayer.nodeIds[0], wildLayer.nodeIds[1]];
settlementLayer.nodes[2].childIds = [wildLayer.nodeIds[1], wildLayer.nodeIds[2]];
settlementLayer.nodes[3].childIds = [wildLayer.nodeIds[2]];
} }
} }
@ -329,13 +332,11 @@ function connectSettlementToWild(
*/ */
function connectWildToEnd( function connectWildToEnd(
wildLayer: MapLayer, wildLayer: MapLayer,
endLayer: MapLayer, endLayer: MapLayer
nodes: Map<string, MapNode>
): void { ): void {
const endNode = nodes.get(endLayer.nodeIds[0])!; const endNode = endLayer.nodes[0];
for (let i = 0; i < 3; i++) { for (let i = 0; i < wildLayer.nodes.length; i++) {
const srcNode = nodes.get(wildLayer.nodeIds[i])!; wildLayer.nodes[i].childIds = [endNode.id];
srcNode.childIds = [endNode.id];
} }
} }
@ -355,6 +356,17 @@ export function getChildren(map: PointCrawlMap, node: MapNode): MapNode[] {
/** Returns parent nodes of the given node (reverse lookup). */ /** Returns parent nodes of the given node (reverse lookup). */
export function getParents(map: PointCrawlMap, node: MapNode): MapNode[] { export function getParents(map: PointCrawlMap, node: MapNode): MapNode[] {
// Use pre-built reverse index if available
if (map.parentIndex) {
const parentIds = map.parentIndex.get(node.id);
if (!parentIds) return [];
return parentIds
.map(id => map.nodes.get(id))
.filter((n): n is MapNode => n !== undefined);
}
// Fallback: scan parent layer (legacy support)
const parents: MapNode[] = []; const parents: MapNode[] = [];
const parentLayer = map.layers[node.layerIndex - 1]; const parentLayer = map.layers[node.layerIndex - 1];
if (!parentLayer) return parents; if (!parentLayer) return parents;

View File

@ -1,5 +1,5 @@
export { MapNodeType, MapLayerType } from './types'; export { MapNodeType, MapLayerType } from './types';
export type { MapNode, MapLayer, PointCrawlMap } from './types'; export type { MapNode, MapLayer, PointCrawlMap, MapGenerationConfig } from './types';
export { generatePointCrawlMap } from './generator'; export { generatePointCrawlMap } from './generator';
export { getNode, getChildren, getParents, hasPath, findAllPaths } from './generator'; export { getNode, getChildren, getParents, hasPath, findAllPaths } from './generator';

View File

@ -49,6 +49,8 @@ export interface MapLayer {
nodeIds: string[]; nodeIds: string[];
/** Semantic type of the layer */ /** Semantic type of the layer */
layerType: MapLayerType | 'start' | 'end'; layerType: MapLayerType | 'start' | 'end';
/** Direct references to nodes in this layer (for performance) */
nodes: MapNode[];
} }
/** /**
@ -61,4 +63,24 @@ export interface PointCrawlMap {
nodes: Map<string, MapNode>; nodes: Map<string, MapNode>;
/** RNG seed used for generation (for reproducibility) */ /** RNG seed used for generation (for reproducibility) */
seed: number; seed: number;
/** Reverse index: nodeId → parent node IDs (for fast getParent lookup) */
parentIndex?: Map<string, string[]>;
}
/**
* Configuration for map generation.
*/
export interface MapGenerationConfig {
/** Total number of layers (including start and end) */
totalLayers: number;
/** Number of nodes in each wild layer */
wildLayerNodeCount: number;
/** Number of nodes in each settlement layer */
settlementLayerNodeCount: number;
/** Probability weights for wild node types (should sum to 100) */
wildNodeTypeWeights: {
[MapNodeType.Minion]: number;
[MapNodeType.Elite]: number;
[MapNodeType.Event]: number;
};
} }

View File

@ -213,6 +213,80 @@ describe('generatePointCrawlMap', () => {
} }
}); });
it('should not have crossing edges in wild→settlement transitions', () => {
const map = generatePointCrawlMap(12345);
const wildToSettlementTransitions = [
{ src: 2, tgt: 3 },
{ src: 5, tgt: 6 },
];
for (const transition of wildToSettlementTransitions) {
const srcLayer = map.layers[transition.src];
const tgtLayer = map.layers[transition.tgt];
// Collect edges as pairs of indices
const edges: Array<{ srcIndex: number; tgtIndex: number }> = [];
for (let s = 0; s < srcLayer.nodeIds.length; s++) {
const srcNode = map.nodes.get(srcLayer.nodeIds[s]);
for (const tgtId of srcNode!.childIds) {
const t = tgtLayer.nodeIds.indexOf(tgtId);
edges.push({ srcIndex: s, tgtIndex: t });
}
}
// Check for crossings
for (let e1 = 0; e1 < edges.length; e1++) {
for (let e2 = e1 + 1; e2 < edges.length; e2++) {
const { srcIndex: s1, tgtIndex: t1 } = edges[e1];
const { srcIndex: s2, tgtIndex: t2 } = edges[e2];
if (s1 === s2) continue;
if (t1 === t2) continue;
const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2);
expect(crosses).toBe(false);
}
}
}
});
it('should not have crossing edges in settlement→wild transitions', () => {
const map = generatePointCrawlMap(12345);
const settlementToWildTransitions = [
{ src: 3, tgt: 4 },
{ src: 6, tgt: 7 },
];
for (const transition of settlementToWildTransitions) {
const srcLayer = map.layers[transition.src];
const tgtLayer = map.layers[transition.tgt];
// Collect edges as pairs of indices
const edges: Array<{ srcIndex: number; tgtIndex: number }> = [];
for (let s = 0; s < srcLayer.nodeIds.length; s++) {
const srcNode = map.nodes.get(srcLayer.nodeIds[s]);
for (const tgtId of srcNode!.childIds) {
const t = tgtLayer.nodeIds.indexOf(tgtId);
edges.push({ srcIndex: s, tgtIndex: t });
}
}
// Check for crossings
for (let e1 = 0; e1 < edges.length; e1++) {
for (let e2 = e1 + 1; e2 < edges.length; e2++) {
const { srcIndex: s1, tgtIndex: t1 } = edges[e1];
const { srcIndex: s2, tgtIndex: t2 } = edges[e2];
if (s1 === s2) continue;
if (t1 === t2) continue;
const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2);
expect(crosses).toBe(false);
}
}
}
});
it('should assign encounters to nodes', () => { it('should assign encounters to nodes', () => {
const map = generatePointCrawlMap(456); const map = generatePointCrawlMap(456);