Compare commits

..

4 Commits

Author SHA1 Message Date
hyper ef9557cba7 refactor: minimize repetitions 2026-04-13 21:18:06 +08:00
hyper 1e5e4e9f7e fix: fix map gen again 2026-04-13 20:06:23 +08:00
hyper c30db2f8a4 refactor: update encounter table design 2026-04-13 19:29:53 +08:00
hyper 5d1dc487f8 refactor: map gen? 2026-04-13 19:06:37 +08:00
6 changed files with 420 additions and 152 deletions

View File

@ -1,26 +1,35 @@
# npc encounter (2): offer random trades, could be merchants or healer or something
# shelter (2): offer consumable restock and heal
# enemy (10): minor enemies
# minion (10): minor enemies
# elite (4): dangerous enemies
# boss (1): boss enemy
# event (1): random dangerous event that requires reaction
# shop (2): merchant who sells different stuff
# camp (2): consumable restock and heal
# curio (8): random pickup of treasure or resources
type,name,description
'npc'|'enemy'|'elite'|'boss'|'event'|'shelter',string,string
enemy,仙人掌怪,概念:防+强化。【尖刺X】对攻击者造成X点伤害。
enemy,蛇,概念:攻+强化。给玩家塞入蛇毒牌消耗。一回合弃掉超过1张蛇毒时受到6伤害
enemy,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1直到弃掉一张该物品的牌。
enemy,枪手,概念单回高攻。【瞄准X】造成双倍伤害。受伤时失去等量【瞄准】。
enemy,风卷草,概念:防+强化。【滚动X】攻击时每消耗10点【滚动】造成等量伤害。
enemy,秃鹫,概念:攻+防。造成伤害后玩家获得秃鹫之眼(当你受到伤害时自动从手牌打出受到秃鹫的攻击)。
enemy,沙蝎,概念:攻+强化。【尾刺X】玩家回合结束时受到沙蝎的X点攻击。受伤时失去等量【尾刺】。
enemy,幼沙虫,概念:防+强化。每回合第一次受伤时玩家失去1点能量。
enemy,蜥蜴,概念:攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。
enemy,沙匪,概念:攻特化。洗牌时,将一个随机物品的牌全部弃掉。
'minion'|'elite'|'event'|'shop'|'camp'|'curio',string,string
minion,仙人掌怪,概念:防+强化。【尖刺X】对攻击者造成X点伤害。
minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌消耗。一回合弃掉超过1张蛇毒时受到6伤害
minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1直到弃掉一张该物品的牌。
minion,枪手,概念单回高攻。【瞄准X】造成双倍伤害。受伤时失去等量【瞄准】。
minion,风卷草,概念:防+强化。【滚动X】攻击时每消耗10点【滚动】造成等量伤害。
minion,秃鹫,概念:攻+防。造成伤害后玩家获得秃鹫之眼(当你受到伤害时自动从手牌打出受到秃鹫的攻击)。
minion,沙蝎,概念:攻+强化。【尾刺X】玩家回合结束时受到沙蝎的X点攻击。受伤时失去等量【尾刺】。
minion,幼沙虫,概念:防+强化。每回合第一次受伤时玩家失去1点能量。
minion,蜥蜴,概念:攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。
minion,沙匪,概念:攻特化。洗牌时,将一个随机物品的牌全部弃掉。
elite,风暴之灵,【风暴X】攻击时玩家获得1张静电。受伤时失去等量【风暴】。静电在手里时受【电击】伤害+1
elite,骑马枪手,【冲锋X】受到或造成的伤害翻倍并消耗等量的冲锋。
elite,沙虫王,召唤幼体沙虫每当玩家弃掉一张牌恢复1生命。
elite,沙漠守卫,召唤木乃伊会复活木乃伊2次。
boss,法老之灵,沙漠区域最终Boss。
npc,沙漠商人,商店:可以恢复生命、出售装备、附魔物品。
npc,绿洲篝火,篝火可以恢复生命、补充药水使用次数、获得下次战斗Buff。
npc,迷失的旅人,提供任务:完成特定地点遭遇以获得独特奖励。
event,海市蜃楼,随机遭遇可能获得宝藏或遭遇陷阱使用d6双阶段结构结算。
shop,沙漠商人,商店:可以恢复生命、出售装备、附魔物品。
shop,游牧商队,商队:出售稀有物品、移除牌组中一张牌。
camp,绿洲篝火,篝火可以恢复生命、补充药水使用次数、获得下次战斗Buff。
camp,岩洞庇护所,篝火:可以恢复生命、升级一张牌。
curio,沙中遗物,随机获得一件遗物或受到3点伤害。
curio,枯井,投入1能量可能获得药水或什么也没有。
curio,古代石碑,阅读碑文获得随机Buff直到下次战斗结束。
curio,沙暴残骸,搜索残骸随机获得一张物品牌或受到2点伤害。
curio,蜃景宝箱,打开宝箱50%获得宝藏50%为蜃景什么也没有。
curio,埋藏陶罐,挖掘:获得随机资源(金币、药水或遗物碎片)。
curio,风化雕像,献祭1生命获得一件随机遗物。
curio,绿洲碎片,小型绿洲恢复3生命并获得1张随机消耗品。
event,海市蜃楼,随机遭遇可能获得宝藏或遭遇陷阱使用d6双阶段结构结算。

