refactor: map gen?
This commit is contained in:
parent
06a2236a1d
commit
5d1dc487f8
|
|
@ -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]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -301,26 +306,24 @@ function connectWildToSettlement(
|
||||||
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 reverse = rng.next() < 0.5;
|
||||||
const wildIdx = (i: number) => (i + shift) % 3;
|
|
||||||
|
|
||||||
const settlementAssignments: number[][] = [
|
if (reverse) {
|
||||||
[wildIdx(0)],
|
settlementLayer.nodes[0].childIds = [wildLayer.nodeIds[2]];
|
||||||
[wildIdx(0), wildIdx(1)],
|
settlementLayer.nodes[1].childIds = [wildLayer.nodeIds[1], wildLayer.nodeIds[2]];
|
||||||
[wildIdx(1), wildIdx(2)],
|
settlementLayer.nodes[2].childIds = [wildLayer.nodeIds[0], wildLayer.nodeIds[1]];
|
||||||
[wildIdx(2)],
|
settlementLayer.nodes[3].childIds = [wildLayer.nodeIds[0]];
|
||||||
];
|
} else {
|
||||||
|
settlementLayer.nodes[0].childIds = [wildLayer.nodeIds[0]];
|
||||||
for (let i = 0; i < 4; i++) {
|
settlementLayer.nodes[1].childIds = [wildLayer.nodeIds[0], wildLayer.nodeIds[1]];
|
||||||
const srcNode = nodes.get(settlementLayer.nodeIds[i])!;
|
settlementLayer.nodes[2].childIds = [wildLayer.nodeIds[1], wildLayer.nodeIds[2]];
|
||||||
srcNode.childIds = settlementAssignments[i].map(idx => wildLayer.nodeIds[idx]);
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue