blockyard 0.0.9 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +359 -1
  2. package/README.md +48 -27
  3. package/SECURITY.md +2 -2
  4. package/bin/blockyard.js +2 -1
  5. package/docs/API.md +17 -15
  6. package/docs/ARCHITECTURE.md +128 -10
  7. package/docs/CONFIGURATION.md +39 -30
  8. package/docs/DEFECTS.md +4 -1
  9. package/docs/GETTING-STARTED.md +18 -8
  10. package/docs/INSTALL.md +97 -37
  11. package/docs/MEASUREMENTS.md +147 -0
  12. package/docs/PLAN-SCORCHED-YARD.md +456 -0
  13. package/docs/PLAN-SKIES.md +142 -0
  14. package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
  15. package/docs/SECURITY.md +58 -22
  16. package/docs/TROUBLESHOOTING.md +44 -5
  17. package/docs/USER-GUIDE.md +505 -35
  18. package/package.json +4 -2
  19. package/public/404.html +1 -1
  20. package/public/css/app.css +393 -82
  21. package/public/donate-qr.png +0 -0
  22. package/public/index.html +353 -109
  23. package/public/js/agents.js +228 -51
  24. package/public/js/app.js +131 -16
  25. package/public/js/blockanoid.js +15 -7
  26. package/public/js/blockout.js +15 -7
  27. package/public/js/blockscene3d.js +230 -38
  28. package/public/js/charts.js +21 -21
  29. package/public/js/depthchart.js +31 -27
  30. package/public/js/details3d.js +1481 -73
  31. package/public/js/doom.js +31 -0
  32. package/public/js/dosaudio.js +48 -0
  33. package/public/js/dosgame.js +389 -0
  34. package/public/js/dosio.js +186 -0
  35. package/public/js/dospc.js +1353 -0
  36. package/public/js/dosworker.js +196 -0
  37. package/public/js/explorer.js +7 -1
  38. package/public/js/livingsky.js +494 -0
  39. package/public/js/login.js +8 -2
  40. package/public/js/markets.js +46 -8
  41. package/public/js/mining.js +314 -36
  42. package/public/js/panels.js +41 -28
  43. package/public/js/pricechart.js +14 -13
  44. package/public/js/quake.js +20 -0
  45. package/public/js/safenext.js +14 -0
  46. package/public/js/scorched.js +1051 -0
  47. package/public/js/scorchedai.js +227 -0
  48. package/public/js/scorchedair.js +286 -0
  49. package/public/js/scorchedfx.js +376 -0
  50. package/public/js/scorchedshop.js +105 -0
  51. package/public/js/scorchedwind.js +69 -0
  52. package/public/js/scorchedyard.js +1338 -0
  53. package/public/js/settings.js +368 -100
  54. package/public/js/soundcard.js +459 -0
  55. package/public/js/tetrust.js +15 -6
  56. package/public/js/tetsound.js +35 -5
  57. package/public/js/theme.js +235 -0
  58. package/public/js/wolf3d.js +22 -0
  59. package/public/js/x86.js +1978 -0
  60. package/scripts/check.js +46 -0
  61. package/scripts/donate-qr.py +12 -9
  62. package/scripts/dos-bench.js +56 -0
  63. package/scripts/index-build.js +9 -2
  64. package/scripts/pool-map.js +152 -36
  65. package/scripts/setup.js +142 -22
  66. package/scripts/shots.mjs +27 -0
  67. package/scripts/smoke.sh +7 -6
  68. package/scripts/tls.js +31 -0
  69. package/scripts/ui.js +4 -2
  70. package/server/auth/sessions.js +33 -13
  71. package/server/chain/blockfile.js +64 -5
  72. package/server/chain/index/build.js +451 -58
  73. package/server/chain/index/heights.js +29 -3
  74. package/server/chain/index/live.js +13 -7
  75. package/server/chain/index/rows.js +6 -1
  76. package/server/chain/index/store.js +28 -5
  77. package/server/chain/index/worker.js +23 -11
  78. package/server/collect/logparse.js +65 -18
  79. package/server/collect/markets.js +76 -7
  80. package/server/collect/mining.js +32 -0
  81. package/server/collect/monitor.js +54 -12
  82. package/server/collect/network.js +305 -0
  83. package/server/config.js +53 -22
  84. package/server/http/api.js +119 -18
  85. package/server/http/games.js +77 -0
  86. package/server/http/server.js +30 -5
  87. package/server/http/sse.js +53 -7
  88. package/server/main.js +66 -11
  89. package/server/rpc/allowlist.js +26 -0
  90. package/server/rpc/client.js +30 -2
  91. package/server/store/audit.js +6 -1
  92. package/server/store/history.js +19 -3
  93. package/server/store/ledger.js +15 -4
  94. package/server/tls/selfsigned.js +160 -0
  95. package/systemd/blockyard.service +41 -8
  96. package/docs/PRIVATE-LEADERBOARD.md +0 -230
  97. package/docs/STATE-2026-09-09.md +0 -200