1 # npc encounter (2): offer random trades, could be merchants or healer or something # minion (10): minor enemies
# shelter (2): offer consumable restock and heal
# enemy (10): minor enemies
2 # elite (4): dangerous enemies # elite (4): dangerous enemies
3 # boss (1): boss enemy # event (1): random dangerous event that requires reaction
4 # shop (2): merchant who sells different stuff
5 # camp (2): consumable restock and heal
6 # curio (8): random pickup of treasure or resources
7 type,name,description type,name,description
8 'npc'|'enemy'|'elite'|'boss'|'event'|'shelter',string,string 'minion'|'elite'|'event'|'shop'|'camp'|'curio',string,string
9 enemy,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。 minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。
10 enemy,蛇,概念:攻+强化。给玩家塞入蛇毒牌(消耗。一回合弃掉超过1张蛇毒时,受到6伤害)。 minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(消耗。一回合弃掉超过1张蛇毒时,受到6伤害)。
11 enemy,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。 minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。
12 enemy,枪手,概念:单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】。 minion,枪手,概念:单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】。
13 enemy,风卷草,概念:防+强化。【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。 minion,风卷草,概念:防+强化。【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。
14 enemy,秃鹫,概念:攻+防。造成伤害后玩家获得秃鹫之眼(当你受到伤害时自动从手牌打出受到秃鹫的攻击)。 minion,秃鹫,概念:攻+防。造成伤害后玩家获得秃鹫之眼(当你受到伤害时自动从手牌打出受到秃鹫的攻击)。
15 enemy,沙蝎,概念:攻+强化。【尾刺X】:玩家回合结束时受到沙蝎的X点攻击。受伤时失去等量【尾刺】。 minion,沙蝎,概念:攻+强化。【尾刺X】:玩家回合结束时受到沙蝎的X点攻击。受伤时失去等量【尾刺】。
16 enemy,幼沙虫,概念:防+强化。每回合第一次受伤时,玩家失去1点能量。 minion,幼沙虫,概念:防+强化。每回合第一次受伤时,玩家失去1点能量。
17 enemy,蜥蜴,概念:攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。 minion,蜥蜴,概念:攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。
18 enemy,沙匪,概念:攻特化。洗牌时,将一个随机物品的牌全部弃掉。 minion,沙匪,概念:攻特化。洗牌时,将一个随机物品的牌全部弃掉。
19 elite,风暴之灵,【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。(静电:在手里时受【电击】伤害+1) elite,风暴之灵,【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。(静电:在手里时受【电击】伤害+1)
20 elite,骑马枪手,【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。 elite,骑马枪手,【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。
21 elite,沙虫王,召唤幼体沙虫;每当玩家弃掉一张牌,恢复1生命。 elite,沙虫王,召唤幼体沙虫;每当玩家弃掉一张牌,恢复1生命。
22 elite,沙漠守卫,召唤木乃伊;会复活木乃伊2次。 elite,沙漠守卫,召唤木乃伊;会复活木乃伊2次。
23 boss,法老之灵,沙漠区域最终Boss。 shop,沙漠商人,商店:可以恢复生命、出售装备、附魔物品。
24 npc,沙漠商人,商店:可以恢复生命、出售装备、附魔物品。 shop,游牧商队,商队:出售稀有物品、移除牌组中一张牌。
25 npc,绿洲篝火,篝火:可以恢复生命、补充药水使用次数、获得下次战斗Buff。 camp,绿洲篝火,篝火:可以恢复生命、补充药水使用次数、获得下次战斗Buff。
26 npc,迷失的旅人,提供任务:完成特定地点遭遇以获得独特奖励。 camp,岩洞庇护所,篝火:可以恢复生命、升级一张牌。
27 event,海市蜃楼,随机遭遇:可能获得宝藏或遭遇陷阱,使用d6双阶段结构结算。 curio,沙中遗物,随机获得一件遗物或受到3点伤害。
28 curio,枯井,投入1能量:可能获得药水或什么也没有。
29 curio,古代石碑,阅读碑文:获得随机Buff直到下次战斗结束。
30 curio,沙暴残骸,搜索残骸:随机获得一张物品牌或受到2点伤害。
31 curio,蜃景宝箱,打开宝箱:50%获得宝藏,50%为蜃景什么也没有。
32 curio,埋藏陶罐,挖掘:获得随机资源(金币、药水或遗物碎片)。
33 curio,风化雕像,献祭1生命:获得一件随机遗物。
34 curio,绿洲碎片,小型绿洲:恢复3生命并获得1张随机消耗品。
35 event,海市蜃楼,随机遭遇:可能获得宝藏或遭遇陷阱,使用d6双阶段结构结算。

