refactor: redesign map gen

This commit is contained in:
2026-04-13 14:56:33 +08:00
parent fe361dc877
commit 06a2236a1d
5 changed files with 429 additions and 217 deletions
@@ -1,150 +1,210 @@
import { describe, it, expect } from 'vitest';
import { generatePointCrawlMap } from '@/samples/slay-the-spire-like/map/generator';
import { MapNodeType } from '@/samples/slay-the-spire-like/map/types';
import { generatePointCrawlMap, hasPath } from '@/samples/slay-the-spire-like/map/generator';
import { MapNodeType, MapLayerType } from '@/samples/slay-the-spire-like/map/types';
describe('generatePointCrawlMap', () => {
it('should generate a map with 13 layers', () => {
it('should generate a map with 10 layers', () => {
const map = generatePointCrawlMap(123);
expect(map.layers.length).toBe(13);
expect(map.layers.length).toBe(10);
});
it('should have correct fixed layer types', () => {
it('should have correct layer structure', () => {
const map = generatePointCrawlMap(123);
const expectedStructure = [
'start',
MapLayerType.Wild,
MapLayerType.Wild,
MapLayerType.Settlement,
MapLayerType.Wild,
MapLayerType.Wild,
MapLayerType.Settlement,
MapLayerType.Wild,
MapLayerType.Wild,
'end',
];
for (let i = 0; i < expectedStructure.length; i++) {
expect(map.layers[i].layerType).toBe(expectedStructure[i]);
}
});
it('should have correct node counts per layer', () => {
const map = generatePointCrawlMap(123);
const expectedCounts = [1, 3, 3, 4, 3, 3, 4, 3, 3, 1];
for (let i = 0; i < expectedCounts.length; i++) {
expect(map.layers[i].nodeIds.length).toBe(expectedCounts[i]);
}
});
it('should have Start and End nodes with correct types', () => {
const map = generatePointCrawlMap(123);
const startNode = map.nodes.get('node-0-0');
const bossNode = map.nodes.get('node-12-0');
const endNode = map.nodes.get('node-9-0');
expect(startNode?.type).toBe(MapNodeType.Start);
expect(bossNode?.type).toBe(MapNodeType.Boss);
expect(endNode?.type).toBe(MapNodeType.End);
});
it('should assign encounters to nodes based on encounterDesert.csv', () => {
const map = generatePointCrawlMap(456);
it('should have wild layers with minion/elite/event types', () => {
const map = generatePointCrawlMap(123);
const wildLayerIndices = [1, 2, 4, 5, 7, 8];
const validWildTypes = new Set([MapNodeType.Minion, MapNodeType.Elite, MapNodeType.Event]);
// Check that nodes have encounters assigned
let combatWithEncounter = 0;
let eliteWithEncounter = 0;
let bossWithEncounter = 0;
let eventWithEncounter = 0;
let npcWithEncounter = 0;
let shelterWithEncounter = 0;
for (const node of map.nodes.values()) {
if (node.type === MapNodeType.Combat && node.encounter) {
combatWithEncounter++;
expect(node.encounter.name).toBeTruthy();
expect(node.encounter.description).toBeTruthy();
}
if (node.type === MapNodeType.Elite && node.encounter) {
eliteWithEncounter++;
expect(node.encounter.name).toBeTruthy();
expect(node.encounter.description).toBeTruthy();
}
if (node.type === MapNodeType.Boss && node.encounter) {
bossWithEncounter++;
expect(node.encounter.name).toBeTruthy();
expect(node.encounter.description).toBeTruthy();
}
if (node.type === MapNodeType.Event && node.encounter) {
eventWithEncounter++;
expect(node.encounter.name).toBeTruthy();
expect(node.encounter.description).toBeTruthy();
}
if (node.type === MapNodeType.NPC && node.encounter) {
npcWithEncounter++;
expect(node.encounter.name).toBeTruthy();
expect(node.encounter.description).toBeTruthy();
}
if (node.type === MapNodeType.Shelter && node.encounter) {
shelterWithEncounter++;
expect(node.encounter.name).toBeTruthy();
expect(node.encounter.description).toBeTruthy();
}
}
// Should have assigned at least some encounters
const totalWithEncounters =
combatWithEncounter +
eliteWithEncounter +
bossWithEncounter +
eventWithEncounter +
npcWithEncounter +
shelterWithEncounter;
expect(totalWithEncounters).toBeGreaterThan(0);
});
it('should use correct encounter types for each node type', () => {
const map = generatePointCrawlMap(789);
for (const node of map.nodes.values()) {
if (node.encounter) {
// Encounter should match node type conceptually
// Combat nodes should have enemy encounters, elites should have elite encounters, etc.
if (node.type === MapNodeType.Boss) {
expect(node.encounter.description).toContain('Boss');
}
for (const layerIdx of wildLayerIndices) {
const layer = map.layers[layerIdx];
for (const nodeId of layer.nodeIds) {
const node = map.nodes.get(nodeId);
expect(node).toBeDefined();
expect(validWildTypes.has(node!.type)).toBe(true);
}
}
});
it('should only connect nodes to nearby nodes to avoid crossing paths', () => {
it('should have settlement layers with at least 1 camp, 1 shop, 1 curio', () => {
const map = generatePointCrawlMap(123);
const settlementLayerIndices = [3, 6];
for (const layerIdx of settlementLayerIndices) {
const layer = map.layers[layerIdx];
const nodeTypes = layer.nodeIds.map(id => map.nodes.get(id)!.type);
expect(nodeTypes).toContain(MapNodeType.Camp);
expect(nodeTypes).toContain(MapNodeType.Shop);
expect(nodeTypes).toContain(MapNodeType.Curio);
expect(nodeTypes.length).toBe(4);
}
});
it('should have Start connected to all 3 wild nodes', () => {
const map = generatePointCrawlMap(42);
const startNode = map.nodes.get('node-0-0');
const wildLayer = map.layers[1];
// 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];
expect(startNode?.childIds.length).toBe(3);
expect(startNode?.childIds).toEqual(expect.arrayContaining(wildLayer.nodeIds));
});
for (const srcId of sourceLayer.nodeIds) {
it('should have each wild node connect to 1 wild node in wild→wild layers', () => {
const map = generatePointCrawlMap(42);
const wildToWildTransitions = [
{ src: 1, tgt: 2 },
{ src: 4, tgt: 5 },
{ src: 7, tgt: 8 },
];
for (const transition of wildToWildTransitions) {
const srcLayer = map.layers[transition.src];
for (const srcId of srcLayer.nodeIds) {
const srcNode = map.nodes.get(srcId);
expect(srcNode).toBeDefined();
expect(srcNode?.childIds.length).toBe(1);
const childLayer = map.layers[transition.tgt];
expect(childLayer.nodeIds).toContain(srcNode!.childIds[0]);
}
}
});
const srcIndex = sourceLayer.nodeIds.indexOf(srcId);
it('should have each wild node connect to 2 settlement nodes in wild→settlement layers', () => {
const map = generatePointCrawlMap(42);
const wildToSettlementTransitions = [
{ src: 2, tgt: 3 },
{ src: 5, tgt: 6 },
];
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);
for (const transition of wildToSettlementTransitions) {
const srcLayer = map.layers[transition.src];
for (const srcId of srcLayer.nodeIds) {
const srcNode = map.nodes.get(srcId);
expect(srcNode?.childIds.length).toBe(2);
const childLayer = map.layers[transition.tgt];
for (const childId of srcNode!.childIds) {
expect(childLayer.nodeIds).toContain(childId);
}
}
}
});
it('should not have crossing edges between consecutive layers', () => {
it('should have settlement nodes connect correctly (1-2-2-1 pattern)', () => {
const map = generatePointCrawlMap(42);
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];
// First and last settlement connect to 1 wild
const firstSettlement = map.nodes.get(srcLayer.nodeIds[0]);
expect(firstSettlement?.childIds.length).toBe(1);
const lastSettlement = map.nodes.get(srcLayer.nodeIds[3]);
expect(lastSettlement?.childIds.length).toBe(1);
// Middle two settlements connect to 2 wilds
for (let i = 1; i <= 2; i++) {
const midSettlement = map.nodes.get(srcLayer.nodeIds[i]);
expect(midSettlement?.childIds.length).toBe(2);
for (const childId of midSettlement!.childIds) {
expect(tgtLayer.nodeIds).toContain(childId);
}
}
}
});
it('should have all 3 wild nodes connect to End', () => {
const map = generatePointCrawlMap(42);
const lastWildLayer = map.layers[8];
const endNode = map.nodes.get('node-9-0');
for (const wildId of lastWildLayer.nodeIds) {
const wildNode = map.nodes.get(wildId);
expect(wildNode?.childIds).toEqual([endNode!.id]);
}
});
it('should have all nodes reachable from Start and can reach End', () => {
const map = generatePointCrawlMap(123);
const startId = 'node-0-0';
const endId = 'node-9-0';
for (const nodeId of map.nodes.keys()) {
if (nodeId === startId || nodeId === endId) continue;
expect(hasPath(map, startId, nodeId)).toBe(true);
expect(hasPath(map, nodeId, endId)).toBe(true);
}
});
it('should not have crossing edges in wild→wild transitions', () => {
const map = generatePointCrawlMap(12345);
const wildToWildTransitions = [
{ src: 1, tgt: 2 },
{ src: 4, tgt: 5 },
{ src: 7, tgt: 8 },
];
// 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];
for (const transition of wildToWildTransitions) {
const srcLayer = map.layers[transition.src];
const tgtLayer = map.layers[transition.tgt];
// Collect all edges as pairs of indices
// Collect 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 (let s = 0; s < srcLayer.nodeIds.length; s++) {
const srcNode = map.nodes.get(srcLayer.nodeIds[s]);
for (const tgtId of srcNode!.childIds) {
const t = targetLayer.nodeIds.indexOf(tgtId);
const t = tgtLayer.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)
// 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];
// Skip if they share a source (not a crossing)
if (s1 === s2) continue;
if (t1 === t2) continue;
const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2);
expect(crosses).toBe(false);
@@ -152,4 +212,19 @@ describe('generatePointCrawlMap', () => {
}
}
});
it('should assign encounters to nodes', () => {
const map = generatePointCrawlMap(456);
let nodesWithEncounter = 0;
for (const node of map.nodes.values()) {
if (node.encounter) {
nodesWithEncounter++;
expect(node.encounter.name).toBeTruthy();
expect(node.encounter.description).toBeTruthy();
}
}
expect(nodesWithEncounter).toBeGreaterThan(0);
});
});