From fe361dc877d1dc387c95434be14c993790cfd447 Mon Sep 17 00:00:00 2001 From: hypercross Date: Mon, 13 Apr 2026 12:46:11 +0800 Subject: [PATCH] fix: avoid paths corssing each other --- .../slay-the-spire-like/map/generator.ts | 94 ++++++++++++------- .../slay-the-spire-like/map/generator.test.ts | 66 +++++++++++++ 2 files changed, 128 insertions(+), 32 deletions(-) diff --git a/src/samples/slay-the-spire-like/map/generator.ts b/src/samples/slay-the-spire-like/map/generator.ts index ab75f53..d0afc3f 100644 --- a/src/samples/slay-the-spire-like/map/generator.ts +++ b/src/samples/slay-the-spire-like/map/generator.ts @@ -127,6 +127,12 @@ function pickLayerNodeType(_layerIndex: number, rng: RNG): MapNodeType { * Constraints: * - Each source node gets 1–2 edges to target nodes * - Every target node has at least one incoming edge (no dead ends) + * - Nodes only connect to nearby nodes (by index) to avoid crossing paths + * + * Strategy to avoid crossings: + * - Partition target nodes among source nodes (no overlap) + * - Each source can only connect to targets in its assigned partition + * - This guarantees no crossings by construction */ function generateLayerEdges( sourceIds: string[], @@ -139,47 +145,71 @@ function generateLayerEdges( for (const id of sourceIds) sourceBranches.set(id, 0); for (const id of targetIds) targetIncoming.set(id, 0); - // --- Pass 1: give each source 1–2 targets, prioritising uncovered targets --- + const pickRandom = (arr: string[]): string => arr[rng.nextInt(arr.length)]; + + // Partition targets among sources (no overlap) + // Each source gets a contiguous range of targets + const getTargetRange = (srcIndex: number): { start: number; end: number } => { + if (sourceIds.length === 1) { + return { start: 0, end: targetIds.length }; + } + + // Calculate proportional boundaries + const start = Math.floor((srcIndex * targetIds.length) / sourceIds.length); + const end = Math.floor(((srcIndex + 1) * targetIds.length) / sourceIds.length); + return { start, end }; + }; + + // --- Pass 1: give each source 1–2 targets within its partition --- const uncovered = new Set(targetIds); - for (const srcId of sourceIds) { - const branches = rng.nextInt(2) + 1; // 1 or 2 + for (let s = 0; s < sourceIds.length; s++) { + const srcId = sourceIds[s]; + const range = getTargetRange(s); + const availableInPartition = []; - for (let b = 0; b < branches; b++) { - if (uncovered.size > 0) { - // Pick a random uncovered target - const arr = Array.from(uncovered); - const idx = rng.nextInt(arr.length); - const tgtId = arr[idx]; - nodes.get(srcId)!.childIds.push(tgtId); - sourceBranches.set(srcId, sourceBranches.get(srcId)! + 1); - targetIncoming.set(tgtId, targetIncoming.get(tgtId)! + 1); - uncovered.delete(tgtId); - } else if (sourceBranches.get(srcId)! < 2) { - // All targets covered; pick any random target - const tgtId = targetIds[rng.nextInt(targetIds.length)]; - nodes.get(srcId)!.childIds.push(tgtId); - sourceBranches.set(srcId, sourceBranches.get(srcId)! + 1); - targetIncoming.set(tgtId, targetIncoming.get(tgtId)! + 1); - } + // Collect available targets in this partition + for (let t = range.start; t < range.end; t++) { + availableInPartition.push(targetIds[t]); + } + + // Decide branches (1 or 2), but limited by available targets + const maxBranches = Math.min(2, availableInPartition.length); + if (maxBranches === 0) continue; + + const branches = rng.nextInt(maxBranches) + 1; + + // Shuffle and pick + const shuffled = [...availableInPartition].sort(() => rng.next() - 0.5); + const selected = shuffled.slice(0, branches); + + for (const tgtId of selected) { + nodes.get(srcId)!.childIds.push(tgtId); + sourceBranches.set(srcId, sourceBranches.get(srcId)! + 1); + targetIncoming.set(tgtId, targetIncoming.get(tgtId)! + 1); + uncovered.delete(tgtId); } } // --- Pass 2: cover any remaining uncovered targets --- + // Since partitions don't overlap, we must assign to the owning source for (const tgtId of uncovered) { - // Find a source that still has room (< 2 branches) - const available = sourceIds.filter(id => sourceBranches.get(id)! < 2); - if (available.length > 0) { - const srcId = available[rng.nextInt(available.length)]; - nodes.get(srcId)!.childIds.push(tgtId); - sourceBranches.set(srcId, sourceBranches.get(srcId)! + 1); - targetIncoming.set(tgtId, targetIncoming.get(tgtId)! + 1); - } else { - // All sources are at 2 branches; force-add to a random source - const srcId = sourceIds[rng.nextInt(sourceIds.length)]; - nodes.get(srcId)!.childIds.push(tgtId); - targetIncoming.set(tgtId, targetIncoming.get(tgtId)! + 1); + const tgtIndex = targetIds.indexOf(tgtId); + + // Find which source partition this target belongs to + let owningSource = 0; + for (let s = 0; s < sourceIds.length; s++) { + const range = getTargetRange(s); + if (tgtIndex >= range.start && tgtIndex < range.end) { + owningSource = s; + break; + } } + + const srcId = sourceIds[owningSource]; + nodes.get(srcId)!.childIds.push(tgtId); + sourceBranches.set(srcId, sourceBranches.get(srcId)! + 1); + targetIncoming.set(tgtId, targetIncoming.get(tgtId)! + 1); } } diff --git a/tests/samples/slay-the-spire-like/map/generator.test.ts b/tests/samples/slay-the-spire-like/map/generator.test.ts index 70aee68..605f3da 100644 --- a/tests/samples/slay-the-spire-like/map/generator.test.ts +++ b/tests/samples/slay-the-spire-like/map/generator.test.ts @@ -86,4 +86,70 @@ describe('generatePointCrawlMap', () => { } } }); + + it('should only connect nodes to nearby nodes to avoid crossing paths', () => { + const map = generatePointCrawlMap(42); + + // Check each edge between consecutive layers + for (let i = 0; i < map.layers.length - 1; i++) { + const sourceLayer = map.layers[i]; + const targetLayer = map.layers[i + 1]; + + for (const srcId of sourceLayer.nodeIds) { + const srcNode = map.nodes.get(srcId); + expect(srcNode).toBeDefined(); + + const srcIndex = sourceLayer.nodeIds.indexOf(srcId); + + for (const tgtId of srcNode!.childIds) { + const tgtIndex = targetLayer.nodeIds.indexOf(tgtId); + + // Calculate the "scaled" source index to compare with target index + // This accounts for layers with different widths + const scaledSrcIndex = srcIndex * (targetLayer.nodeIds.length / sourceLayer.nodeIds.length); + const distance = Math.abs(tgtIndex - scaledSrcIndex); + + // The distance should be within a reasonable radius + // Allow some tolerance for edge cases when covering uncovered targets + const maxAllowedDistance = Math.max(2, Math.floor(targetLayer.nodeIds.length / 2)); + expect(distance).toBeLessThanOrEqual(maxAllowedDistance); + } + } + } + }); + + it('should not have crossing edges between consecutive layers', () => { + const map = generatePointCrawlMap(12345); + + // Check each pair of consecutive layers for crossing edges + for (let i = 0; i < map.layers.length - 1; i++) { + const sourceLayer = map.layers[i]; + const targetLayer = map.layers[i + 1]; + + // Collect all edges as pairs of indices + const edges: Array<{ srcIndex: number; tgtIndex: number }> = []; + for (let s = 0; s < sourceLayer.nodeIds.length; s++) { + const srcNode = map.nodes.get(sourceLayer.nodeIds[s]); + for (const tgtId of srcNode!.childIds) { + const t = targetLayer.nodeIds.indexOf(tgtId); + edges.push({ srcIndex: s, tgtIndex: t }); + } + } + + // Check for crossings: edge (s1, t1) and (s2, t2) cross if + // s1 < s2 but t1 > t2 (or vice versa) + 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]; + + // Skip if they share a source (not a crossing) + if (s1 === s2) continue; + + const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2); + expect(crosses).toBe(false); + } + } + } + }); });