basin / files / src / model / flow.test.ts
flow.test.ts
| 1 | import { describe, it, expect } from 'vitest'; |
| 2 | import { simulateFlow, simulateFlows } from './flow'; |
| 3 | import type { Edges, Heightmap } from './heightmap'; |
| 4 | import { ALL_OCEAN, generateDome } from './heightmap'; |
| 5 | import { mulberry32 } from '../rng'; |
| 6 | |
| 7 | /** Build a heightmap from an explicit grid of rows. */ |
| 8 | function grid(rows: number[][], edges: Edges = ALL_OCEAN): Heightmap { |
| 9 | const height = rows.length; |
| 10 | const width = rows[0].length; |
| 11 | const data = new Float32Array(width * height); |
| 12 | for (let y = 0; y < height; y++) { |
| 13 | for (let x = 0; x < width; x++) data[y * width + x] = rows[y][x]; |
| 14 | } |
| 15 | return { width, height, data, edges }; |
| 16 | } |
| 17 | |
| 18 | /** Collect wet cells as "x,y" strings for order-independent comparison. */ |
| 19 | function wetCells(hm: Heightmap, wet: Uint8Array): Set<string> { |
| 20 | const s = new Set<string>(); |
| 21 | for (let y = 0; y < hm.height; y++) { |
| 22 | for (let x = 0; x < hm.width; x++) if (wet[y * hm.width + x]) s.add(`${x},${y}`); |
| 23 | } |
| 24 | return s; |
| 25 | } |
| 26 | |
| 27 | describe('simulateFlow', () => { |
| 28 | it('flows downhill along a ramp and stops at the low edge', () => { |
| 29 | // Heights decrease left→right; a spring at the top of the ramp wets the row. |
| 30 | const hm = grid([ |
| 31 | [5, 4, 3], |
| 32 | [5, 4, 3], |
| 33 | [5, 4, 3], |
| 34 | ]); |
| 35 | const { wet, outflow } = simulateFlow(hm, 0, 1); |
| 36 | expect(wetCells(hm, wet)).toEqual(new Set(['0,1', '1,1', '2,1'])); |
| 37 | // Both ends sit on the border → both are outflow cells. |
| 38 | expect(new Set(outflow.map((c) => `${c.x},${c.y}`))).toEqual(new Set(['0,1', '2,1'])); |
| 39 | }); |
| 40 | |
| 41 | it('is strictly decreasing: equal-height neighbours stay dry', () => { |
| 42 | // A single peak over a flat plateau. Water reaches the 4 edge-mids (all < 3) |
| 43 | // but cannot cross from one plateau cell to an equal-height corner. |
| 44 | const hm = grid([ |
| 45 | [2, 2, 2], |
| 46 | [2, 3, 2], |
| 47 | [2, 2, 2], |
| 48 | ]); |
| 49 | const { wet } = simulateFlow(hm, 1, 1); |
| 50 | expect(wetCells(hm, wet)).toEqual(new Set(['1,1', '1,0', '0,1', '2,1', '1,2'])); |
| 51 | }); |
| 52 | |
| 53 | it('does not flow at all from a local pit', () => { |
| 54 | const hm = grid([ |
| 55 | [9, 9, 9], |
| 56 | [9, 0, 9], |
| 57 | [9, 9, 9], |
| 58 | ]); |
| 59 | const { wet, outflow } = simulateFlow(hm, 1, 1); |
| 60 | expect(wetCells(hm, wet)).toEqual(new Set(['1,1'])); |
| 61 | expect(outflow).toHaveLength(0); |
| 62 | }); |
| 63 | |
| 64 | it('reports outflow as exactly the wet border cells', () => { |
| 65 | const hm = grid([ |
| 66 | [5, 4, 3], |
| 67 | [5, 4, 3], |
| 68 | [5, 4, 3], |
| 69 | ]); |
| 70 | const { wet, outflow } = simulateFlow(hm, 0, 1); |
| 71 | const expected = new Set<string>(); |
| 72 | for (let y = 0; y < hm.height; y++) { |
| 73 | for (let x = 0; x < hm.width; x++) { |
| 74 | const isEdge = x === 0 || y === 0 || x === hm.width - 1 || y === hm.height - 1; |
| 75 | if (isEdge && wet[y * hm.width + x]) expected.add(`${x},${y}`); |
| 76 | } |
| 77 | } |
| 78 | expect(new Set(outflow.map((c) => `${c.x},${c.y}`))).toEqual(expected); |
| 79 | }); |
| 80 | }); |
| 81 | |
| 82 | describe('simulateFlows', () => { |
| 83 | it('unions the wet sets: wet = OR of each spring flooded alone', () => { |
| 84 | const hm = generateDome(10, 10, mulberry32(7)); |
| 85 | const a = simulateFlow(hm, 3, 3); |
| 86 | const b = simulateFlow(hm, 6, 6); |
| 87 | const both = simulateFlows(hm, [ |
| 88 | { x: 3, y: 3 }, |
| 89 | { x: 6, y: 6 }, |
| 90 | ]); |
| 91 | for (let i = 0; i < both.wet.length; i++) { |
| 92 | expect(both.wet[i]).toBe(a.wet[i] | b.wet[i]); |
| 93 | } |
| 94 | }); |
| 95 | |
| 96 | it('does not exit through a wall side (water pools against it)', () => { |
| 97 | // Heights fall toward the north row; north is a wall, south is ocean. |
| 98 | const hm = grid( |
| 99 | [ |
| 100 | [3, 3, 3], |
| 101 | [4, 4, 4], |
| 102 | [5, 5, 5], |
| 103 | ], |
| 104 | { n: 'wall', e: 'ocean', s: 'ocean', w: 'ocean' }, |
| 105 | ); |
| 106 | const { wet, outflow } = simulateFlow(hm, 1, 2); |
| 107 | // Water reaches the north wall cell... |
| 108 | expect(wetCells(hm, wet)).toEqual(new Set(['1,2', '1,1', '1,0'])); |
| 109 | // ...but only the south ocean cell (the spring itself) counts as outflow. |
| 110 | expect(new Set(outflow.map((c) => `${c.x},${c.y}`))).toEqual(new Set(['1,2'])); |
| 111 | }); |
| 112 | |
| 113 | it('deduplicates coincident springs', () => { |
| 114 | const hm = grid([ |
| 115 | [5, 4, 3], |
| 116 | [5, 4, 3], |
| 117 | [5, 4, 3], |
| 118 | ]); |
| 119 | const one = simulateFlow(hm, 0, 1); |
| 120 | const dup = simulateFlows(hm, [ |
| 121 | { x: 0, y: 1 }, |
| 122 | { x: 0, y: 1 }, |
| 123 | ]); |
| 124 | expect(wetCells(hm, dup.wet)).toEqual(wetCells(hm, one.wet)); |
| 125 | }); |
| 126 | }); |