View File

@ -1,5 +1,5 @@
type EncounterDesertTable = readonly {
readonly type: "npc" | "enemy" | "elite" | "boss" | "event" | "shelter";
readonly type: "minion" | "elite" | "event" | "shop" | "camp" | "curio";
readonly name: string;
readonly description: string;
}[];

View File

@ -1,21 +1,21 @@
import { Mulberry32RNG, type RNG } from '@/utils/rng';
import encounterDesertCsv, { type EncounterDesert } from '../data/encounterDesert.csv';
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 */
const encountersByType = new Map<string, EncounterDesert[]>();
function indexEncounters(): void {
if (encountersByType.size > 0) return;
/** Pre-indexed encounters by type */
const encountersByType = buildEncounterIndex();
function buildEncounterIndex(): Map<string, EncounterDesert[]> {
const index = new Map<string, EncounterDesert[]>();
for (const encounter of encounterDesertCsv) {
const type = encounter.type;
if (!encountersByType.has(type)) {
encountersByType.set(type, []);
if (!index.has(type)) {
index.set(type, []);
}
encountersByType.get(type)!.push(encounter);
index.get(type)!.push(encounter);
}
return index;
}
/** Map from MapNodeType to encounter type key */
@ -28,23 +28,17 @@ const NODE_TYPE_TO_ENCOUNTER: Partial<Record<MapNodeType, string>> = {
[MapNodeType.Curio]: 'shelter',
};
/**
* Picks a random encounter for the given node type.
* Returns undefined if no matching encounter exists.
*/
function pickEncounterForNode(type: MapNodeType, rng: RNG): EncounterDesert | undefined {
indexEncounters();
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;
/** Default map generation configuration */
const DEFAULT_CONFIG: MapGenerationConfig = {
totalLayers: 10,
wildLayerNodeCount: 3,
settlementLayerNodeCount: 4,
wildNodeTypeWeights: {
[MapNodeType.Minion]: 50,
[MapNodeType.Elite]: 25,
[MapNodeType.Event]: 25,
},
};
/**
* Layer structure definition.
@ -63,6 +57,35 @@ const LAYER_STRUCTURE: Array<{ layerType: MapLayerType | 'start' | 'end'; count:
{ 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.
*
@ -82,14 +105,29 @@ export function generatePointCrawlMap(seed?: number): PointCrawlMap {
const layers: MapLayer[] = [];
const nodes = new Map<string, MapNode>();
// Pre-generate optimal types for wild layer pairs (layers 1-2, 4-5, 7-8)
const wildPairTypes = new Map<number, MapNodeType[]>();
const wildPairs = [
{ layer1: 1, layer2: 2 },
{ layer1: 4, layer2: 5 },
{ layer1: 7, layer2: 8 },
];
for (const pair of wildPairs) {
const [types1, types2] = generateOptimalWildPair(rng);
wildPairTypes.set(pair.layer1, types1);
wildPairTypes.set(pair.layer2, types2);
}
// Step 1: create layers and nodes
for (let i = 0; i < TOTAL_LAYERS; i++) {
const structure = LAYER_STRUCTURE[i];
const nodeIds: string[] = [];
const layerNodes: MapNode[] = [];
for (let j = 0; j < structure.count; j++) {
const id = `node-${i}-${j}`;
const type = resolveNodeType(structure.layerType, j, structure.count, rng);
const type = resolveNodeType(structure.layerType, j, structure.count, rng, wildPairTypes.get(i), j);
const encounter = pickEncounterForNode(type, rng);
const node: MapNode = {
id,
@ -100,19 +138,32 @@ export function generatePointCrawlMap(seed?: number): PointCrawlMap {
};
nodes.set(id, node);
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
const parentIndex = new Map<string, string[]>();
for (let i = 0; i < TOTAL_LAYERS - 1; i++) {
const sourceLayer = layers[i];
const targetLayer = layers[i + 1];
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 };
}
/**
@ -122,7 +173,9 @@ function resolveNodeType(
layerType: MapLayerType | 'start' | 'end',
_nodeIndex: number,
_layerCount: number,
rng: RNG
rng: RNG,
preGeneratedTypes?: MapNodeType[],
nodeIndex?: number
): MapNodeType {
switch (layerType) {
case 'start':
@ -130,6 +183,10 @@ function resolveNodeType(
case 'end':
return MapNodeType.End;
case MapLayerType.Wild:
// Use pre-generated types if available (from optimal pair generation)
if (preGeneratedTypes && nodeIndex !== undefined) {
return preGeneratedTypes[nodeIndex];
}
return pickWildNodeType(rng);
case MapLayerType.Settlement:
// This will be overridden by assignSettlementTypes
@ -140,35 +197,121 @@ function resolveNodeType(
}
/**
* Picks a random type for a wild node.
* minion: 50%, elite: 25%, event: 25%
* Picks a random type for a wild node based on configured weights.
* Default: 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;
const weights = DEFAULT_CONFIG.wildNodeTypeWeights;
const roll = rng.nextInt(100);
if (roll < weights[MapNodeType.Minion]) return MapNodeType.Minion;
if (roll < weights[MapNodeType.Minion] + weights[MapNodeType.Elite]) return MapNodeType.Elite;
return MapNodeType.Event;
}
/**
* Generates random types for a pair of wild layers (3 nodes each).
* Returns two arrays of 3 node types each.
*/
function generateWildPair(rng: RNG): [MapNodeType[], MapNodeType[]] {
const layer1Types: MapNodeType[] = [];
const layer2Types: MapNodeType[] = [];
for (let i = 0; i < 3; i++) {
layer1Types.push(pickWildNodeType(rng));
layer2Types.push(pickWildNodeType(rng));
}
return [layer1Types, layer2Types];
}
/**
* Counts repetitions in a wild layer pair.
* - sameLayer: number of duplicate types within each layer
* - adjacent: number of positions where layer1[i] === layer2[i]
* - total: sum of both
*/
function countRepetitions(
layer1Types: MapNodeType[],
layer2Types: MapNodeType[]
): { sameLayer: number; adjacent: number; total: number } {
// Count same-layer repetitions
let sameLayer = 0;
// For layer 1: count duplicates
const layer1Count = new Map<MapNodeType, number>();
for (const type of layer1Types) {
layer1Count.set(type, (layer1Count.get(type) || 0) + 1);
}
for (const count of layer1Count.values()) {
if (count > 1) sameLayer += count - 1;
}
// For layer 2: count duplicates
const layer2Count = new Map<MapNodeType, number>();
for (const type of layer2Types) {
layer2Count.set(type, (layer2Count.get(type) || 0) + 1);
}
for (const count of layer2Count.values()) {
if (count > 1) sameLayer += count - 1;
}
// Count adjacent repetitions (wild[i] → wild[i] connection)
let adjacent = 0;
for (let i = 0; i < 3; i++) {
if (layer1Types[i] === layer2Types[i]) {
adjacent++;
}
}
return { sameLayer, adjacent, total: sameLayer + adjacent };
}
/**
* Generates optimal wild layer pair by trying multiple attempts and selecting the one with fewest repetitions.
*/
function generateOptimalWildPair(
rng: RNG,
attempts = 3
): [MapNodeType[], MapNodeType[]] {
let bestLayer1: MapNodeType[] = [];
let bestLayer2: MapNodeType[] = [];
let bestScore = Infinity;
for (let i = 0; i < attempts; i++) {
const [layer1, layer2] = generateWildPair(rng);
const score = countRepetitions(layer1, layer2);
if (score.total < bestScore) {
bestScore = score.total;
bestLayer1 = layer1;
bestLayer2 = layer2;
}
// Perfect score is 0, no need to continue
if (bestScore === 0) break;
}
return [bestLayer1, bestLayer2];
}
/**
* 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 {
function assignSettlementTypes(nodeIds: string[], nodes: 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);
const shuffledIndices = fisherYatesShuffle([0, 1, 2, 3], rng);
// 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];
nodes[shuffledIndices[i]].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;
nodes[shuffledIndices[3]].type = randomType;
}
/**
@ -182,22 +325,22 @@ function generateLayerEdges(
): void {
// Assign settlement types when creating settlement layer
if (targetLayer.layerType === MapLayerType.Settlement) {
assignSettlementTypes(targetLayer.nodeIds, nodes, rng);
assignSettlementTypes(targetLayer.nodeIds, targetLayer.nodes, rng);
}
const sourceType = sourceLayer.layerType;
const targetType = targetLayer.layerType;
if (sourceType === 'start' && targetType === MapLayerType.Wild) {
connectStartToWild(sourceLayer, targetLayer, nodes);
connectStartToWild(sourceLayer, targetLayer);
} else if (sourceType === MapLayerType.Wild && targetType === MapLayerType.Wild) {
connectWildToWild(sourceLayer, targetLayer, nodes, rng);
connectWildToWild(sourceLayer, targetLayer);
} 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) {
connectSettlementToWild(sourceLayer, targetLayer, nodes, rng);
connectSettlementToWild(sourceLayer, targetLayer, rng);
} else if (sourceType === MapLayerType.Wild && targetType === 'end') {
connectWildToEnd(sourceLayer, targetLayer, nodes);
connectWildToEnd(sourceLayer, targetLayer);
}
}
@ -206,10 +349,9 @@ function generateLayerEdges(
*/
function connectStartToWild(
startLayer: MapLayer,
wildLayer: MapLayer,
nodes: Map<string, MapNode>
wildLayer: MapLayer
): void {
const startNode = nodes.get(startLayer.nodeIds[0])!;
const startNode = startLayer.nodes[0];
startNode.childIds = [...wildLayer.nodeIds];
}
@ -219,15 +361,12 @@ function connectStartToWild(
*/
function connectWildToWild(
sourceLayer: MapLayer,
targetLayer: MapLayer,
nodes: Map<string, MapNode>,
_rng: RNG
targetLayer: MapLayer
): 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 i = 0; i < sourceLayer.nodes.length; i++) {
sourceLayer.nodes[i].childIds = [targetLayer.nodeIds[i]];
}
}
@ -239,54 +378,17 @@ function connectWildToWild(
function connectWildToSettlement(
wildLayer: MapLayer,
settlementLayer: MapLayer,
nodes: Map<string, MapNode>,
rng: RNG
_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]);
}
// Non-crossing connection pattern: each wild connects to 2 adjacent settlements.
// Pattern: w[0]→{s[0],s[1]}, w[1]→{s[1],s[2]}, w[2]→{s[2],s[3]}
// This creates a "chain" where middle settlements are shared.
// This pattern guarantees no crossings because target indices are always
// non-decreasing when sorted by source indices.
wildLayer.nodes[0].childIds = [settlementLayer.nodeIds[0], settlementLayer.nodeIds[1]];
wildLayer.nodes[1].childIds = [settlementLayer.nodeIds[1], settlementLayer.nodeIds[2]];
wildLayer.nodes[2].childIds = [settlementLayer.nodeIds[2], settlementLayer.nodeIds[3]];
}
/**
@ -294,34 +396,23 @@ function connectWildToSettlement(
* - 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
_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
// Non-crossing pattern: s0→w0, s1→w0,w1, s2→w1,w2, s3→w2
// This pattern guarantees no crossings because when edges are sorted by
// source index, the minimum target index is non-decreasing.
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]);
}
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 +420,11 @@ function connectSettlementToWild(
*/
function connectWildToEnd(
wildLayer: MapLayer,
endLayer: MapLayer,
nodes: Map<string, MapNode>
endLayer: MapLayer
): 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];
const endNode = endLayer.nodes[0];
for (let i = 0; i < wildLayer.nodes.length; i++) {
wildLayer.nodes[i].childIds = [endNode.id];
}
}
@ -355,6 +444,17 @@ export function getChildren(map: PointCrawlMap, node: MapNode): MapNode[] {
/** Returns parent nodes of the given node (reverse lookup). */
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 parentLayer = map.layers[node.layerIndex - 1];
if (!parentLayer) return parents;

View File

@ -1,5 +1,5 @@
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 { getNode, getChildren, getParents, hasPath, findAllPaths } from './generator';

View File

@ -49,6 +49,8 @@ export interface MapLayer {
nodeIds: string[];
/** Semantic type of the layer */
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>;
/** RNG seed used for generation (for reproducibility) */
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', () => {
const map = generatePointCrawlMap(456);
@ -227,4 +301,67 @@ describe('generatePointCrawlMap', () => {
expect(nodesWithEncounter).toBeGreaterThan(0);
});
it('should minimize same-layer repetitions in wild layer pairs', () => {
// Test that wild layers in pairs (1-2, 4-5, 7-8) have minimal duplicate types within each layer
const map = generatePointCrawlMap(12345);
const wildPairIndices = [
[1, 2],
[4, 5],
[7, 8],
];
for (const [layer1Idx, layer2Idx] of wildPairIndices) {
const layer1 = map.layers[layer1Idx];
const layer2 = map.layers[layer2Idx];
// Count repetitions in layer 1
const layer1Types = layer1.nodeIds.map(id => map.nodes.get(id)!.type);
const layer1Unique = new Set(layer1Types).size;
const layer1Repetitions = layer1Types.length - layer1Unique;
// Count repetitions in layer 2
const layer2Types = layer2.nodeIds.map(id => map.nodes.get(id)!.type);
const layer2Unique = new Set(layer2Types).size;
const layer2Repetitions = layer2Types.length - layer2Unique;
// With optimal selection, we expect fewer repetitions than pure random
// On average, random would have ~1.5 repetitions per 3-node layer
// With 3 attempts, we should typically get 0-1 repetitions
expect(layer1Repetitions + layer2Repetitions).toBeLessThanOrEqual(2);
}
});
it('should minimize adjacent repetitions in wild→wild connections', () => {
// Test that wild nodes connected by wild→wild edges have different types
const map = generatePointCrawlMap(12345);
const wildToWildPairs = [
{ src: 1, tgt: 2 },
{ src: 4, tgt: 5 },
{ src: 7, tgt: 8 },
];
let totalAdjacentRepetitions = 0;
for (const pair of wildToWildPairs) {
const srcLayer = map.layers[pair.src];
const tgtLayer = map.layers[pair.tgt];
// Each wild node connects to exactly 1 wild node in next layer (1-to-1)
for (let i = 0; i < srcLayer.nodeIds.length; i++) {
const srcNode = map.nodes.get(srcLayer.nodeIds[i])!;
const tgtId = srcNode.childIds[0];
const tgtNode = map.nodes.get(tgtId)!;
if (srcNode.type === tgtNode.type) {
totalAdjacentRepetitions++;
}
}
}
// With 3 wild pairs and 3 nodes each, that's 9 connections total
// Random would have ~3 repetitions (1/3 chance per connection)
// With optimal selection of 3 attempts, should be much lower (0-2)
expect(totalAdjacentRepetitions).toBeLessThanOrEqual(3);
});
});