@@ -0,0 +1,227 @@
1
+ // SCORCHED YARD, the computer players (docs/PLAN-SCORCHED-YARD.md §6; the manual's own words
2
+ // for each are quoted there). Each personality is a function of the game and its tank that
3
+ // answers `{ angle, power, weapon }`; the screen applies it through `aim()` and `fire()` like a
4
+ // key press, so the AI shares nothing with the DOM and a test can hold each one to its habit.
5
+ //
6
+ // They plan with `simulateShot`, the rules' own physics run without side effects: the walls, the
7
+ // wind, the dirt and the tanks in the way are all in it, so a Poolshark's rebound and a
8
+ // Spoiler's "perfect shot almost every time" are the same search over different angles.
9
+ import { WEAPON_ORDER, WEAPONS, ITEMS, alive, simulateShot, buy, useItem, COLS, TANK_W, MAX_HEALTH } from './scorched.js';
10
+ import { SHOP } from './scorchedshop.js';
11
+
12
+ const centreX = (t) => t.x + TANK_W / 2;
13
+ const toward = (tank, target, a) => (centreX(target) > centreX(tank) ? a : 180 - a);
14
+
15
+ /** The nearest living tank other than this one. */
16
+ export function nearest(g, tank) {
17
+ return alive(g).filter((t) => t !== tank).sort((a, b) => Math.abs(a.x - tank.x) - Math.abs(b.x - tank.x))[0] ?? null;
18
+ }
19
+ /** The tank with the highest score (ties by health), other than this one. */
20
+ export function leaderOf(g, tank) {
21
+ return alive(g).filter((t) => t !== tank).sort((a, b) => b.score - a.score || b.health - a.health)[0] ?? null;
22
+ }
23
+ /** The weakest living tank other than this one. */
24
+ export function weakest(g, tank) {
25
+ return alive(g).filter((t) => t !== tank).sort((a, b) => a.health - b.health)[0] ?? null;
26
+ }
27
+
28
+ /**
29
+ * How far a simulated shot ends from the target: the horizontal miss, with a hit on the target
30
+ * counted as zero and a hit on someone else, or a lost shell, as a poor result.
31
+ */
32
+ function missOf(g, tank, target, angle, power) {
33
+ const r = simulateShot(g, tank, angle, power);
34
+ if (r.hit === target.id) return 0;
35
+ if (r.lost) return 1000;
36
+ const dx = Math.abs(r.x - centreX(target));
37
+ const dy = Math.abs(r.y - target.y);
38
+ return dx + dy * 0.5 + (r.hit != null ? 10 : 0);
39
+ }
40
+
41
+ /**
42
+ * The search: every angle in `angles` (relative to the target's side) at every power step, the
43
+ * shot with the smallest miss. `step` is the power grid; the Spoiler searches finer.
44
+ */
45
+ export function solve(g, tank, target, angles = [35, 45, 55, 65, 75], step = 25) {
46
+ let best = null;
47
+ for (const a0 of angles) {
48
+ const angle = toward(tank, target, a0);
49
+ for (let power = 100; power <= 1000; power += step) {
50
+ const miss = missOf(g, tank, target, angle, power);
51
+ if (!best || miss < best.miss) best = { angle, power, miss };
52
+ if (miss === 0) return best;
53
+ }
54
+ }
55
+ return best;
56
+ }
57
+
58
+ /** Refine a solution around it: a few degrees and a few units of power either way. */
59
+ function refine(g, tank, target, best) {
60
+ let out = best;
61
+ for (const da of [-3, -1.5, 0, 1.5, 3]) {
62
+ for (const dp of [-20, -10, 0, 10, 20]) {
63
+ const angle = Math.max(0, Math.min(180, best.angle + da)), power = Math.max(0, Math.min(1000, best.power + dp));
64
+ const miss = missOf(g, tank, target, angle, power);
65
+ if (miss < out.miss) out = { angle, power, miss };
66
+ if (miss === 0) return out;
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /** The biggest blast the tank owns that suits the target's health, or the baby missile. */
73
+ function pickWeapon(tank, target, { thrifty = false } = {}) {
74
+ const owned = WEAPON_ORDER.filter((w) => (tank.inventory[w] ?? 0) > 0 && WEAPONS[w].kind === 'blast');
75
+ if (!owned.length) return 'babyMissile';
76
+ if (thrifty || target.health <= 30) return owned[0];
77
+ return owned.sort((a, b) => WEAPONS[b].damage - WEAPONS[a].damage)[0];
78
+ }
79
+
80
+ // ------------------------------------------------------------------- the personalities
81
+ /** The Moron: "pick an angle and power, and shoot". Whatever it happens to own. */
82
+ export function moron(g, tank, rnd = g.rnd) {
83
+ // random, but toward SOMEONE: it used to fire toward the middle of the field whatever was there,
84
+ // so once the tanks on that side were gone it (and every player falling back on it) kept shelling
85
+ // the empty half of the board
86
+ const foe = nearest(g, tank);
87
+ const left = foe ? centreX(foe) < centreX(tank) : tank.x > COLS / 2;
88
+ const angle = left ? 95 + Math.round(rnd() * 65) : 20 + Math.round(rnd() * 65);
89
+ const power = 300 + Math.round(rnd() * 700);
90
+ const owned = WEAPON_ORDER.filter((w) => (tank.inventory[w] ?? 0) > 0);
91
+ const weapon = owned[Math.floor(rnd() * owned.length)] ?? 'babyMissile';
92
+ return { angle, power, weapon };
93
+ }
94
+
95
+ /** The Shooter: "significantly deadlier … only if they have a straight line of fire": low, direct shots. */
96
+ export function shooter(g, tank, rnd = g.rnd) {
97
+ const target = nearest(g, tank);
98
+ if (!target) return moron(g, tank, rnd);
99
+ const best = solve(g, tank, target, [12, 20, 28, 36], 25);
100
+ if (!best || best.miss > 12) return { ...moron(g, tank, rnd), weapon: 'babyMissile', fallback: true }; // no line of fire: a Moron's shot
101
+ return { angle: best.angle, power: best.power, weapon: pickWeapon(tank, target, { thrifty: best.miss > 4 }) };
102
+ }
103
+
104
+ /** The Poolshark: a Shooter, unless the walls rebound -- then it looks for the bank shot as well. */
105
+ export function poolshark(g, tank, rnd = g.rnd) {
106
+ if (g.walls !== 'rubber' && g.walls !== 'spring') return shooter(g, tank, rnd);
107
+ const target = nearest(g, tank);
108
+ if (!target) return moron(g, tank, rnd);
109
+ // the direct low shots and the ones off the wall behind it (angles past 90 face the wall)
110
+ const direct = solve(g, tank, target, [12, 20, 28, 36], 25);
111
+ const bank = solve(g, tank, target, [110, 125, 140, 155], 25);
112
+ const best = [direct, bank].filter(Boolean).sort((a, b) => a.miss - b.miss)[0];
113
+ if (!best || best.miss > 12) return { ...moron(g, tank, rnd), weapon: 'babyMissile', fallback: true };
114
+ return { angle: best.angle, power: best.power, weapon: pickWeapon(tank, target, { thrifty: best.miss > 4 }) };
115
+ }
116
+
117
+ /**
118
+ * The Tosser: "start out like Morons, but they'll refine their aim … until they hit". A high lob
119
+ * at the nearest tank, then the power corrected from the last shot's miss, by halves.
120
+ */
121
+ export function tosser(g, tank, rnd = g.rnd) {
122
+ const target = nearest(g, tank);
123
+ if (!target) return moron(g, tank, rnd);
124
+ const m = tank.memory;
125
+ let angle, power;
126
+ if (!m || m.target !== target.id) {
127
+ angle = toward(tank, target, 60 + Math.round(rnd() * 15));
128
+ power = 350 + Math.round(rnd() * 400);
129
+ } else {
130
+ angle = m.angle;
131
+ const r = simulateShot(g, tank, m.angle, m.power);
132
+ const short = Math.abs(r.x - centreX(tank)) < Math.abs(centreX(target) - centreX(tank));
133
+ const stepP = Math.max(8, Math.round((m.step ?? 160) / 2));
134
+ power = Math.max(50, Math.min(1000, m.power + (short ? stepP : -stepP)));
135
+ tank.memory = { ...m, step: stepP };
136
+ }
137
+ tank.memory = { ...(tank.memory ?? {}), target: target.id, angle, power, step: tank.memory?.step ?? 160 };
138
+ return { angle, power, weapon: pickWeapon(tank, target, { thrifty: true }) };
139
+ }
140
+
141
+ /** The Chooser: "all the above methods available … decide which one will be most effective". */
142
+ export function chooser(g, tank, rnd = g.rnd) {
143
+ const target = nearest(g, tank);
144
+ if (!target) return moron(g, tank, rnd);
145
+ const low = solve(g, tank, target, [12, 20, 28, 36], 25);
146
+ const lob = solve(g, tank, target, [55, 65, 75], 25);
147
+ const bank = (g.walls === 'rubber' || g.walls === 'spring') ? solve(g, tank, target, [115, 135, 155], 25) : null;
148
+ const best = [low, lob, bank].filter(Boolean).sort((a, b) => a.miss - b.miss)[0];
149
+ if (!best) return moron(g, tank, rnd);
150
+ return { angle: best.angle, power: best.power, weapon: pickWeapon(tank, target) };
151
+ }
152
+
153
+ /** The Spoiler: "taking into account the wind factor and gravity, they will get a perfect shot almost every time". */
154
+ export function spoiler(g, tank, rnd = g.rnd, target = nearest(g, tank)) {
155
+ if (!target) return moron(g, tank, rnd);
156
+ const coarse = solve(g, tank, target, [15, 25, 35, 45, 55, 65, 75], 20);
157
+ const best = coarse ? refine(g, tank, target, coarse) : null;
158
+ if (!best) return moron(g, tank, rnd);
159
+ // "almost": a degree or two of error, drawn from the game's own stream
160
+ const wobble = rnd() < 0.15 ? (rnd() - 0.5) * 4 : 0;
161
+ return { angle: Math.max(0, Math.min(180, best.angle + wobble)), power: best.power, weapon: pickWeapon(tank, target) };
162
+ }
163
+
164
+ /** The Cyborg: a Spoiler that "attacks tanks who are weakened, winning, or have attacked them". */
165
+ export function cyborg(g, tank, rnd = g.rnd) {
166
+ const grudge = tank.lastHitBy != null ? g.tanks[tank.lastHitBy] : null;
167
+ const weak = weakest(g, tank);
168
+ const lead = leaderOf(g, tank);
169
+ const target = (grudge?.alive && grudge !== tank) ? grudge : (weak && weak.health <= 40) ? weak : lead ?? nearest(g, tank);
170
+ return spoiler(g, tank, rnd, target);
171
+ }
172
+
173
+ const PERSONALITIES = { moron, shooter, poolshark, tosser, chooser, spoiler, cyborg };
174
+
175
+ /**
176
+ * What this tank does this turn. The persona is what the rules drew for it this round (an Unknown
177
+ * plays as one of the others); unknown kinds play as the Moron. A battery is used first when it
178
+ * is worth it.
179
+ */
180
+ export function decide(g, tank) {
181
+ const play = PERSONALITIES[tank.persona] ?? PERSONALITIES[tank.kind] ?? moron;
182
+ const d = play(g, tank);
183
+ if (!WEAPONS[d.weapon] || (tank.inventory[d.weapon] ?? 0) <= 0) d.weapon = 'babyMissile';
184
+ return d;
185
+ }
186
+
187
+ /** Before firing: the items a computer player uses on its turn (a battery when hurt, a shield when threatened). */
188
+ export function prepare(g, tank) {
189
+ const used = [];
190
+ if (tank.persona !== 'moron' && tank.health <= 50 && (tank.items.battery ?? 0) > 0 && useItem(g, tank, 'battery')) used.push('battery');
191
+ if (tank.persona !== 'moron' && !tank.shield && tank.health < MAX_HEALTH && useItem(g, tank, 'shield')) used.push('shield');
192
+ return used;
193
+ }
194
+
195
+ // ------------------------------------------------------------------- the shop, by taste
196
+ const TASTES = {
197
+ moron: null, // random
198
+ shooter: ['missile', 'babyNuke', 'nuke', 'battery', 'shield'],
199
+ poolshark: ['missile', 'babyNuke', 'heavyRoller', 'battery', 'shield'],
200
+ tosser: ['mirv', 'funkyBomb', 'babyNuke', 'parachute', 'battery'],
201
+ chooser: ['nuke', 'babyNuke', 'mirv', 'shield', 'parachute', 'battery', 'missile'],
202
+ spoiler: ['nuke', 'deathsHead', 'heatGuidance', 'shield', 'forceShield', 'battery', 'contactTrigger'],
203
+ cyborg: ['deathsHead', 'nuke', 'heavyShield', 'forceShield', 'heatGuidance', 'battery', 'parachute'],
204
+ };
205
+
206
+ /**
207
+ * Between rounds the computer shops. The Moron buys at random until it cannot afford the next
208
+ * thing it points at; the others go down their list of tastes, buying what they can, up to
209
+ * `picks` packs. Returns what was bought, in order.
210
+ */
211
+ export function shop(g, tank, rnd = g.rnd, picks = 3) {
212
+ const bought = [];
213
+ if (tank.kind === 'human') return bought;
214
+ const taste = TASTES[tank.persona] ?? TASTES[tank.kind] ?? null;
215
+ for (let i = 0; i < picks; i++) {
216
+ let pick = null;
217
+ if (taste) {
218
+ pick = taste.map((id) => SHOP.find((e) => e.id === id)).find((e) => e && tank.cash >= e.price && ((e.item ? tank.items[e.id] : tank.inventory[e.id]) ?? 0) < e.pack * 2) ?? null;
219
+ } else {
220
+ const affordable = SHOP.filter((e) => tank.cash >= e.price);
221
+ pick = affordable.length ? affordable[Math.floor(rnd() * affordable.length)] : null;
222
+ }
223
+ if (!pick || !buy(tank, pick.id)) break;
224
+ bought.push(pick.id);
225
+ }
226
+ return bought;
227
+ }
@@ -0,0 +1,286 @@
1
+ // SCORCHED YARD'S AIR, SIMULATED (operator, 2026-09-16, with a night capture: "There is no
2
+ // simulation movement. It's just a squiggly line moving across the screen").
3
+ //
4
+ // (It first showed the flow with a dye -- smoke carried by the air -- which read as smoke blowing
5
+ // out of the scene. The dye is gone; the flow lines at the bottom of this module are the picture.)
6
+ //
7
+ // That was fair. Every earlier cut was kinematic: sprites, dashes and finally sine curves, each
8
+ // told where to be at time t. None of them could flow round anything, because nothing in them knew
9
+ // what was there. This is an actual fluid: a small grid solved the way Jos Stam's "Stable Fluids"
10
+ // does it -- velocity advected semi-Lagrangian, made divergence-free by a pressure projection,
11
+ // with vorticity confinement to keep the eddies alive -- and weightless tracers carried by that
12
+ // velocity, whose paths are what you see.
13
+ //
14
+ // * THE WIND is a body force pulling the air toward the gauge's speed and direction. A change of
15
+ // wind is not a fade: the air has momentum and turns round.
16
+ // * THE LAND IS SOLID. Cells under the terrain are walls, so the air rises over the hills, speeds
17
+ // up across the crests and rolls into eddies in their lee. That is the bowing.
18
+ // * TURBULENCE is a few small vortex impulses a step, stronger in a strong wind, which the solver
19
+ // carries, stretches and tears apart on its own.
20
+ //
21
+ // Cheap by construction: 96 x 48 cells, fourteen Jacobi iterations a step, all typed arrays. Nothing
22
+ // here reads the DOM; the caller hands over the size, the wind and the solid mask.
23
+
24
+ const IX = (f, x, y) => x + y * f.nx;
25
+ const clampI = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
26
+ const hash = (n) => { const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); };
27
+ const smooth = (t) => t * t * (3 - 2 * t);
28
+
29
+ /** A fluid of `nx` by `ny` cells, still. */
30
+ export function makeFluid(nx = 96, ny = 48, seed = 1) {
31
+ const n = nx * ny;
32
+ return {
33
+ nx, ny, t: 0, seed,
34
+ u: new Float32Array(n), v: new Float32Array(n), u0: new Float32Array(n), v0: new Float32Array(n),
35
+ p: new Float32Array(n), div: new Float32Array(n), w: new Float32Array(n), solid: new Uint8Array(n),
36
+ };
37
+ }
38
+
39
+ /** Mark the cells the land fills. `topOf(x)` answers the first free row, from the top, of column x. */
40
+ export function setSolid(f, topOf) {
41
+ f.solid.fill(0);
42
+ for (let x = 0; x < f.nx; x++) {
43
+ const top = clampI(Math.round(topOf(x)), 0, f.ny);
44
+ for (let y = top; y < f.ny; y++) f.solid[IX(f, x, y)] = 1;
45
+ }
46
+ }
47
+
48
+ function sample(f, a, x, y, wrap = true) {
49
+ // x wraps (the field is a strip of sky) for the solver; a tracer beyond the edge reads the edge
50
+ x = wrap ? ((x % f.nx) + f.nx) % f.nx : (x < 0 ? 0 : x > f.nx - 1.001 ? f.nx - 1.001 : x);
51
+ y = y < 0 ? 0 : y > f.ny - 1.001 ? f.ny - 1.001 : y;
52
+ const x0 = Math.floor(x), y0 = Math.floor(y), x1 = (x0 + 1) % f.nx, y1 = y0 + 1;
53
+ const sx = x - x0, sy = y - y0;
54
+ return (a[x0 + y0 * f.nx] * (1 - sx) + a[x1 + y0 * f.nx] * sx) * (1 - sy) + (a[x0 + y1 * f.nx] * (1 - sx) + a[x1 + y1 * f.nx] * sx) * sy;
55
+ }
56
+
57
+ /** One step of the solver: `dt` in ms, `wind` the gauge value (-10..10). */
58
+ export function stepFluid(f, dt, { wind = 0 } = {}) {
59
+ const s = Math.min(0.05, Math.max(0, dt / 1000));
60
+ if (!s) return f;
61
+ f.t += s;
62
+ const { nx, ny, u, v, u0, v0, p, div, w, solid } = f;
63
+ const n = nx * ny;
64
+ const strength = Math.min(1, Math.abs(wind) / 10);
65
+ const dir = wind < 0 ? -1 : 1;
66
+ const U = dir * (2.5 + 17 * strength); // cells a second at the gauge's speed: a gale crosses in five seconds
67
+
68
+ // --- forces: the wind pulls the air toward its speed; a few vortex impulses stir it
69
+ const pull = Math.min(1, 2.2 * s);
70
+ for (let i = 0; i < n; i++) { if (solid[i]) { u[i] = 0; v[i] = 0; continue; } u[i] += (U - u[i]) * pull; v[i] *= 1 - Math.min(1, 0.35 * s); }
71
+ const kicks = 1 + Math.round(3 * strength);
72
+ for (let k = 0; k < kicks; k++) {
73
+ const h = (q) => hash(f.t * 97.3 + k * 13.1 + q + f.seed);
74
+ if (h(1) > 0.55) continue; // not every step
75
+ const cx = h(2) * nx, cy = h(3) * ny * 0.85, r = 2.5 + h(4) * 4;
76
+ const spin = (h(5) - 0.5) * (6 + 22 * strength);
77
+ for (let y = Math.max(0, Math.floor(cy - r)); y <= Math.min(ny - 1, Math.ceil(cy + r)); y++) {
78
+ for (let xx = Math.floor(cx - r); xx <= Math.ceil(cx + r); xx++) {
79
+ const x = ((xx % nx) + nx) % nx;
80
+ const dx = xx - cx, dy = y - cy, q = dx * dx + dy * dy;
81
+ if (q > r * r) continue;
82
+ const fall = (1 - q / (r * r)) * spin * s;
83
+ const i = x + y * nx;
84
+ if (solid[i]) continue;
85
+ u[i] += -dy * fall; v[i] += dx * fall;
86
+ }
87
+ }
88
+ }
89
+
90
+ // --- advect the velocity by itself
91
+ u0.set(u); v0.set(v);
92
+ for (let y = 0; y < ny; y++) for (let x = 0; x < nx; x++) {
93
+ const i = x + y * nx;
94
+ if (solid[i]) { u[i] = 0; v[i] = 0; continue; }
95
+ const bx = x - u0[i] * s, by = y - v0[i] * s;
96
+ u[i] = sample(f, u0, bx, by); v[i] = sample(f, v0, bx, by);
97
+ }
98
+
99
+ // --- vorticity confinement: find where the air spins and push it to keep spinning
100
+ for (let y = 1; y < ny - 1; y++) for (let x = 0; x < nx; x++) {
101
+ const xl = (x - 1 + nx) % nx, xr = (x + 1) % nx;
102
+ w[x + y * nx] = (v[xr + y * nx] - v[xl + y * nx] - u[x + (y + 1) * nx] + u[x + (y - 1) * nx]) * 0.5;
103
+ }
104
+ const eps = 1.4 + 2.2 * strength;
105
+ for (let y = 2; y < ny - 2; y++) for (let x = 0; x < nx; x++) {
106
+ const i = x + y * nx;
107
+ if (solid[i]) continue;
108
+ const xl = (x - 1 + nx) % nx, xr = (x + 1) % nx;
109
+ const gx = (Math.abs(w[xr + y * nx]) - Math.abs(w[xl + y * nx])) * 0.5;
110
+ const gy = (Math.abs(w[x + (y + 1) * nx]) - Math.abs(w[x + (y - 1) * nx])) * 0.5;
111
+ const len = Math.hypot(gx, gy) + 1e-5;
112
+ u[i] += (gy / len) * w[i] * eps * s;
113
+ v[i] -= (gx / len) * w[i] * eps * s;
114
+ }
115
+
116
+ // --- project: make it divergence-free, so it flows round the land instead of into it
117
+ for (let y = 0; y < ny; y++) for (let x = 0; x < nx; x++) {
118
+ const i = x + y * nx;
119
+ const xl = (x - 1 + nx) % nx, xr = (x + 1) % nx;
120
+ const vu = y + 1 < ny ? v[x + (y + 1) * nx] : 0, vd = y > 0 ? v[x + (y - 1) * nx] : 0;
121
+ div[i] = solid[i] ? 0 : -0.5 * (u[xr + y * nx] - u[xl + y * nx] + vu - vd);
122
+ p[i] = 0;
123
+ }
124
+ for (let it = 0; it < 14; it++) {
125
+ for (let y = 0; y < ny; y++) for (let x = 0; x < nx; x++) {
126
+ const i = x + y * nx;
127
+ if (solid[i]) continue;
128
+ const xl = (x - 1 + nx) % nx, xr = (x + 1) % nx;
129
+ const pc = p[i];
130
+ const pl = solid[xl + y * nx] ? pc : p[xl + y * nx];
131
+ const pr = solid[xr + y * nx] ? pc : p[xr + y * nx];
132
+ const pu = y + 1 < ny && !solid[x + (y + 1) * nx] ? p[x + (y + 1) * nx] : pc;
133
+ const pd = y > 0 && !solid[x + (y - 1) * nx] ? p[x + (y - 1) * nx] : pc;
134
+ p[i] = (div[i] + pl + pr + pu + pd) * 0.25;
135
+ }
136
+ }
137
+ for (let y = 0; y < ny; y++) for (let x = 0; x < nx; x++) {
138
+ const i = x + y * nx;
139
+ if (solid[i]) { u[i] = 0; v[i] = 0; continue; }
140
+ const xl = (x - 1 + nx) % nx, xr = (x + 1) % nx;
141
+ const pc = p[i];
142
+ const pl = solid[xl + y * nx] ? pc : p[xl + y * nx];
143
+ const pr = solid[xr + y * nx] ? pc : p[xr + y * nx];
144
+ const pu = y + 1 < ny && !solid[x + (y + 1) * nx] ? p[x + (y + 1) * nx] : pc;
145
+ const pd = y > 0 && !solid[x + (y - 1) * nx] ? p[x + (y - 1) * nx] : pc;
146
+ u[i] -= 0.5 * (pr - pl);
147
+ v[i] -= 0.5 * (pu - pd);
148
+ if ((y === 0 && v[i] < 0) || (y === ny - 1 && v[i] > 0)) v[i] = 0;
149
+ }
150
+
151
+ return f;
152
+ }
153
+
154
+ /** The mean horizontal speed of the free air, for tests and for anyone curious. */
155
+ export function meanFlow(f) {
156
+ let s = 0, k = 0;
157
+ for (let i = 0; i < f.u.length; i++) if (!f.solid[i]) { s += f.u[i]; k += 1; }
158
+ return k ? s / k : 0;
159
+ }
160
+
161
+ /**
162
+ * Run the solver on its own for `seconds` of simulated time, so a new field -- a new game, a new
163
+ * round's land -- opens on air that is already flowing round the hills rather than on a front of
164
+ * smoke crawling in from one edge. About a quarter of a millisecond a step.
165
+ */
166
+ export function warmFluid(f, seconds, wind) {
167
+ const steps = Math.ceil((seconds * 1000) / 33);
168
+ for (let i = 0; i < steps; i++) stepFluid(f, 33, { wind });
169
+ return steps;
170
+ }
171
+
172
+ // ------------------------------------------------------------------ what the eye follows
173
+ /**
174
+ * STREAKLINES THROUGH THE SIMULATED FLOW (operator, 2026-09-16: "looks too much like smoke blowing
175
+ * out the scene"). Any soft density drifting across the sky reads as smoke, however it is tuned.
176
+ * What reads as AIR is the wind map: many thin lines, each the recent path of a weightless tracer
177
+ * carried by the simulated velocity, so they curve where the air curves -- up over a hill, into the
178
+ * eddy behind it. Earlier lines read as shooting stars for two reasons, both absent here: they were
179
+ * straight (told where to go, not carried), and bright at the head. These taper to nothing at BOTH
180
+ * ends, brightest in the middle of their length, and there is no head to catch the eye.
181
+ */
182
+ const TRAIL = 22;
183
+ const EDGE_RUN = 10; // cells a tracer keeps going beyond the field before it is reborn
184
+ const EDGE_FADE = 0.09; // the share of the width, and of the height at the top, over which lines fade out
185
+ export function makeTracers(f, n = 320, seed = 1) {
186
+ const tr = { n, x: new Float32Array(n), y: new Float32Array(n), age: new Float32Array(n), life: new Float32Array(n), len: new Uint8Array(n), hx: new Float32Array(n * TRAIL), hy: new Float32Array(n * TRAIL), head: new Uint8Array(n), seed, k: 0 };
187
+ for (let i = 0; i < n; i++) respawn(f, tr, i, true);
188
+ return tr;
189
+ }
190
+
191
+ function respawn(f, tr, i, anywhere) {
192
+ // a free cell, anywhere on the field: density stays even, nothing enters as a front
193
+ for (let tries = 0; tries < 24; tries++) {
194
+ tr.k += 1;
195
+ const x = hash(tr.seed * 31.7 + tr.k * 1.618) * f.nx, y = hash(tr.seed * 17.3 + tr.k * 2.414) * f.ny;
196
+ if (!f.solid[Math.floor(x) + Math.floor(y) * f.nx]) { tr.x[i] = x; tr.y[i] = y; break; }
197
+ }
198
+ tr.age[i] = 0;
199
+ tr.life[i] = 2.5 + hash(tr.seed + tr.k * 0.77) * 3.5;
200
+ tr.len[i] = 0;
201
+ tr.head[i] = 0;
202
+ if (anywhere) tr.age[i] = hash(tr.k * 3.3) * tr.life[i] * 0.6;
203
+ }
204
+
205
+ /** Carry every tracer `dt` ms through the fluid's velocity, keeping its recent path. */
206
+ export function stepTracers(f, tr, dt) {
207
+ const s = Math.min(0.05, Math.max(0, dt / 1000));
208
+ if (!s) return tr;
209
+ for (let i = 0; i < tr.n; i++) {
210
+ const ux = sample(f, f.u, tr.x[i], tr.y[i], false), vy = sample(f, f.v, tr.x[i], tr.y[i], false);
211
+ let x = tr.x[i] + ux * s, y = tr.y[i] + vy * s;
212
+ tr.age[i] += s;
213
+ // A TRACER RUNS ON PAST THE EDGE (operator, 2026-09-16, with a capture of the right edge: "It's
214
+ // not fading out cleanly at the edges, it just sorta disappears"). It was removed the moment it
215
+ // crossed, so its whole line vanished at once. It now carries on for a stretch beyond the field,
216
+ // where paintTracers has already faded it to nothing, and is only reborn once it is out of sight.
217
+ const gone = x < -EDGE_RUN || x >= f.nx + EDGE_RUN || y < -EDGE_RUN;
218
+ const inside = x >= 0 && x < f.nx && y >= 0 && y < f.ny;
219
+ const cell = inside && f.solid[Math.floor(x) + Math.floor(y) * f.nx];
220
+ if (gone || y >= f.ny || cell || tr.age[i] > tr.life[i]) { respawn(f, tr, i, false); continue; }
221
+ tr.x[i] = x; tr.y[i] = y;
222
+ const h = (tr.head[i] + 1) % TRAIL;
223
+ tr.head[i] = h;
224
+ tr.hx[i * TRAIL + h] = x; tr.hy[i * TRAIL + h] = y;
225
+ if (tr.len[i] < TRAIL) tr.len[i] += 1;
226
+ }
227
+ return tr;
228
+ }
229
+
230
+ /**
231
+ * Draw the paths as thin polylines. Segments are bucketed into four opacities by where they sit
232
+ * along the path -- faint at both ends, full in the middle -- so the whole field is four strokes,
233
+ * not thousands. A tracer fades in over its first half second and out over its last.
234
+ */
235
+ export function paintTracers(ctx, f, tr, w, h, { bright = 0, wind = 0, colour = '240,246,255' } = {}) {
236
+ const strength = Math.min(1, Math.abs(wind) / 10);
237
+ if (strength < 0.03) return 0;
238
+ const sx = w / f.nx, sy = h / f.ny;
239
+ const lit = Math.max(0, Math.min(1, bright));
240
+ const base = (0.2 + 0.2 * lit) * (0.7 + 0.3 * strength);
241
+ const levels = [0.25, 0.5, 0.75, 1];
242
+ // ONE CONTINUOUS PATH PER RUN, EVERY OTHER POINT, FLAT CAPS (operator, 2026-09-16: "performance
243
+ // freezing and jittering when the cubes are being blown up"). Measured with each layer switched
244
+ // off in turn during a nuke, these lines were the largest single cost: 5,500 separate round-capped
245
+ // segments a frame. A tracer's consecutive points in the same opacity band are now one polyline,
246
+ // at half the points -- the paths are smooth, so nothing shows -- with butt caps and round joins.
247
+ const paths = levels.map(() => []);
248
+ for (let i = 0; i < tr.n; i++) {
249
+ const L = tr.len[i];
250
+ if (L < 5) continue;
251
+ const fade = Math.min(1, tr.age[i] / 0.5, (tr.life[i] - tr.age[i]) / 0.8);
252
+ if (fade <= 0.05) continue;
253
+ let run = null, runLv = -1;
254
+ // point p of the path, 0 the oldest and L-1 the newest, lives at this slot of the ring
255
+ const slot = (q) => (tr.head[i] - (L - 1) + q + TRAIL * 2) % TRAIL;
256
+ for (let p0 = 0; p0 < L - 1; p0 += 2) {
257
+ const p1 = Math.min(p0 + 2, L - 1);
258
+ const a = slot(p0), b = slot(p1);
259
+ const ax = tr.hx[i * TRAIL + a], ay = tr.hy[i * TRAIL + a], bx = tr.hx[i * TRAIL + b], by = tr.hy[i * TRAIL + b];
260
+ const along = (p0 + p1) / 2 / (L - 1); // 0 at the tail, 1 at the head
261
+ // and nothing at the edges of the field: each segment fades by how near it is to the left, the
262
+ // right or the top, smoothly, so a line going out of the picture thins away rather than ending
263
+ const mx = (ax + bx) * 0.5, my = (ay + by) * 0.5;
264
+ const ex = Math.min(mx / (f.nx * EDGE_FADE), (f.nx - mx) / (f.nx * EDGE_FADE)), ey = my / (f.ny * EDGE_FADE);
265
+ const edge = smooth(Math.max(0, Math.min(1, Math.min(ex, ey))));
266
+ const taper = Math.sin(Math.PI * along) * fade * edge; // nothing at either end, nothing at the edges
267
+ if (taper < 0.08) { run = null; runLv = -1; continue; }
268
+ const lv = taper < 0.3 ? 0 : taper < 0.55 ? 1 : taper < 0.8 ? 2 : 3;
269
+ if (lv !== runLv || !run) { run = [ax * sx, ay * sy]; paths[lv].push(run); runLv = lv; }
270
+ run.push(bx * sx, by * sy);
271
+ }
272
+ }
273
+ ctx.lineCap = 'butt';
274
+ ctx.lineJoin = 'round';
275
+ ctx.lineWidth = 1.3;
276
+ let strokes = 0;
277
+ paths.forEach((runs, lv) => {
278
+ if (!runs.length) return;
279
+ ctx.strokeStyle = `rgba(${colour},${(base * levels[lv]).toFixed(3)})`;
280
+ ctx.beginPath();
281
+ for (const r of runs) { ctx.moveTo(r[0], r[1]); for (let j = 2; j < r.length; j += 2) ctx.lineTo(r[j], r[j + 1]); }
282
+ ctx.stroke();
283
+ strokes += 1;
284
+ });
285
+ return strokes;
286
+ }