Compare commits
4 Commits
06a2236a1d
...
ef9557cba7
| Author | SHA1 | Date |
|---|---|---|
|
|
ef9557cba7 | |
|
|
1e5e4e9f7e | |
|
|
c30db2f8a4 | |
|
|
5d1dc487f8 |
|
|
@ -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,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;
|
||||
}[];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue