blockyard 0.1.0 → 0.1.2

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 (71) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +13 -11
  3. package/SECURITY.md +2 -2
  4. package/docs/API.md +1 -1
  5. package/docs/ARCHITECTURE.md +36 -5
  6. package/docs/CONFIGURATION.md +6 -4
  7. package/docs/DEFECTS.md +4 -1
  8. package/docs/GETTING-STARTED.md +14 -7
  9. package/docs/INSTALL.md +7 -4
  10. package/docs/PLAN-SCORCHED-YARD.md +456 -0
  11. package/docs/PLAN-SKIES.md +142 -0
  12. package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
  13. package/docs/SECURITY.md +26 -7
  14. package/docs/TROUBLESHOOTING.md +10 -5
  15. package/docs/USER-GUIDE.md +247 -9
  16. package/package.json +4 -2
  17. package/public/css/app.css +87 -0
  18. package/public/index.html +58 -6
  19. package/public/js/app.js +60 -19
  20. package/public/js/blockanoid.js +15 -7
  21. package/public/js/blockout.js +15 -7
  22. package/public/js/blockscene3d.js +51 -11
  23. package/public/js/depthchart.js +1 -1
  24. package/public/js/details3d.js +25 -2
  25. package/public/js/explorer.js +7 -1
  26. package/public/js/livingsky.js +494 -0
  27. package/public/js/login.js +3 -2
  28. package/public/js/mining.js +4 -4
  29. package/public/js/panels.js +27 -18
  30. package/public/js/safenext.js +14 -0
  31. package/public/js/scorched.js +1071 -0
  32. package/public/js/scorchedai.js +268 -0
  33. package/public/js/scorchedair.js +286 -0
  34. package/public/js/scorchedfx.js +376 -0
  35. package/public/js/scorchedshop.js +105 -0
  36. package/public/js/scorchedwind.js +69 -0
  37. package/public/js/scorchedyard.js +1361 -0
  38. package/public/js/settings.js +266 -80
  39. package/public/js/tetrust.js +15 -6
  40. package/public/js/tetsound.js +35 -5
  41. package/scripts/check.js +46 -0
  42. package/scripts/index-build.js +9 -2
  43. package/scripts/pool-map.js +152 -36
  44. package/scripts/setup.js +108 -10
  45. package/scripts/shots.mjs +27 -0
  46. package/scripts/smoke.sh +6 -5
  47. package/scripts/ui.js +4 -2
  48. package/server/auth/sessions.js +33 -13
  49. package/server/chain/blockfile.js +64 -5
  50. package/server/chain/index/build.js +432 -56
  51. package/server/chain/index/heights.js +29 -3
  52. package/server/chain/index/live.js +13 -7
  53. package/server/chain/index/rows.js +6 -1
  54. package/server/chain/index/store.js +28 -5
  55. package/server/chain/index/worker.js +23 -11
  56. package/server/collect/logparse.js +65 -18
  57. package/server/collect/markets.js +76 -7
  58. package/server/collect/mining.js +32 -0
  59. package/server/collect/monitor.js +24 -11
  60. package/server/collect/network.js +19 -9
  61. package/server/config.js +7 -0
  62. package/server/http/api.js +70 -13
  63. package/server/http/server.js +22 -5
  64. package/server/http/sse.js +53 -7
  65. package/server/main.js +13 -3
  66. package/server/rpc/allowlist.js +26 -0
  67. package/server/rpc/client.js +30 -2
  68. package/server/store/audit.js +6 -1
  69. package/server/store/history.js +19 -3
  70. package/server/store/ledger.js +15 -4
  71. package/systemd/blockyard.service +34 -3
