fuzzyworld

basin / files / src / model / heightmap.test.ts

heightmap.test.ts

Blame: the commit that last changed each line · file · history

a7bf123a 2026-07-01 Pavel Safronov1import { describe, it, expect } from 'vitest';
03c2cc12 2026-07-01 Pavel Safronov2import { generateDome, resolveDrainage, erode, heightAt, isOceanEdge, ALL_OCEAN } from './heightmap';
a7bf123a 2026-07-01 Pavel Safronov3import type { Edges, Heightmap } from './heightmap';
4import { simulateFlow } from './flow';
5import { mulberry32 } from '../rng';
6
7/** True iff water released at (x, y) escapes to an ocean edge. */
8function drains(hm: Heightmap, x: number, y: number): boolean {
9 return simulateFlow(hm, x, y).outflow.length > 0;
10}
11
12describe('generateDome', () => {
13 it('keeps every border cell at ocean height 0', () => {
14 const hm = generateDome(12, 9, mulberry32(42));
15 for (let x = 0; x < hm.width; x++) {
16 expect(heightAt(hm, x, 0)).toBe(0);
17 expect(heightAt(hm, x, hm.height - 1)).toBe(0);
18 }
19 for (let y = 0; y < hm.height; y++) {
20 expect(heightAt(hm, 0, y)).toBe(0);
21 expect(heightAt(hm, hm.width - 1, y)).toBe(0);
22 }
23 });
24
25 it('guarantees every interior cell drains to an edge (no trapped pits)', () => {
26 // The drainage invariant is the whole point of resolveDrainage: across many
27 // seeds and sizes, water released anywhere interior must reach an ocean edge.
28 for (const seed of [1, 2, 3, 99, 12345]) {
29 for (const size of [6, 10, 15]) {
30 const hm = generateDome(size, size, mulberry32(seed));
31 for (let y = 1; y < size - 1; y++) {
32 for (let x = 1; x < size - 1; x++) {
33 expect(drains(hm, x, y)).toBe(true);
34 }
35 }
36 }
37 }
38 });
39});
40
41describe('isOceanEdge', () => {
42 it('reports ocean sides and treats corners as ocean if either side is', () => {
43 const edges: Edges = { n: 'wall', e: 'ocean', s: 'ocean', w: 'wall' };
44 // 4x4 board. North (y=0) and west (x=0) are walls; east/south are ocean.
45 expect(isOceanEdge(edges, 2, 0, 4, 4)).toBe(false); // north wall
46 expect(isOceanEdge(edges, 0, 2, 4, 4)).toBe(false); // west wall
47 expect(isOceanEdge(edges, 3, 1, 4, 4)).toBe(true); // east ocean
48 expect(isOceanEdge(edges, 1, 3, 4, 4)).toBe(true); // south ocean
49 // NW corner touches two walls → not ocean; SW corner touches south ocean.
50 expect(isOceanEdge(edges, 0, 0, 4, 4)).toBe(false);
51 expect(isOceanEdge(edges, 0, 3, 4, 4)).toBe(true);
52 });
53});
54
55describe('generateDome with walls', () => {
56 const canyon: Edges = { n: 'wall', e: 'ocean', s: 'wall', w: 'ocean' };
57
58 it('keeps ocean edges at 0 and stands wall edges up as high ground', () => {
59 const hm = generateDome(12, 12, mulberry32(3), canyon);
60 for (let y = 0; y < 12; y++) {
61 expect(heightAt(hm, 0, y)).toBe(0); // west ocean
62 expect(heightAt(hm, 11, y)).toBe(0); // east ocean
63 }
64 // The wall rows (excluding the ocean-corner ends) rise above 0.
65 for (let x = 1; x < 11; x++) {
66 expect(heightAt(hm, x, 0)).toBeGreaterThan(0); // north wall
67 expect(heightAt(hm, x, 11)).toBeGreaterThan(0); // south wall
68 }
69 });
70
71 it('still drains every interior cell — but only to ocean sides', () => {
72 for (const seed of [1, 2, 3, 99, 12345]) {
73 const hm = generateDome(12, 12, mulberry32(seed), canyon);
74 for (let y = 1; y < 11; y++) {
75 for (let x = 1; x < 11; x++) {
76 const { outflow } = simulateFlow(hm, x, y);
77 expect(outflow.length).toBeGreaterThan(0);
78 for (const o of outflow) {
79 expect(isOceanEdge(hm.edges, o.x, o.y, 12, 12)).toBe(true);
80 }
81 }
82 }
83 }
84 });
85});
86
03c2cc12 2026-07-01 Pavel Safronov87describe('erode', () => {
88 it('strength 0 is a no-op that touches neither the terrain nor the rng', () => {
89 const hm = generateDome(10, 10, mulberry32(3));
90 const snapshot = Float32Array.from(hm.data);
91
92 const rng = mulberry32(99);
93 erode(hm, rng, 0);
94 expect(Array.from(hm.data)).toEqual(Array.from(snapshot)); // terrain untouched
95 // The rng stream is untouched, so the very next draw is a fresh first draw.
96 expect(rng()).toBe(mulberry32(99)());
97 });
98
99 it('reshapes the terrain and cuts channels below the pristine surface', () => {
100 const pristine = generateDome(16, 16, mulberry32(7));
101 const aged = generateDome(16, 16, mulberry32(7), ALL_OCEAN, 12);
102
103 // Something changed, and at least some interior cell was cut lower than it
104 // stood on the pristine dome (a groove), not merely silted higher everywhere.
105 let changed = 0;
106 let cutDeeper = 0;
107 for (let y = 1; y < 15; y++) {
108 for (let x = 1; x < 15; x++) {
109 const i = y * 16 + x;
110 if (aged.data[i] !== pristine.data[i]) changed++;
111 if (aged.data[i] < pristine.data[i] - 1e-3) cutDeeper++;
112 }
113 }
114 expect(changed).toBeGreaterThan(0);
115 expect(cutDeeper).toBeGreaterThan(0);
116 });
117
118 it('never lowers an ocean edge below 0', () => {
119 const hm = generateDome(12, 12, mulberry32(21), ALL_OCEAN, 15);
120 for (let x = 0; x < 12; x++) {
121 expect(heightAt(hm, x, 0)).toBe(0);
122 expect(heightAt(hm, x, 11)).toBe(0);
123 }
124 for (let y = 0; y < 12; y++) {
125 expect(heightAt(hm, 0, y)).toBe(0);
126 expect(heightAt(hm, 11, y)).toBe(0);
127 }
128 });
129});
130
a7bf123a 2026-07-01 Pavel Safronov131describe('resolveDrainage', () => {
132 it('raises a sealed interior pit until it drains', () => {
133 // A single deep pit walled off by higher ground; before filling it cannot
134 // drain, after filling it must.
135 const make = (): Heightmap => ({
136 width: 5,
137 height: 5,
138 edges: ALL_OCEAN,
139 data: Float32Array.from([
140 0, 0, 0, 0, 0,
141 0, 1, 1, 1, 0,
142 0, 1, 0, 1, 0,
143 0, 1, 1, 1, 0,
144 0, 0, 0, 0, 0,
145 ]),
146 });
147
148 const before = make();
149 expect(drains(before, 2, 2)).toBe(false);
150
151 const after = make();
152 resolveDrainage(after);
153 expect(heightAt(after, 2, 2)).toBeGreaterThan(0); // pit was raised
154 // Every interior cell now escapes.
155 for (let y = 1; y < 4; y++) {
156 for (let x = 1; x < 4; x++) expect(drains(after, x, y)).toBe(true);
157 }
158 });
159
160 it('leaves the ocean border untouched', () => {
161 const hm: Heightmap = {
162 width: 4,
163 height: 4,
164 edges: ALL_OCEAN,
165 data: Float32Array.from([
166 0, 0, 0, 0,
167 0, 1, 1, 0,
168 0, 1, 1, 0,
169 0, 0, 0, 0,
170 ]),
171 };
172 resolveDrainage(hm);
173 for (let x = 0; x < 4; x++) {
174 expect(heightAt(hm, x, 0)).toBe(0);
175 expect(heightAt(hm, x, 3)).toBe(0);
176 }
177 });
178});