basin / files / src / model / flow.test.ts
flow.test.ts
a7bf123a 2026-07-01 Pavel Safronov | 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 | }); |