diff --git a/src/samples/slay-the-spire-like/map/generator.ts b/src/samples/slay-the-spire-like/map/generator.ts index 015367c..ed288dd 100644 --- a/src/samples/slay-the-spire-like/map/generator.ts +++ b/src/samples/slay-the-spire-like/map/generator.ts @@ -105,6 +105,20 @@ export function generatePointCrawlMap(seed?: number): PointCrawlMap { const layers: MapLayer[] = []; const nodes = new Map(); + // Pre-generate optimal types for wild layer pairs (layers 1-2, 4-5, 7-8) + const wildPairTypes = new Map(); + 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]; @@ -113,7 +127,7 @@ export function generatePointCrawlMap(seed?: number): PointCrawlMap { 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, @@ -159,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': @@ -167,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 @@ -189,6 +209,92 @@ function pickWildNodeType(rng: RNG): MapNodeType { 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(); + 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(); + 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. 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 459b7de..aa1e13f 100644 --- a/tests/samples/slay-the-spire-like/map/generator.test.ts +++ b/tests/samples/slay-the-spire-like/map/generator.test.ts @@ -301,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); + }); });