@@ -0,0 +1,1071 @@
1
+ // SCORCHED YARD, the rules (operator, 2026-09-16: "Scope out a plan for re-creating the classic PC
2
+ // DOS game Scorched Earth using our engine. I suggest using blocks to generate the deformable
3
+ // landscape ... Make it at least 3 player. 1 Human Player and 2 AI Players. Try to reproduce it as
4
+ // faithfully as possible, but make it look fabulous"; then "go with 96x48, start on M1", "start on
5
+ // M2"). docs/PLAN-SCORCHED-YARD.md is the plan; this is its M1 and M2: the landscape, the tanks,
6
+ // the whole roster of shells and items, craters, falling dirt, damage, death, turns, rounds, cash.
7
+ //
8
+ // This file knows nothing of the screen: no DOM, no canvas, no clock. A game is a plain object; a
9
+ // shot is a function of it; the screen (scorchedyard.js) asks for the tiles when it wants to draw.
10
+ // Every random choice comes from the game's own seeded stream, so a round replays exactly in a
11
+ // test. The roster itself is data in scorchedshop.js; the behaviours are here.
12
+ //
13
+ // THE FIELD is W x H cells (96 x 48: the original's 640 x 350 EGA screen was about 2:1, so a cell
14
+ // stands in for six or seven of its pixels). The dirt is a BITMAP over those cells, not a height
15
+ // per column, so that a tunnel or an overhang exists once a digger has been through; a column's
16
+ // top is kept beside it for the fast questions. Grid y runs UP from the floor, as the engine
17
+ // draws it.
18
+ //
19
+ // UNITS: grid cells, and seconds inside the integrator (the screen hands it milliseconds). Power
20
+ // 0..1000 is the original's scale; 1000 throws a shell at V_MAX cells a second, which on Earth
21
+ // gravity carries a 45° shot across the whole field and no further -- the original's feel.
22
+ import {
23
+ WEAPONS, WEAPON_ORDER, ITEMS, ITEM_ORDER, START_INVENTORY, START_ITEMS, START_CASH,
24
+ CASH_PER_DAMAGE, KILL_BONUS, SURVIVOR_BONUS, buy, payInterest,
25
+ } from './scorchedshop.js';
26
+ export { WEAPONS, WEAPON_ORDER, ITEMS, ITEM_ORDER, START_INVENTORY, START_ITEMS, START_CASH, buy, payInterest };
27
+
28
+ export const COLS = 96;
29
+ export const ROWS = 48;
30
+
31
+ // the tanks' colours, in the order players are added (the original's palette, roughly)
32
+ export const PERSONALITIES = Object.freeze(['moron', 'shooter', 'poolshark', 'tosser', 'chooser', 'spoiler', 'cyborg', 'unknown']);
33
+ const UNKNOWN_POOL = Object.freeze(['moron', 'shooter', 'poolshark', 'tosser', 'chooser', 'spoiler', 'cyborg']);
34
+ // EIGHT SEATS, EIGHT COLOURS (operator, 2026-09-16: "I would like to add up to 8 players total like
35
+ // the original"). Distinct at a glance on dirt, and none of them the dirt's own browns.
36
+ export const TANK_COLOURS = Object.freeze(['#f7931a', '#4d8dff', '#2ecc8f', '#ef5a5a', '#c78bff', '#f0c419', '#32d4e0', '#ff86b8']);
37
+ // A COLOUR FOR EACH PERSONALITY (operator, 2026-09-17: "assign a color to each personality"). A tank
38
+ // used to take its colour from its seat, so the first opponent was blue and the second green whatever
39
+ // played there. Now the colour says who you are up against: you are BlockYard orange, and each of
40
+ // the manual's eight has its own. The Unknown has one too, so its colour gives nothing away about
41
+ // the personality it draws each round. A second or third of one kind is a lighter, then a darker,
42
+ // shade of the same colour.
43
+ export const PERSONA_COLOURS = Object.freeze({
44
+ human: '#f7931a', moron: '#f0c419', shooter: '#4d8dff', poolshark: '#32d4e0', tosser: '#2ecc8f',
45
+ chooser: '#c78bff', spoiler: '#ef5a5a', cyborg: '#ff86b8', unknown: '#b7c0cc',
46
+ });
47
+ const mixHex = (hex, to, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(hex.slice(i, i + 2), 16) * (1 - k) + to * k).toString(16).padStart(2, '0')).join('')}`;
48
+ /** The colour of the `nth` tank (1 = the first) of a kind: its own colour, then shades of it. */
49
+ export function colourFor(kind, nth = 1) {
50
+ const base = PERSONA_COLOURS[kind] ?? TANK_COLOURS[0];
51
+ if (nth <= 1) return base;
52
+ const shades = [[255, 0.35], [0, 0.35], [255, 0.6], [0, 0.55], [255, 0.8], [0, 0.7]];
53
+ const [to, k] = shades[(nth - 2) % shades.length];
54
+ return mixHex(base, to, k);
55
+ }
56
+ export const MAX_PLAYERS = 8;
57
+ export const MAX_HEALTH = 100;
58
+ export const V_MAX = 56; // cells/s at power 1000
59
+ export const GRAVITY = 30; // cells/s² at the Earth setting (1)
60
+ export const WIND_ACCEL = 1.2; // cells/s² per unit of wind
61
+ export const WIND_MAX = 10;
62
+ export const MAX_STEP = 0.25; // a shell never moves more than this in one substep: no tunnelling
63
+ export const FALL_DAMAGE = 4; // per cell fallen beyond the first
64
+ export const DEATH_BLAST = Object.freeze({ radius: 3.5, damage: 60 });
65
+ export const TANK_W = 2; // the hull: two cells wide
66
+ export const TANK_H = 1.4; // hull and turret, for the hitbox
67
+ export const ROLL_SPEED = 14; // cells/s along the ground
68
+ export const BORE_SPEED = 16; // cells/s through dirt
69
+ export const HEAT_PULL = 14; // cells/s² toward the nearest tank, heat-guided and falling
70
+ const clampX = (x) => Math.max(0, Math.min(COLS - 1e-3, x));
71
+ const BOMBLET = Object.freeze({ name: 'bomblet', kind: 'blast', radius: 1.5, damage: 30 }); // a Funky Bomb's pieces, which the shop does not sell
72
+
73
+ // the strata: from the floor up, as a fraction of a column's own height, with a colour each
74
+ // VIBRANT (operator, 2026-09-16: "make the terrain blocks more vibrant colored"). The first set
75
+ // were dust-and-shadow browns that read as tinted rather than lit whatever the lamp did. These
76
+ // are the same strata at full saturation: hot magma, slate rock with some blue in it, terracotta
77
+ // clay, golden soil and a green that is actually green. The lamp then only has to do the shading.
78
+ // AND BRIGHT (operator, 2026-09-16, with a screenshot: "See how the tanks are nice and bright on
79
+ // their front faces? I would like the side of the terrain to be similarly brightened"). The tanks
80
+ // and the land go through the same lamp; the tanks looked lit because their paint is bright. The
81
+ // strata were lifted a fifth to a quarter in value at the same saturation: the front of the land
82
+ // now sits where the front of a tank does.
83
+ export const STRATA = Object.freeze([
84
+ Object.freeze({ to: 0.12, color: '#e84a2c' }), // magma, at the very bottom
85
+ Object.freeze({ to: 0.40, color: '#7b78a8' }), // rock
86
+ Object.freeze({ to: 0.72, color: '#cc6a32' }), // clay
87
+ Object.freeze({ to: 0.97, color: '#e9ad4e' }), // soil
88
+ Object.freeze({ to: 1.00, color: '#55dc4e' }), // the grass cap
89
+ ]);
90
+
91
+ // ------------------------------------------------------------------- randomness, seeded
92
+ /** A small LCG (the same constants agents.js uses), so a game replays from its seed. */
93
+ export function rng(seed) {
94
+ let s = (Number(seed) >>> 0) || 1;
95
+ return () => { s = (Math.imul(s, 1103515245) + 12345) >>> 0; return (s >>> 8) / 16777216; };
96
+ }
97
+ const hash01 = (n) => { const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); };
98
+
99
+ // ------------------------------------------------------------------- the dirt
100
+ const idx = (x, y) => y * COLS + x;
101
+ export const dirtAt = (g, x, y) => x >= 0 && x < COLS && y >= 0 && y < ROWS && g.dirt[idx(x, y)] === 1;
102
+ /** The top of a column: one above its highest dirt cell (0 for a bare column). */
103
+ export function topOf(g, x) {
104
+ if (x < 0 || x >= COLS) return 0;
105
+ for (let y = ROWS - 1; y >= 0; y--) if (g.dirt[idx(x, y)]) return y + 1;
106
+ return 0;
107
+ }
108
+ function retop(g, x0 = 0, x1 = COLS - 1) {
109
+ for (let x = Math.max(0, x0); x <= Math.min(COLS - 1, x1); x++) g.tops[x] = topOf(g, x);
110
+ }
111
+
112
+ /**
113
+ * The landscape: a sum of low sines with seeded phases, clamped to leave sky above and dirt below.
114
+ * `style` picks the amplitude: 'hills' (the default), 'mountains', 'flat', 'valley'.
115
+ */
116
+ export function generateLand(g, style = 'hills') {
117
+ const r = g.rnd;
118
+ const p = [r(), r(), r(), r()].map((v) => v * Math.PI * 2);
119
+ const amp = { hills: 0.22, mountains: 0.34, flat: 0.04, valley: 0.26 }[style] ?? 0.22;
120
+ const base = style === 'mountains' ? 0.42 : 0.36;
121
+ g.dirt.fill(0);
122
+ for (let x = 0; x < COLS; x++) {
123
+ const u = x / COLS;
124
+ let h = base
125
+ + amp * 0.55 * Math.sin(u * Math.PI * 2 * 1.3 + p[0])
126
+ + amp * 0.30 * Math.sin(u * Math.PI * 2 * 2.9 + p[1])
127
+ + amp * 0.15 * Math.sin(u * Math.PI * 2 * 5.7 + p[2])
128
+ + amp * 0.08 * Math.sin(u * Math.PI * 2 * 11 + p[3]);
129
+ if (style === 'valley') h -= 0.18 * Math.cos((u - 0.5) * Math.PI * 2);
130
+ const cells = Math.round(Math.max(3, Math.min(ROWS * 0.78, h * ROWS)));
131
+ for (let y = 0; y < cells; y++) g.dirt[idx(x, y)] = 1;
132
+ }
133
+ retop(g);
134
+ g.landVersion = (g.landVersion ?? 0) + 1;
135
+ }
136
+
137
+ /** Clear every dirt cell inside a circle. Returns how many went. */
138
+ export function crater(g, cx, cy, r) {
139
+ let removed = 0;
140
+ for (let x = Math.max(0, Math.floor(cx - r - 1)); x <= Math.min(COLS - 1, Math.ceil(cx + r + 1)); x++) {
141
+ for (let y = Math.max(0, Math.floor(cy - r - 1)); y <= Math.min(ROWS - 1, Math.ceil(cy + r + 1)); y++) {
142
+ if (!g.dirt[idx(x, y)]) continue;
143
+ if (Math.hypot(x + 0.5 - cx, y + 0.5 - cy) <= r) { g.dirt[idx(x, y)] = 0; removed += 1; }
144
+ }
145
+ }
146
+ if (removed) g.landVersion += 1;
147
+ return removed;
148
+ }
149
+
150
+ /** Fill every cell inside a circle with dirt (the dirt weapons). Returns how many were added. */
151
+ export function addDirt(g, cx, cy, r) {
152
+ let added = 0;
153
+ for (let x = Math.max(0, Math.floor(cx - r - 1)); x <= Math.min(COLS - 1, Math.ceil(cx + r + 1)); x++) {
154
+ for (let y = Math.max(0, Math.floor(cy - r - 1)); y <= Math.min(ROWS - 1, Math.ceil(cy + r + 1)); y++) {
155
+ if (g.dirt[idx(x, y)]) continue;
156
+ if (Math.hypot(x + 0.5 - cx, y + 0.5 - cy) <= r) { g.dirt[idx(x, y)] = 1; added += 1; }
157
+ }
158
+ }
159
+ if (added) g.landVersion += 1;
160
+ return added;
161
+ }
162
+
163
+ /**
164
+ * Dirt above a hole falls until it rests: each column in x0..x1 is compacted downward, run by
165
+ * run, and every run that moved is recorded in `g.falling` for the screen to animate.
166
+ */
167
+ export function settleDirt(g, x0 = 0, x1 = COLS - 1) {
168
+ for (let x = Math.max(0, x0); x <= Math.min(COLS - 1, x1); x++) {
169
+ let write = 0;
170
+ let y = 0;
171
+ while (y < ROWS) {
172
+ if (!g.dirt[idx(x, y)]) { y++; continue; }
173
+ const start = y;
174
+ while (y < ROWS && g.dirt[idx(x, y)]) y++;
175
+ const len = y - start;
176
+ if (start !== write) {
177
+ for (let k = 0; k < len; k++) g.dirt[idx(x, start + k)] = 0;
178
+ for (let k = 0; k < len; k++) g.dirt[idx(x, write + k)] = 1;
179
+ g.falling.push({ x, y: write, len, from: start });
180
+ }
181
+ write += len;
182
+ }
183
+ g.tops[x] = write;
184
+ }
185
+ g.landVersion += 1;
186
+ }
187
+
188
+ // ------------------------------------------------------------------- the game
189
+ /**
190
+ * A new game. `players` is a list of `{ name, kind: 'human' | 'moron' | ..., colour? }`; the
191
+ * first human plays from the keyboard, the rest are decided by scorchedai.js. Options: `seed`,
192
+ * `gravity` (1 = Earth), `wind` ('round' | 'turn' | 'shot' | 'none'), `walls` ('concrete' | 'rubber' |
193
+ * 'wrap' | 'none'), `land` (a landscape style), `rounds`, `cash` (to start), `interest` (a rate).
194
+ */
195
+ export function newGame(players, opts = {}) {
196
+ const seed = Number.isFinite(opts.seed) ? opts.seed : (Date.now() >>> 0);
197
+ const g = {
198
+ seed, rnd: rng(seed),
199
+ W: COLS, H: ROWS,
200
+ dirt: new Uint8Array(COLS * ROWS), tops: new Int16Array(COLS),
201
+ gravity: Number.isFinite(opts.gravity) ? opts.gravity : 1,
202
+ windMode: opts.wind ?? 'round', walls: opts.walls ?? 'concrete', land: opts.land ?? 'hills',
203
+ interest: Number.isFinite(opts.interest) ? opts.interest : 0.05,
204
+ wind: 0,
205
+ tanks: (players ?? []).map((p, i) => ({
206
+ id: i, name: p.name ?? `Player ${i + 1}`, kind: p.kind ?? 'human', colour: p.colour ?? TANK_COLOURS[i % TANK_COLOURS.length],
207
+ x: 0, y: 0, health: MAX_HEALTH, alive: true, angle: 45, power: 500, weapon: 'babyMissile',
208
+ inventory: { ...START_INVENTORY }, items: { ...START_ITEMS },
209
+ shield: null, // { id, hp, deflect } while one is up
210
+ armed: { contactTrigger: false, heatGuidance: false },
211
+ kills: 0, score: 0, cash: Number.isFinite(opts.cash) ? opts.cash : START_CASH, damageDealt: 0,
212
+ lastHitBy: null, // who hurt this tank last (the Cyborg holds a grudge)
213
+ memory: null, // a computer player's last shot at its target, for correcting
214
+ aimAt: null, // a computer player's shots so far at one target: its aim tightens (scorchedai.js AIM)
215
+ persona: p.kind ?? 'human', // what an Unknown is playing as this round
216
+ })),
217
+ round: 1, rounds: Math.max(1, Math.round(opts.rounds ?? 5)),
218
+ turn: 0, // index into `order`
219
+ order: [], // tank ids, this round's turn order
220
+ phase: 'aim', // 'aim' | 'flight' | 'settle' | 'roundOver' | 'over'
221
+ shells: [], // in flight: { x, y, vx, vy, weapon, owner, path, t, ... }
222
+ lastPath: [], // the last shot's trace, for the screen
223
+ falling: [], // dirt runs on their way down, for the screen
224
+ sparks: [], // events the screen makes sounds and pictures of, drained by it
225
+ shots: 0,
226
+ landVersion: 0, // bumped whenever the dirt changes: the screen redraws its land layer only then
227
+ };
228
+ if (g.tanks.length < 2) throw new RangeError('a round needs at least two tanks');
229
+ startRound(g);
230
+ return g;
231
+ }
232
+
233
+ /** Fresh land, every tank placed with full health, the turn order rotated, the wind set. */
234
+ export function startRound(g) {
235
+ generateLand(g, g.land);
236
+ placeTanks(g);
237
+ for (const t of g.tanks) {
238
+ t.health = MAX_HEALTH; t.alive = true; t.shield = null; t.armed = { contactTrigger: false, heatGuidance: false };
239
+ t.lastHitBy = null; t.memory = null; t.aimAt = null;
240
+ // an Unknown is one of the others, drawn each round and never announced (the manual)
241
+ t.persona = t.kind === 'unknown' ? UNKNOWN_POOL[Math.floor(g.rnd() * UNKNOWN_POOL.length)] : t.kind;
242
+ }
243
+ const n = g.tanks.length;
244
+ g.order = g.tanks.map((t) => t.id).map((_, i, a) => a[(i + g.round - 1) % n]);
245
+ g.turn = 0;
246
+ g.phase = 'aim';
247
+ g.shells = []; g.lastPath = []; g.falling = [];
248
+ newWind(g);
249
+ g.sparks.push({ kind: 'round', round: g.round });
250
+ autoDefend(g, current(g));
251
+ }
252
+
253
+ function placeTanks(g) {
254
+ const n = g.tanks.length;
255
+ // evenly spaced slots with a seeded jitter, shuffled, so the same seed places the same tanks
256
+ const slotW = (COLS - 8) / n;
257
+ const slots = g.tanks.map((_, i) => Math.round(4 + slotW * i + slotW * (0.25 + 0.5 * g.rnd())));
258
+ for (let i = slots.length - 1; i > 0; i--) { const j = Math.floor(g.rnd() * (i + 1)); [slots[i], slots[j]] = [slots[j], slots[i]]; }
259
+ g.tanks.forEach((t, i) => {
260
+ const x = Math.max(1, Math.min(COLS - TANK_W - 1, slots[i]));
261
+ // a level plateau under the hull: the columns it stands on take the lower of their tops
262
+ const top = Math.min(g.tops[x], g.tops[x + 1]);
263
+ for (let cx = x; cx < x + TANK_W; cx++) {
264
+ for (let y = top; y < ROWS; y++) g.dirt[idx(cx, y)] = 0;
265
+ for (let y = 0; y < top; y++) g.dirt[idx(cx, y)] = 1;
266
+ }
267
+ t.x = x; t.y = top;
268
+ t.angle = x < COLS / 2 ? 45 : 135;
269
+ t.power = 500;
270
+ });
271
+ retop(g);
272
+ g.landVersion += 1;
273
+ }
274
+
275
+ /**
276
+ * A NEW WIND. It is drawn at the start of every round; what happens to it inside the round is the
277
+ * mode's business (operator, 2026-09-16: first "it needs to consistently move in one direction, not
278
+ * shift back and forth during the same round", then "the wind speed did not change the entire
279
+ * game").
280
+ *
281
+ * round the shipped mode. The round's DIRECTION is drawn once and held to the end of it; the
282
+ * STRENGTH is drawn afresh for each tank's turn. The air over a round blows one way, and
283
+ * how hard is still a thing to read before every shot.
284
+ * turn the original's: direction and strength both fresh every turn.
285
+ * shot redrawn at every shot.
286
+ * none still air.
287
+ *
288
+ * `sign`, when given, keeps the direction and rolls only the strength.
289
+ */
290
+ function newWind(g, sign = null) {
291
+ if (g.windMode === 'none') { g.wind = 0; return; }
292
+ const mag = Math.round(g.rnd() * WIND_MAX * 10) / 10;
293
+ const dir = sign ?? (g.rnd() < 0.5 ? -1 : 1);
294
+ g.wind = Math.round(mag * dir * 10) / 10;
295
+ }
296
+
297
+ export const current = (g) => g.tanks[g.order[g.turn]];
298
+ export const alive = (g) => g.tanks.filter((t) => t.alive);
299
+ const enemiesOf = (g, id) => g.tanks.filter((t) => t.alive && t.id !== id);
300
+ const centre = (t) => ({ x: t.x + TANK_W / 2, y: t.y + 0.7 });
301
+
302
+ /** Aim: angle 0..180 (90 straight up), power 0..1000, clamped. */
303
+ export function aim(g, tank, { angle, power, weapon } = {}) {
304
+ if (angle != null) tank.angle = Math.max(0, Math.min(180, Math.round(angle)));
305
+ if (power != null) tank.power = Math.max(0, Math.min(1000, Math.round(power)));
306
+ if (weapon != null && WEAPONS[weapon]) tank.weapon = weapon;
307
+ }
308
+
309
+ /** The next weapon in the cycle that the tank still has, `dir` +1 or -1. */
310
+ export function cycleWeapon(tank, dir = 1) {
311
+ const i = WEAPON_ORDER.indexOf(tank.weapon);
312
+ const n = WEAPON_ORDER.length;
313
+ for (let k = 1; k <= n; k++) {
314
+ const w = WEAPON_ORDER[(((i + dir * k) % n) + n) % n];
315
+ if ((tank.inventory[w] ?? 0) > 0) { tank.weapon = w; return w; }
316
+ }
317
+ return tank.weapon;
318
+ }
319
+
320
+ /** The barrel's muzzle, where a shell starts. */
321
+ export function muzzle(tank) {
322
+ const a = (tank.angle * Math.PI) / 180;
323
+ const cx = tank.x + TANK_W / 2, cy = tank.y + 1.1;
324
+ return { x: cx + Math.cos(a) * 1.3, y: cy + Math.sin(a) * 1.3 };
325
+ }
326
+
327
+ // ------------------------------------------------------------------- items, on your turn
328
+ const SHIELD_PREFERENCE = ['heavyShield', 'forceShield', 'shield'];
329
+
330
+ /**
331
+ * Use an item on your turn: a battery heals, a shield goes up (the named one, or the best owned
332
+ * for 'shield'), the two arming items toggle for the next shot. Returns false when the tank has
333
+ * none, or it is not the moment.
334
+ */
335
+ export function useItem(g, tank, id) {
336
+ if (g.phase !== 'aim' || tank !== current(g) || !tank.alive) return false;
337
+ const item = ITEMS[id];
338
+ if (!item) return false;
339
+ if (item.kind === 'arm') {
340
+ if ((tank.items[id] ?? 0) <= 0) return false;
341
+ tank.armed[id] = !tank.armed[id];
342
+ return true;
343
+ }
344
+ if (item.kind === 'battery') {
345
+ if ((tank.items.battery ?? 0) <= 0 || tank.health >= MAX_HEALTH) return false;
346
+ tank.items.battery -= 1;
347
+ tank.health = Math.min(MAX_HEALTH, tank.health + item.heal);
348
+ g.sparks.push({ kind: 'battery', tank: tank.id });
349
+ return true;
350
+ }
351
+ if (item.kind === 'shield') return raiseShield(g, tank, (tank.items[id] ?? 0) > 0 ? id : null);
352
+ return false;
353
+ }
354
+
355
+ /** Put up a shield: the named one, or the best the tank owns. */
356
+ export function raiseShield(g, tank, id = null) {
357
+ if (tank.shield) return false;
358
+ const pick = id ?? SHIELD_PREFERENCE.find((k) => (tank.items[k] ?? 0) > 0);
359
+ if (!pick || (tank.items[pick] ?? 0) <= 0) return false;
360
+ tank.items[pick] -= 1;
361
+ tank.shield = { id: pick, hp: ITEMS[pick].hp };
362
+ g.sparks.push({ kind: 'shield', tank: tank.id, item: pick });
363
+ return true;
364
+ }
365
+
366
+ function autoDefend(g, tank) {
367
+ if (tank?.alive && (tank.items.autoDefense ?? 0) > 0 && !tank.shield) raiseShield(g, tank);
368
+ }
369
+
370
+ /** Drive a cell left or right with a unit of fuel, following the ground; a cliff is a fall. */
371
+ export function drive(g, tank, dir) {
372
+ if (g.phase !== 'aim' || tank !== current(g) || !tank.alive) return false;
373
+ if ((tank.items.fuel ?? 0) <= 0) return false;
374
+ const nx = tank.x + (dir < 0 ? -1 : 1);
375
+ if (nx < 0 || nx + TANK_W > COLS) return false;
376
+ const ground = Math.min(g.tops[nx], g.tops[nx + 1]);
377
+ if (ground - tank.y > 2) return false; // too steep to climb
378
+ tank.items.fuel -= 1;
379
+ tank.x = nx;
380
+ tank.y = Math.max(tank.y, ground);
381
+ landTanks(g); // off a cliff: it falls, and may be hurt
382
+ g.sparks.push({ kind: 'drive', tank: tank.id, x: tank.x });
383
+ return true;
384
+ }
385
+
386
+ // ------------------------------------------------------------------- firing
387
+ /**
388
+ * Fire the current tank's weapon. Returns false if it is not the moment (a shell in flight, a
389
+ * dead tank, an empty slot). The shell then flies through `step()`; the laser is over at once.
390
+ */
391
+ export function fire(g, tank = current(g)) {
392
+ if (g.phase !== 'aim' || !tank?.alive || tank !== current(g)) return false;
393
+ if ((tank.inventory[tank.weapon] ?? 0) <= 0) { cycleWeapon(tank, 1); if ((tank.inventory[tank.weapon] ?? 0) <= 0) return false; }
394
+ const w = WEAPONS[tank.weapon];
395
+ if (tank.weapon !== 'babyMissile') tank.inventory[tank.weapon] -= 1; // the baby missile is the original's bottomless one
396
+ if (g.windMode === 'shot') newWind(g);
397
+ g.shots += 1;
398
+ g.sparks.push({ kind: 'fire', tank: tank.id, weapon: tank.weapon });
399
+ // the arming items, one of each spent per shot they are armed for
400
+ const armed = {};
401
+ for (const k of ['contactTrigger', 'heatGuidance']) {
402
+ if (tank.armed[k] && (tank.items[k] ?? 0) > 0) { tank.items[k] -= 1; armed[k] = true; }
403
+ if ((tank.items[k] ?? 0) <= 0) tank.armed[k] = false;
404
+ }
405
+ if (w.kind === 'laser' || w.kind === 'wedge' || w.kind === 'disrupter' || w.kind === 'plasma') {
406
+ if (w.kind === 'laser') laser(g, tank, w);
407
+ else if (w.kind === 'wedge') wedge(g, tank, w);
408
+ else if (w.kind === 'disrupter') disrupt(g, tank);
409
+ else plasma(g, tank, w);
410
+ g.lastPath = [];
411
+ g.phase = 'settle';
412
+ if (!g.falling.length) settled(g);
413
+ return true;
414
+ }
415
+ const a = (tank.angle * Math.PI) / 180;
416
+ const v = (tank.power / 1000) * V_MAX;
417
+ const m = muzzle(tank);
418
+ g.shells = [{
419
+ x: m.x, y: m.y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, weapon: tank.weapon, owner: tank.id, path: [{ x: m.x, y: m.y }], t: 0,
420
+ hops: w.hops ?? 0, contact: !!armed.contactTrigger, heat: !!armed.heatGuidance, primary: true,
421
+ }];
422
+ g.phase = 'flight';
423
+ return true;
424
+ }
425
+
426
+ /** The shell's positions with no walls in the way (the AI's oracle, and a test's). */
427
+ export function trajectory(g, tank, secs = 8, dt = 1 / 60) {
428
+ const a = (tank.angle * Math.PI) / 180;
429
+ const v = (tank.power / 1000) * V_MAX;
430
+ const m = muzzle(tank);
431
+ let x = m.x, y = m.y, vx = Math.cos(a) * v, vy = Math.sin(a) * v;
432
+ const pts = [{ x, y }];
433
+ const gy = GRAVITY * g.gravity, wx = g.wind * WIND_ACCEL;
434
+ for (let t = 0; t < secs; t += dt) {
435
+ vx += wx * dt; vy -= gy * dt; x += vx * dt; y += vy * dt;
436
+ pts.push({ x, y });
437
+ if (y < 0) break;
438
+ }
439
+ return pts;
440
+ }
441
+
442
+ /**
443
+ * Where a plain shell fired now would end up, with the walls, the dirt and the tanks in the way,
444
+ * and nothing changed: the computer players plan with this (docs/PLAN-SCORCHED-YARD.md §6).
445
+ * Returns `{ x, y, hit }` -- the impact, and the id of the tank it struck, if any -- and `lost`
446
+ * when the shell left the field or flew for longer than `secs`.
447
+ */
448
+ export function simulateShot(g, tank, angle, power, secs = 14) {
449
+ const a = (angle * Math.PI) / 180;
450
+ const v = (Math.max(0, Math.min(1000, power)) / 1000) * V_MAX;
451
+ const m = muzzle({ ...tank, angle });
452
+ let x = m.x, y = m.y, vx = Math.cos(a) * v, vy = Math.sin(a) * v, t = 0;
453
+ const gy = GRAVITY * g.gravity, wx = g.wind * WIND_ACCEL;
454
+ const h = 1 / 120;
455
+ while (t < secs) {
456
+ vx += wx * h; vy -= gy * h; x += vx * h; y += vy * h; t += h;
457
+ if (x < 0 || x >= COLS) {
458
+ if (g.walls === 'rubber') { x = x < 0 ? -x : 2 * COLS - x - 1e-3; vx = -vx * 0.8; }
459
+ else if (g.walls === 'spring') { x = x < 0 ? -x : 2 * COLS - x - 1e-3; vx = -vx * 1.15; }
460
+ else if (g.walls === 'padded') { x = clampX(x); vx = 0; }
461
+ else if (g.walls === 'wrap') { x = ((x % COLS) + COLS) % COLS; }
462
+ else if (g.walls === 'none') return { x, y, hit: null, lost: true };
463
+ else return { x: clampX(x), y, hit: null, lost: false };
464
+ }
465
+ if (y < 0) return { x, y: 0, hit: null, lost: false };
466
+ if (y < ROWS && dirtAt(g, Math.floor(x), Math.floor(y))) return { x, y, hit: null, lost: false };
467
+ const hit = tankAt(g, x, y, t < 0.12 ? tank.id : -1, 0.2);
468
+ if (hit) return { x, y, hit: hit.id, lost: false };
469
+ }
470
+ return { x, y, hit: null, lost: true };
471
+ }
472
+
473
+ /**
474
+ * Advance the game by dtMs. Returns the events of the step (the screen makes sounds and pictures
475
+ * of them). Substepped so a shell never crosses more than MAX_STEP a hop, whatever the frame.
476
+ */
477
+ export function step(g, dtMs) {
478
+ const events = [];
479
+ if (g.phase === 'flight') {
480
+ const dt = Math.min(100, Math.max(0, dtMs)) / 1000; // a frame, capped: a tab that slept catches up in hops, not a leap
481
+ const fastest = Math.max(1, ...g.shells.map((s) => Math.hypot(s.vx, s.vy)), ROLL_SPEED, BORE_SPEED);
482
+ const n = Math.max(1, Math.ceil((fastest * dt) / MAX_STEP) + 1);
483
+ const h = dt / n;
484
+ for (let i = 0; i < n && g.phase === 'flight'; i++) {
485
+ for (const s of [...g.shells]) flightStep(g, s, h, events);
486
+ if (!g.shells.length) endFlight(g);
487
+ }
488
+ }
489
+ if (g.sparks.length) { events.push(...g.sparks); g.sparks = []; }
490
+ return events;
491
+ }
492
+
493
+ const weaponOf = (id) => WEAPONS[id] ?? (id === 'funkyBomblet' ? BOMBLET : WEAPONS.babyMissile);
494
+ const removeShell = (g, s) => { const i = g.shells.indexOf(s); if (i >= 0) g.shells.splice(i, 1); if (s.primary) g.lastPath = s.path; };
495
+
496
+ function flightStep(g, s, h, events) {
497
+ const w = weaponOf(s.weapon);
498
+ // ---- rolling along the ground (a Roller after it lands)
499
+ if (s.rolling) {
500
+ const col = Math.max(0, Math.min(COLS - 1, Math.floor(s.x)));
501
+ const ground = g.tops[col];
502
+ s.x += s.dir * ROLL_SPEED * h; s.rollLeft -= ROLL_SPEED * h; s.t += h;
503
+ const nc = Math.floor(s.x);
504
+ if (nc < 0 || nc >= COLS) { blastAt(g, s, clampX(s.x), ground); return; }
505
+ s.y = g.tops[nc] + 0.3;
506
+ if (s.path.length < 600) s.path.push({ x: s.x, y: s.y });
507
+ if (tankAt(g, s.x, s.y, s.owner, 0.3)) { blastAt(g, s, s.x, s.y); return; }
508
+ if (nc !== col) {
509
+ const next = g.tops[nc];
510
+ if (next > ground + 1) { blastAt(g, s, col + 0.5, ground); return; } // a wall in the way: it stops there
511
+ const ahead = g.tops[Math.max(0, Math.min(COLS - 1, nc + s.dir))];
512
+ if (ahead > next && ground > next) { blastAt(g, s, nc + 0.5, next); return; } // the bottom of a dip
513
+ }
514
+ if (s.rollLeft <= 0) blastAt(g, s, s.x, s.y);
515
+ return;
516
+ }
517
+ // ---- boring through dirt (a Digger or a Sandhog after it lands)
518
+ if (s.boring) {
519
+ const d = BORE_SPEED * h;
520
+ s.x += s.boring.dx * d; s.y += s.boring.dy * d; s.boring.left -= d; s.t += h;
521
+ if (s.path.length < 600) s.path.push({ x: s.x, y: s.y });
522
+ if (s.x >= 0 && s.x < COLS && s.y >= 0) crater(g, s.x, s.y, w.radius * 0.7);
523
+ if (s.x < 0 || s.x >= COLS || s.y < 0.5 || s.boring.left <= 0 || tankAt(g, s.x, s.y, s.owner, 0.2)) {
524
+ blastAt(g, s, clampX(s.x), Math.max(0, s.y));
525
+ }
526
+ return;
527
+ }
528
+ // ---- in the air
529
+ s.vx += g.wind * WIND_ACCEL * h;
530
+ s.vy -= GRAVITY * g.gravity * h;
531
+ if (s.heat && s.vy < 0) {
532
+ const target = nearestEnemy(g, s.owner, s.x);
533
+ if (target) s.vx += Math.sign(centre(target).x - s.x) * HEAT_PULL * h;
534
+ }
535
+ for (const t of enemiesOf(g, s.owner)) {
536
+ const mag = (t.items.superMag ?? 0) > 0 ? ITEMS.superMag : (t.items.magDeflector ?? 0) > 0 ? ITEMS.magDeflector : null;
537
+ if (!mag) continue;
538
+ const c = centre(t);
539
+ const d = Math.hypot(s.x - c.x, s.y - c.y);
540
+ if (d < mag.reach && d > 0.1) { const k = mag.push * (1 - d / mag.reach) * h; s.vx += ((s.x - c.x) / d) * k; s.vy += ((s.y - c.y) / d) * k; }
541
+ }
542
+ s.x += s.vx * h; s.y += s.vy * h; s.t += h;
543
+ if (s.path.length < 600) s.path.push({ x: s.x, y: s.y });
544
+ // a MIRV splits at its apex
545
+ if (w.kind === 'mirv' && !s.split && s.vy <= 0 && s.t > 0.2) {
546
+ const n = w.heads;
547
+ for (let i = 0; i < n; i++) {
548
+ const spread = (i - (n - 1) / 2) * (n > 5 ? 2 : 3);
549
+ g.shells.push({ ...s, path: [{ x: s.x, y: s.y }], vx: s.vx + spread, primary: false, split: true });
550
+ }
551
+ removeShell(g, s);
552
+ return;
553
+ }
554
+ // the walls, by mode
555
+ if (s.x < 0 || s.x >= COLS) {
556
+ if (g.walls === 'rubber') { s.x = s.x < 0 ? -s.x : 2 * COLS - s.x - 1e-3; s.vx = -s.vx * 0.8; events.push({ kind: 'bounce' }); }
557
+ else if (g.walls === 'spring') { s.x = s.x < 0 ? -s.x : 2 * COLS - s.x - 1e-3; s.vx = -s.vx * 1.15; events.push({ kind: 'bounce' }); }
558
+ else if (g.walls === 'padded') { s.x = clampX(s.x); s.vx = 0; events.push({ kind: 'bounce' }); } // it stops dead and drops
559
+ else if (g.walls === 'wrap') { s.x = ((s.x % COLS) + COLS) % COLS; }
560
+ else if (g.walls === 'none') { g.sparks.push({ kind: 'lost' }); removeShell(g, s); return; }
561
+ else { s.x = clampX(s.x); impact(g, s, s.x, s.y); return; }
562
+ }
563
+ // a contact trigger goes off within reach of a tank
564
+ if (s.contact) {
565
+ for (const t of enemiesOf(g, s.owner)) {
566
+ const c = centre(t);
567
+ if (Math.hypot(s.x - c.x, s.y - c.y) < 2) { impact(g, s, s.x, s.y); return; }
568
+ }
569
+ }
570
+ if (s.y < 0) { impact(g, s, s.x, 0); return; }
571
+ if (s.y < ROWS && dirtAt(g, Math.floor(s.x), Math.floor(s.y))) { impact(g, s, s.x, s.y); return; }
572
+ if (tankAt(g, s.x, s.y, s.t < 0.12 ? s.owner : -1, 0.2)) impact(g, s, s.x, s.y);
573
+ }
574
+
575
+ /** The living tank whose hull box (grown by `pad`) holds the point, skipping `skipId`. */
576
+ function tankAt(g, x, y, skipId, pad = 0) {
577
+ for (const t of g.tanks) {
578
+ if (!t.alive || t.id === skipId) continue;
579
+ if (x >= t.x - pad && x <= t.x + TANK_W + pad && y >= t.y - pad && y <= t.y + TANK_H + pad) return t;
580
+ }
581
+ return null;
582
+ }
583
+ function nearestEnemy(g, ownerId, x) {
584
+ return enemiesOf(g, ownerId).sort((a, b) => Math.abs(centre(a).x - x) - Math.abs(centre(b).x - x))[0] ?? null;
585
+ }
586
+
587
+ /** A shell has arrived somewhere: what its kind does there. */
588
+ function impact(g, s, x, y) {
589
+ const w = weaponOf(s.weapon);
590
+ switch (w.kind) {
591
+ case 'tracer':
592
+ g.sparks.push({ kind: 'tracer', x, y, smoke: !!w.smoke });
593
+ removeShell(g, s);
594
+ return;
595
+ case 'riot':
596
+ crater(g, x, y, w.radius);
597
+ g.sparks.push({ kind: 'blast', x, y, radius: w.radius, weapon: w.name, riot: true });
598
+ settleDirt(g, Math.floor(x - w.radius - 1), Math.ceil(x + w.radius + 1));
599
+ landTanks(g);
600
+ afterBlast(g, s.owner, 0);
601
+ removeShell(g, s);
602
+ return;
603
+ case 'dirt': {
604
+ const added = addDirt(g, x, y, w.radius);
605
+ g.sparks.push({ kind: 'dirt', x, y, radius: w.radius, added });
606
+ settleDirt(g, Math.floor(x - w.radius - 1), Math.ceil(x + w.radius + 1));
607
+ landTanks(g);
608
+ afterBlast(g, s.owner, 0);
609
+ removeShell(g, s);
610
+ return;
611
+ }
612
+ case 'napalm':
613
+ flow(g, x, y, w, s.owner);
614
+ removeShell(g, s);
615
+ return;
616
+ case 'roller': {
617
+ const col = Math.max(0, Math.min(COLS - 1, Math.floor(x)));
618
+ const left = g.tops[Math.max(0, col - 1)], right = g.tops[Math.min(COLS - 1, col + 1)];
619
+ s.rolling = true; s.dir = left < right ? -1 : right < left ? 1 : (s.vx < 0 ? -1 : 1);
620
+ s.rollLeft = 60; s.y = g.tops[col] + 0.3; s.x = col + 0.5;
621
+ g.sparks.push({ kind: 'roll', x: s.x, y: s.y });
622
+ return;
623
+ }
624
+ case 'digger':
625
+ s.boring = { dx: 0, dy: -1, left: w.bore }; s.vx = 0; s.vy = 0;
626
+ g.sparks.push({ kind: 'bore', x, y });
627
+ return;
628
+ case 'sandhog': {
629
+ const sp = Math.hypot(s.vx, s.vy) || 1;
630
+ s.boring = { dx: s.vx / sp, dy: Math.min(-0.15, s.vy / sp), left: w.bore };
631
+ g.sparks.push({ kind: 'bore', x, y });
632
+ return;
633
+ }
634
+ case 'funky': {
635
+ blastAt(g, s, x, y, { keep: true, scale: 0.6 });
636
+ for (let i = 0; i < w.bomblets; i++) {
637
+ const vx = (g.rnd() * 2 - 1) * 10, vy = 8 + g.rnd() * 10;
638
+ g.shells.push({ x, y: y + 0.5, vx, vy, weapon: 'funkyBomblet', owner: s.owner, path: [{ x, y }], t: 0, primary: false, hops: 0 });
639
+ }
640
+ removeShell(g, s);
641
+ return;
642
+ }
643
+ case 'leapfrog': {
644
+ const hop = (w.radii?.length ?? 0) - s.hops; // 0, 1, 2
645
+ const r = w.radii?.[Math.max(0, Math.min((w.radii?.length ?? 1) - 1, hop))] ?? w.radius;
646
+ blastAt(g, s, x, y, { keep: s.hops > 1, radius: r });
647
+ if (s.hops > 1) {
648
+ s.hops -= 1;
649
+ const col = Math.max(0, Math.min(COLS - 1, Math.floor(x)));
650
+ s.y = g.tops[col] + 0.6; s.vy = Math.abs(s.vy) * 0.55 + 6; s.vx *= 0.85; s.t = 0;
651
+ }
652
+ return;
653
+ }
654
+ case 'liquidDirt':
655
+ ooze(g, x, y, w, s.owner);
656
+ removeShell(g, s);
657
+ return;
658
+ default:
659
+ blastAt(g, s, x, y);
660
+ }
661
+ }
662
+
663
+ function blastAt(g, s, x, y, { keep = false, scale = 1, radius = null } = {}) {
664
+ const w = weaponOf(s.weapon);
665
+ explode(g, x, y, { name: w.name, radius: (radius ?? w.radius) * scale, damage: w.damage * scale }, s.owner);
666
+ if (!keep) removeShell(g, s);
667
+ }
668
+
669
+ /**
670
+ * Liquid dirt: drops that ooze downhill along the surface and set where they pool, filling the
671
+ * holes. Simulated at once, like napalm; the dirt it leaves is real.
672
+ */
673
+ function ooze(g, x, y, w, ownerId) {
674
+ const drops = [];
675
+ for (let i = 0; i < w.drops; i++) drops.push({ x: Math.max(0, Math.min(COLS - 1, Math.floor(x + (g.rnd() * 2 - 1) * 1.5))), alive: true });
676
+ let added = 0;
677
+ let lo = COLS, hi = 0;
678
+ for (let stepN = 0; stepN < w.steps; stepN++) {
679
+ for (const d of drops) {
680
+ if (!d.alive) continue;
681
+ const top = g.tops[d.x];
682
+ const l = d.x > 0 ? g.tops[d.x - 1] : Infinity, r = d.x < COLS - 1 ? g.tops[d.x + 1] : Infinity;
683
+ if (l < top && l <= r) d.x -= 1;
684
+ else if (r < top) d.x += 1;
685
+ else if (top < ROWS - 1) { // pooled: it sets here
686
+ g.dirt[idx(d.x, top)] = 1; g.tops[d.x] = top + 1; added += 1; d.alive = false;
687
+ lo = Math.min(lo, d.x); hi = Math.max(hi, d.x);
688
+ } else d.alive = false;
689
+ }
690
+ }
691
+ if (added) g.landVersion += 1;
692
+ g.sparks.push({ kind: 'dirt', x, y, radius: 1.5, added, ooze: true });
693
+ landTanks(g);
694
+ afterBlast(g, ownerId, 0);
695
+ }
696
+
697
+ /**
698
+ * A wedge from the turret, at once (Riot Charge, Riot Blast; Dirt Charge with `dig` false):
699
+ * every cell within `radius` of the muzzle and within `spread` degrees of the barrel's line is
700
+ * cleared, or filled.
701
+ */
702
+ function wedge(g, tank, w) {
703
+ const m = muzzle(tank);
704
+ const a = (tank.angle * Math.PI) / 180;
705
+ const spread = (w.spread * Math.PI) / 180;
706
+ let changed = 0;
707
+ for (let x = Math.max(0, Math.floor(m.x - w.radius - 1)); x <= Math.min(COLS - 1, Math.ceil(m.x + w.radius + 1)); x++) {
708
+ for (let y = Math.max(0, Math.floor(m.y - w.radius - 1)); y <= Math.min(ROWS - 1, Math.ceil(m.y + w.radius + 1)); y++) {
709
+ const dx = x + 0.5 - m.x, dy = y + 0.5 - m.y;
710
+ const d = Math.hypot(dx, dy);
711
+ if (d > w.radius || d < 0.3) continue;
712
+ let da = Math.atan2(dy, dx) - a;
713
+ while (da > Math.PI) da -= 2 * Math.PI;
714
+ while (da < -Math.PI) da += 2 * Math.PI;
715
+ if (Math.abs(da) > spread) continue;
716
+ if (w.dig && g.dirt[idx(x, y)]) { g.dirt[idx(x, y)] = 0; changed += 1; }
717
+ else if (!w.dig && !g.dirt[idx(x, y)]) { g.dirt[idx(x, y)] = 1; changed += 1; }
718
+ }
719
+ }
720
+ if (changed) g.landVersion += 1;
721
+ g.sparks.push({ kind: w.dig ? 'blast' : 'dirt', x: m.x + Math.cos(a) * w.radius * 0.5, y: m.y + Math.sin(a) * w.radius * 0.5, radius: w.radius * 0.6, weapon: w.name, riot: true, wedge: true, removed: changed, added: changed });
722
+ settleDirt(g, Math.floor(m.x - w.radius - 1), Math.ceil(m.x + w.radius + 1));
723
+ landTanks(g);
724
+ afterBlast(g, tank.id, 0);
725
+ }
726
+
727
+ /** The Earth Disrupter: every hanging piece of dirt on the field settles. */
728
+ function disrupt(g, tank) {
729
+ settleDirt(g, 0, COLS - 1);
730
+ g.sparks.push({ kind: 'disrupt', tank: tank.id, fell: g.falling.length });
731
+ landTanks(g);
732
+ afterBlast(g, tank.id, 0);
733
+ }
734
+
735
+ /** The Plasma Blast: energy thrown from the tank itself, its reach set by the power; the thrower is spared. */
736
+ function plasma(g, tank, w) {
737
+ const r = w.minRadius + (w.radius - w.minRadius) * (tank.power / 1000);
738
+ const c = centre(tank);
739
+ explode(g, c.x, c.y, { name: w.name, radius: r, damage: w.damage }, tank.id, { spare: tank.id, dig: false }); // energy, not a crater
740
+ }
741
+
742
+ function endFlight(g) {
743
+ g.shells = [];
744
+ g.phase = 'settle';
745
+ if (!g.falling.length) settled(g);
746
+ }
747
+
748
+ /** The laser: a straight line from the muzzle, at once; dirt along it goes, tanks on it burn. */
749
+ function laser(g, tank, w) {
750
+ const a = (tank.angle * Math.PI) / 180;
751
+ const m = muzzle(tank);
752
+ const dx = Math.cos(a), dy = Math.sin(a);
753
+ let x = m.x, y = m.y, d = 0;
754
+ const burnt = new Set();
755
+ while (x >= 0 && x < COLS && y >= 0 && y < ROWS + 2 && d < 200) {
756
+ crater(g, x, y, w.radius);
757
+ const t = tankAt(g, x, y, tank.id, 0.2);
758
+ if (t && !burnt.has(t.id)) { burnt.add(t.id); applyDamage(g, t, w.damage, tank.id); }
759
+ x += dx * 0.25; y += dy * 0.25; d += 0.25;
760
+ }
761
+ g.sparks.push({ kind: 'laser', x0: m.x, y0: m.y, x1: x, y1: y });
762
+ settleDirt(g, Math.floor(Math.min(m.x, x)) - 1, Math.ceil(Math.max(m.x, x)) + 1);
763
+ landTanks(g);
764
+ afterBlast(g, tank.id, 0);
765
+ }
766
+
767
+ /**
768
+ * Napalm: drops that run downhill along the surface and burn whatever they sit on, spent when
769
+ * they pool in a dip. Simulated at once; the screen animates the cells it is told about.
770
+ */
771
+ function flow(g, x, y, w, ownerId) {
772
+ const cells = [];
773
+ const drops = [];
774
+ for (let i = 0; i < w.drops; i++) drops.push({ x: Math.max(0, Math.min(COLS - 1, Math.floor(x + (g.rnd() * 2 - 1) * 1.5))), alive: true });
775
+ const burnt = new Map();
776
+ for (let stepN = 0; stepN < w.steps; stepN++) {
777
+ for (const d of drops) {
778
+ if (!d.alive) continue;
779
+ const top = g.tops[d.x];
780
+ cells.push({ x: d.x, y: top, step: stepN });
781
+ const t = tankAt(g, d.x + 0.5, top + 0.5, -1, 0.6);
782
+ if (t) {
783
+ const so = burnt.get(t.id) ?? 0;
784
+ if (so < w.damage * 6) { applyDamage(g, t, w.damage, ownerId); burnt.set(t.id, so + w.damage); }
785
+ }
786
+ const l = d.x > 0 ? g.tops[d.x - 1] : Infinity, r = d.x < COLS - 1 ? g.tops[d.x + 1] : Infinity;
787
+ if (l < top && l <= r) d.x -= 1;
788
+ else if (r < top) d.x += 1;
789
+ else if (g.rnd() < 0.35) d.alive = false; // pooled: it burns out
790
+ }
791
+ }
792
+ g.sparks.push({ kind: 'napalm', cells, x, y });
793
+ landTanks(g);
794
+ afterBlast(g, ownerId, 0);
795
+ }
796
+
797
+ /**
798
+ * A blast: the dirt inside the circle goes, tanks in reach take damage by distance, the dirt
799
+ * above the crater falls, tanks left in the air fall with it. Deaths are handled, and can chain.
800
+ */
801
+ export function explode(g, cx, cy, weapon, ownerId, { chain = 0, spare = -1, dig = true } = {}) {
802
+ const r = weapon.radius;
803
+ const removed = dig ? crater(g, cx, cy, r) : 0;
804
+ g.sparks.push({ kind: 'blast', x: cx, y: cy, radius: r, weapon: weapon.name, removed });
805
+ // damage: full inside half the radius, falling to nothing a cell beyond the rim
806
+ for (const t of g.tanks) {
807
+ if (!t.alive || t.id === spare) continue;
808
+ const d = distToTank(cx, cy, t);
809
+ if (d > r + 1) continue;
810
+ const k = d <= r * 0.5 ? 1 : Math.max(0, 1 - (d - r * 0.5) / (r * 0.5 + 1));
811
+ const dmg = Math.round(weapon.damage * k);
812
+ if (dmg > 0) applyDamage(g, t, dmg, ownerId);
813
+ }
814
+ settleDirt(g, Math.floor(cx - r - 1), Math.ceil(cx + r + 1));
815
+ landTanks(g);
816
+ afterBlast(g, ownerId, chain);
817
+ }
818
+
819
+ /** Damage through a shield first; cash to the attacker for every point of health that went. */
820
+ export function applyDamage(g, t, dmg, byId) {
821
+ let left = dmg;
822
+ let absorbed = 0;
823
+ if (t.shield) {
824
+ absorbed = Math.min(t.shield.hp, left);
825
+ t.shield.hp -= absorbed;
826
+ left -= absorbed;
827
+ if (t.shield.hp <= 0) { t.shield = null; g.sparks.push({ kind: 'shieldDown', tank: t.id }); }
828
+ }
829
+ const before = t.health;
830
+ t.health = Math.max(0, t.health - left);
831
+ const lost = before - t.health;
832
+ const by = g.tanks[byId] ?? null;
833
+ if (by && by !== t) { by.damageDealt += lost; by.cash += lost * CASH_PER_DAMAGE; if (lost > 0) t.lastHitBy = byId; }
834
+ g.sparks.push({ kind: 'hit', tank: t.id, damage: lost, absorbed, by: byId });
835
+ }
836
+
837
+ function afterBlast(g, ownerId, chain) {
838
+ for (const t of g.tanks) if (t.alive && t.health <= 0) kill(g, t, ownerId, chain);
839
+ }
840
+
841
+ function distToTank(cx, cy, t) {
842
+ const nx = Math.max(t.x, Math.min(t.x + TANK_W, cx)), ny = Math.max(t.y, Math.min(t.y + TANK_H, cy));
843
+ return Math.hypot(cx - nx, cy - ny);
844
+ }
845
+
846
+ /** Tanks left in the air fall to the ground under them; a fall of more than a cell hurts, unless a parachute opens. */
847
+ export function landTanks(g) {
848
+ for (const t of g.tanks) {
849
+ if (!t.alive) continue;
850
+ const ground = Math.min(g.tops[t.x], g.tops[Math.min(COLS - 1, t.x + 1)]);
851
+ if (ground >= t.y) {
852
+ if (ground > t.y) t.y = ground; // buried by dirt that landed on it: dug out to the surface
853
+ continue;
854
+ }
855
+ const drop = t.y - ground;
856
+ t.y = ground;
857
+ if (drop > 1 && (t.items.parachute ?? 0) > 0) {
858
+ t.items.parachute -= 1;
859
+ g.sparks.push({ kind: 'chute', tank: t.id, cells: drop });
860
+ continue;
861
+ }
862
+ const dmg = Math.max(0, Math.round((drop - 1) * FALL_DAMAGE));
863
+ if (dmg > 0) t.health = Math.max(0, t.health - dmg);
864
+ g.sparks.push({ kind: 'fall', tank: t.id, cells: drop, damage: dmg });
865
+ }
866
+ }
867
+
868
+ function kill(g, t, byId, chain) {
869
+ t.alive = false;
870
+ t.health = 0;
871
+ t.shield = null;
872
+ const by = g.tanks[byId] ?? null;
873
+ if (by && by !== t) { by.kills += 1; by.score += 100; by.cash += KILL_BONUS; }
874
+ else t.score -= 50; // by its own shot, or the fall
875
+ g.sparks.push({ kind: 'death', tank: t.id, by: byId });
876
+ // the death blast, which can take a neighbour with it (chained at most as deep as there are tanks)
877
+ if (chain < g.tanks.length) explode(g, t.x + TANK_W / 2, t.y + 0.5, DEATH_BLAST, t.id, { chain: chain + 1 });
878
+ }
879
+
880
+ /** The screen has finished animating the fall (or a test skips it): the turn passes. */
881
+ export function settled(g) {
882
+ g.falling = [];
883
+ if (g.phase !== 'settle') return;
884
+ const living = alive(g);
885
+ if (living.length <= 1) {
886
+ g.phase = 'roundOver';
887
+ const w = living[0] ?? null;
888
+ if (w) { w.score += 200; w.cash += SURVIVOR_BONUS; }
889
+ g.sparks.push({ kind: 'roundOver', winner: w?.id ?? null, round: g.round, last: g.round >= g.rounds });
890
+ return;
891
+ }
892
+ nextTurn(g);
893
+ }
894
+
895
+ export function nextTurn(g) {
896
+ const n = g.order.length;
897
+ for (let k = 1; k <= n; k++) {
898
+ const i = (g.turn + k) % n;
899
+ if (g.tanks[g.order[i]].alive) { g.turn = i; break; }
900
+ }
901
+ // the round mode keeps this round's direction and rolls the strength again; the original's mode
902
+ // rolls both; the others leave it alone
903
+ if (g.windMode === 'turn') newWind(g);
904
+ else if (g.windMode === 'round') newWind(g, g.wind < 0 ? -1 : 1);
905
+ g.phase = 'aim';
906
+ const t = current(g);
907
+ autoDefend(g, t);
908
+ g.sparks.push({ kind: 'turn', tank: t.id });
909
+ }
910
+
911
+ /** After a round: interest is paid, then the next one, or the game is over. */
912
+ export function nextRound(g) {
913
+ if (g.phase !== 'roundOver') return false;
914
+ if (g.round >= g.rounds) { g.phase = 'over'; g.sparks.push({ kind: 'over', winner: leader(g)?.id ?? null }); return false; }
915
+ for (const t of g.tanks) payInterest(t, g.interest);
916
+ g.round += 1;
917
+ startRound(g);
918
+ return true;
919
+ }
920
+
921
+ export function leader(g) {
922
+ return [...g.tanks].sort((a, b) => b.score - a.score || b.kills - a.kills || b.health - a.health)[0] ?? null;
923
+ }
924
+
925
+ // ------------------------------------------------------------------- the picture
926
+ // WHAT EACH SHELL LOOKS LIKE IN THE AIR (operator, 2026-09-16: "Everything is the same white dot
927
+ // effect for the shot"; then "The missile effect is the same as the baby missile. I said I wanted
928
+ // variance across all shot types"). One entry PER WEAPON that flies, twenty-seven of them: the
929
+ // core is the ball the tile layer draws, in its own colour and size; the glow, the trail's manner
930
+ // and length, its colour, and a ring for the heavy ones are painted over it by scorchedfx.js.
931
+ // Within a family the baby is small and pale, the plain one middling, the heavy one big, dark
932
+ // and ringed -- so a player can tell what is coming before it lands.
933
+ const L = (core, glow, trail, size, o = {}) => Object.freeze({ core, glow, trail, size, len: 6, count: 4, width: 0.3, col: glow, ring: null, spin: 1, ...o });
934
+ export const SHELL_LOOKS = Object.freeze({
935
+ // the blasts
936
+ babyMissile: L('#fff2c8', [255, 205, 130], 'flame', 0.42, { len: 4, col: [255, 180, 90] }),
937
+ missile: L('#ffd27a', [255, 150, 60], 'flame', 0.6, { len: 9, col: [255, 110, 40], ring: [255, 120, 40] }),
938
+ babyNuke: L('#e6ffc4', [150, 255, 110], 'radio', 0.6, { count: 5 }),
939
+ nuke: L('#ccff99', [100, 255, 70], 'radio', 0.82, { count: 11, width: 1.5, ring: [160, 255, 120] }),
940
+ // the ones that do something first
941
+ leapfrog: L('#d2ffc8', [120, 255, 150], 'comet', 0.55, { len: 8, width: 0.3 }),
942
+ funkyBomb: L('#ffffff', null, 'rainbow', 0.55, { len: 12, spin: 1 }),
943
+ funkyBomblet: L('#ffffff', null, 'rainbow', 0.34, { len: 6, spin: 2.5 }),
944
+ mirv: L('#ecdcff', [190, 150, 255], 'comet', 0.55, { len: 10, width: 0.38 }),
945
+ deathsHead: L('#2b1d3a', [255, 60, 90], 'comet', 0.72, { len: 14, width: 0.5, ring: [255, 220, 230] }),
946
+ napalm: L('#ffb347', [255, 120, 40], 'fire', 0.5, { len: 7, col: [255, 120, 30] }),
947
+ hotNapalm: L('#ffffff', [120, 180, 255], 'fire', 0.58, { len: 10, col: [90, 150, 255], ring: [200, 230, 255] }),
948
+ tracer: L('#c9ced8', null, 'dash', 0.3, { len: 14 }),
949
+ smokeTracer: L('#b8bcc4', null, 'smoke', 0.36, { len: 12 }),
950
+ babyRoller: L('#b5bcc6', [200, 200, 215], 'sparks', 0.45, { count: 3 }),
951
+ roller: L('#8f98a4', [220, 210, 200], 'sparks', 0.6, { count: 5 }),
952
+ heavyRoller: L('#5b6470', [255, 160, 90], 'sparks', 0.82, { count: 9, ring: [255, 140, 70] }),
953
+ // dirt, off and on
954
+ riotBomb: L('#c0f2ff', [150, 220, 255], 'wisp', 0.55, { len: 6 }),
955
+ heavyRiotBomb: L('#7fd0ff', [90, 180, 255], 'wisp', 0.78, { len: 11, ring: [170, 220, 255] }),
956
+ dirtClod: L('#b07a40', [180, 130, 80], 'clods', 0.5, { count: 3 }),
957
+ dirtBall: L('#8f6234', [160, 115, 70], 'clods', 0.7, { count: 5 }),
958
+ tonOfDirt: L('#5e4023', [130, 90, 50], 'clods', 1.05, { count: 9, ring: [110, 80, 45] }),
959
+ liquidDirt: L('#7c5a35', [150, 110, 60], 'drip', 0.55, { count: 5 }),
960
+ // the diggers
961
+ babyDigger: L('#ffb36b', [255, 170, 90], 'drill', 0.4, { count: 2, spin: 1 }),
962
+ digger: L('#ff9a3c', [255, 150, 60], 'drill', 0.52, { count: 2, spin: 1.6 }),
963
+ heavyDigger: L('#e06a1c', [255, 120, 50], 'drill', 0.68, { count: 3, spin: 1.3, ring: [255, 170, 100] }),
964
+ babySandhog: L('#ff9a7a', [255, 140, 120], 'drill', 0.45, { count: 2, spin: 2 }),
965
+ sandhog: L('#ff7a3c', [255, 110, 60], 'drill', 0.56, { count: 3, spin: 2 }),
966
+ heavySandhog: L('#c83c1c', [255, 70, 40], 'drill', 0.74, { count: 4, spin: 1.7, ring: [255, 120, 90] }),
967
+ });
968
+ /** The look for a weapon id; anything unknown flies as a baby missile. */
969
+ export function shellLook(weaponId) {
970
+ return SHELL_LOOKS[weaponId] ?? SHELL_LOOKS.babyMissile;
971
+ }
972
+ const strataColour = (y, top, x) => {
973
+ const f = top > 0 ? (y + 0.5) / top : 0;
974
+ let s = STRATA[STRATA.length - 1], i = STRATA.length - 1;
975
+ for (let b = 0; b < STRATA.length; b++) if (f <= STRATA[b].to) { s = STRATA[b]; i = b; break; }
976
+ // a nudge per column and stratum so the face is not flat colour
977
+ const k = 0.9 + 0.2 * hash01(x * 7 + i * 131);
978
+ return shade(s.color, k);
979
+ };
980
+ function shade(hex, k) {
981
+ const h = hex.replace('#', '');
982
+ const ch = (i) => Math.max(0, Math.min(255, Math.round(parseInt(h.slice(i, i + 2), 16) * k)));
983
+ return `#${[ch(0), ch(2), ch(4)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
984
+ }
985
+
986
+ /**
987
+ * The land as tiles for the engine: A CUBE PER CELL, coloured by stratum. Not one tall tile per
988
+ * run: under the oblique camera a tile's height climbs the screen at 0.3 of a grid row, so a run
989
+ * of twelve cells stood four rows tall and the next stratum began twelve rows up -- the land drew
990
+ * as floating ribbons. Tetrust's well is cells for the same reason. About 1,800 cubes on a fresh
991
+ * field; the screen draws them on their own canvas and redraws it only when `landVersion` moves,
992
+ * so they cost nothing while a shell flies. `omit` (a Set of "x,y") leaves cells out -- the ones
993
+ * the screen is animating on its actor layer while they fall.
994
+ */
995
+ export function landTiles(g, { omit = null } = {}) {
996
+ const out = [];
997
+ for (let x = 0; x < COLS; x++) {
998
+ const top = g.tops[x];
999
+ for (let y = 0; y < ROWS; y++) {
1000
+ if (!g.dirt[idx(x, y)]) continue;
1001
+ if (omit?.has(`${x},${y}`)) continue;
1002
+ out.push({ txid: `c${x}:${y}`, x, y, s: 1, tall: 1, color: strataColour(y, top, x) });
1003
+ }
1004
+ }
1005
+ return out;
1006
+ }
1007
+
1008
+ /**
1009
+ * The actors: the tanks as a hull, a turret and a barrel (and a shield as a wire cube round them),
1010
+ * every shell as a ball, and the last shot's trace as a string of dim beads. Ids are stable, so a
1011
+ * tank that has not moved is the same tile frame after frame.
1012
+ */
1013
+ export function actorTiles(g, { trace = true, tankY = null, chutes = null } = {}) {
1014
+ const out = [];
1015
+ for (const t of g.tanks) {
1016
+ if (!t.alive) continue;
1017
+ const c = t.colour;
1018
+ const y = tankY?.get(t.id) ?? t.y; // the screen may still be lowering it after a fall
1019
+ out.push({ txid: `track${t.id}`, x: t.x - 0.15, y: y - 0.05, s: 1.15, tall: 0.35, color: shade(c, 0.45) });
1020
+ out.push({ txid: `track${t.id}b`, x: t.x + 1, y: y - 0.05, s: 1.15, tall: 0.35, color: shade(c, 0.45) });
1021
+ out.push({ txid: `hull${t.id}a`, x: t.x, y: y + 0.3, s: 1, tall: 0.9, color: c, label: t.name });
1022
+ out.push({ txid: `hull${t.id}b`, x: t.x + 1, y: y + 0.3, s: 1, tall: 0.9, color: c, label: t.name });
1023
+ out.push({ txid: `turret${t.id}`, x: t.x + 0.65, y: y + 0.55, s: 0.7, tall: 1.6, color: shade(c, 1.15) });
1024
+ // the barrel: a bar in unit space pointing right, turned by the angle (screen y is down, so
1025
+ // a counter-clockwise angle on the field is a negative rotation on the screen)
1026
+ out.push({
1027
+ txid: `barrel${t.id}`, x: t.x + TANK_W / 2 - 0.5, y: y + 0.6, s: 1, tall: 1.4, color: shade(c, 0.85),
1028
+ poly: [[0, -0.06], [0.75, -0.06], [0.75, 0.06], [0, 0.06]], rot: -(t.angle * Math.PI) / 180,
1029
+ });
1030
+ // a pennant on the turret, streaming downwind and lifting with the strength (the wind made visible)
1031
+ const w = g.wind;
1032
+ if (Math.abs(w) > 0.05) {
1033
+ const len = 0.45 + 0.5 * Math.min(1, Math.abs(w) / WIND_MAX), lift = 0.35 * (1 - Math.min(1, Math.abs(w) / WIND_MAX));
1034
+ const dir = w > 0 ? 1 : -1;
1035
+ out.push({
1036
+ txid: `flag${t.id}`, x: t.x + TANK_W / 2 - 0.5, y: y + 1.9, s: 1, tall: 0.01, color: shade(c, 1.2),
1037
+ poly: [[0, 0], [dir * len, -lift * 0.5 - 0.06], [dir * len * 0.85, -lift * 0.5 + 0.06]], rot: 0,
1038
+ });
1039
+ }
1040
+ if (chutes?.has(t.id)) {
1041
+ // a canopy over a tank coming down under its parachute: a half-disc, and the lines to the hull
1042
+ const pts = [];
1043
+ for (let i = 0; i <= 8; i++) { const a = Math.PI + (i / 8) * Math.PI; pts.push([Math.cos(a) * 0.5, Math.sin(a) * 0.5 - 0.1]); }
1044
+ pts.push([0.5, -0.1], [0.15, 0.45], [-0.15, 0.45], [-0.5, -0.1]);
1045
+ out.push({ txid: `chute${t.id}`, x: t.x + TANK_W / 2 - 1.4, y: y + 2.4, s: 2.8, tall: 0.01, color: '#f2efe6', poly: pts, rot: 0 });
1046
+ }
1047
+ if (t.shield) {
1048
+ const k = t.shield.id === 'heavyShield' ? '#7ad7ff' : t.shield.id === 'forceShield' ? '#8fe0ff' : '#9ce8ff';
1049
+ out.push({ txid: `shield${t.id}`, x: t.x - 0.6, y: y - 0.4, s: TANK_W + 1.2, tall: 1.6, wire: k, color: k });
1050
+ }
1051
+ }
1052
+ g.shells.forEach((s, i) => {
1053
+ const look = shellLook(s.weapon);
1054
+ const sz = look.size;
1055
+ out.push({ txid: `shell${i}`, x: s.x - sz / 2, y: s.y - sz / 2, s: sz, tall: sz, sphere: true, color: s.boring ? '#ffb347' : look.core });
1056
+ });
1057
+ if (trace && g.lastPath.length > 2) {
1058
+ const every = Math.max(1, Math.floor(g.lastPath.length / 40));
1059
+ for (let i = 0; i < g.lastPath.length; i += every) {
1060
+ const p = g.lastPath[i];
1061
+ if (p.y < 0 || p.y > ROWS + 4) continue;
1062
+ out.push({ txid: `trace${i}`, x: p.x - 0.1, y: p.y - 0.1, s: 0.2, tall: 0.2, sphere: true, color: '#7a8699' });
1063
+ }
1064
+ }
1065
+ return out;
1066
+ }
1067
+
1068
+ /** The whole picture: the land and the actors (the screen draws them on two canvases; a test wants both). */
1069
+ export function tiles(g, opts = {}) {
1070
+ return [...landTiles(g, opts), ...actorTiles(g, opts)];
1071
+ }