blockyard 0.0.1 → 0.0.9

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 (107) hide show
  1. package/CHANGELOG.md +679 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +172 -4
  5. package/SECURITY.md +38 -0
  6. package/bin/blockyard.js +40 -0
  7. package/config/pool-map.json +2620 -0
  8. package/docs/API.md +1575 -0
  9. package/docs/ARCHITECTURE.md +1307 -0
  10. package/docs/AUTO-UPDATE.md +269 -0
  11. package/docs/CONFIGURATION.md +840 -0
  12. package/docs/DEFECTS.md +813 -0
  13. package/docs/EFFECTS-AGENTS.md +448 -0
  14. package/docs/GETTING-STARTED.md +202 -0
  15. package/docs/INSTALL.md +490 -0
  16. package/docs/MEASUREMENTS.md +1254 -0
  17. package/docs/PRIVATE-LEADERBOARD.md +230 -0
  18. package/docs/RULES.md +681 -0
  19. package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
  20. package/docs/SECURITY-AUDIT.md +258 -0
  21. package/docs/SECURITY.md +195 -0
  22. package/docs/STATE-2026-09-09.md +200 -0
  23. package/docs/TROUBLESHOOTING.md +298 -0
  24. package/docs/USER-GUIDE.md +1022 -0
  25. package/package.json +53 -5
  26. package/public/404.html +9 -0
  27. package/public/css/app.css +1785 -0
  28. package/public/index.html +893 -0
  29. package/public/js/about.js +112 -0
  30. package/public/js/agents.js +964 -0
  31. package/public/js/app.js +1312 -0
  32. package/public/js/arkanoid.js +806 -0
  33. package/public/js/blockanoid.js +347 -0
  34. package/public/js/blockout.js +347 -0
  35. package/public/js/blockpack.js +428 -0
  36. package/public/js/blockscene3d.js +2678 -0
  37. package/public/js/breakout.js +224 -0
  38. package/public/js/charts.js +635 -0
  39. package/public/js/depthchart.js +311 -0
  40. package/public/js/details3d.js +2957 -0
  41. package/public/js/explorer.js +405 -0
  42. package/public/js/feepalette.js +149 -0
  43. package/public/js/fmt.js +162 -0
  44. package/public/js/goggles.js +886 -0
  45. package/public/js/kiosk.js +41 -0
  46. package/public/js/login.js +83 -0
  47. package/public/js/markets.js +357 -0
  48. package/public/js/mining.js +1138 -0
  49. package/public/js/panels.js +966 -0
  50. package/public/js/pricechart.js +188 -0
  51. package/public/js/settings.js +1014 -0
  52. package/public/js/tetris.js +226 -0
  53. package/public/js/tetrust.js +356 -0
  54. package/public/js/tetsound.js +175 -0
  55. package/public/login.html +33 -0
  56. package/scripts/blockfile-measure.js +156 -0
  57. package/scripts/browser-check.mjs +286 -0
  58. package/scripts/check.js +173 -0
  59. package/scripts/decode-check.js +81 -0
  60. package/scripts/doc-counts.js +109 -0
  61. package/scripts/donate-qr.py +20 -0
  62. package/scripts/fake-node.js +534 -0
  63. package/scripts/index-bench.js +216 -0
  64. package/scripts/index-benchmark.js +117 -0
  65. package/scripts/index-build.js +40 -0
  66. package/scripts/live-render-check.mjs +89 -0
  67. package/scripts/manage-users.js +132 -0
  68. package/scripts/motion-check.mjs +138 -0
  69. package/scripts/pool-map.js +157 -0
  70. package/scripts/setup.js +410 -0
  71. package/scripts/shots.mjs +272 -0
  72. package/scripts/smoke.sh +327 -0
  73. package/scripts/ui.js +174 -0
  74. package/server/auth/sessions.js +221 -0
  75. package/server/auth/users.js +243 -0
  76. package/server/chain/blockfile.js +234 -0
  77. package/server/chain/index/build.js +193 -0
  78. package/server/chain/index/heights.js +36 -0
  79. package/server/chain/index/live.js +276 -0
  80. package/server/chain/index/rows.js +145 -0
  81. package/server/chain/index/store.js +154 -0
  82. package/server/chain/index/worker.js +109 -0
  83. package/server/chain/tx.js +310 -0
  84. package/server/collect/gbt.js +229 -0
  85. package/server/collect/logparse.js +765 -0
  86. package/server/collect/logtail.js +189 -0
  87. package/server/collect/markets.js +333 -0
  88. package/server/collect/mining.js +333 -0
  89. package/server/collect/monitor.js +2516 -0
  90. package/server/collect/nextblock.js +275 -0
  91. package/server/collect/sync.js +386 -0
  92. package/server/config.js +620 -0
  93. package/server/http/api.js +1275 -0
  94. package/server/http/explorer.js +418 -0
  95. package/server/http/server.js +412 -0
  96. package/server/http/sse.js +176 -0
  97. package/server/http/static.js +212 -0
  98. package/server/main.js +628 -0
  99. package/server/netinfo.js +253 -0
  100. package/server/rpc/allowlist.js +130 -0
  101. package/server/rpc/client.js +414 -0
  102. package/server/store/audit.js +148 -0
  103. package/server/store/history.js +220 -0
  104. package/server/store/ledger.js +290 -0
  105. package/server/store/ring.js +173 -0
  106. package/server/util/fmt.js +29 -0
  107. package/systemd/blockyard.service +100 -0
@@ -0,0 +1,226 @@
1
+ // TETRUST, the game (operator, 2026-09-12: "a playable tetris game using our 3d Engine ...
2
+ // 'Tetrust' is a play on words for Bitcoiners to always trust and verify").
3
+ //
4
+ // This file is the RULES and nothing else: no DOM, no canvas, no clock. A game is a plain object,
5
+ // every move is a function on it, and the random piece order comes from a seed -- so the whole of
6
+ // it runs under node:test and a board state can be asserted cell by cell. What is drawn (and when)
7
+ // is tetrust.js's business; it asks this file for the tiles and hands them to the 3D engine.
8
+ //
9
+ // Coordinates follow the engine's board: x runs left to right, y runs UP from row 0 on the floor.
10
+ // A piece falls toward y = 0. That is the opposite of most Tetris code, and it is on purpose --
11
+ // the engine draws row 0 at the bottom of the screen, so the rules speak the renderer's language
12
+ // rather than flipping on the way out.
13
+
14
+ export const COLS = 10;
15
+ export const ROWS = 20;
16
+
17
+ // Seven pieces, four orientations each, as cells in a 4x4 box (x right, y up). Spelled out rather
18
+ // than rotated by formula: a rotation table is a fact you can read, a matrix is a thing you debug.
19
+ export const PIECES = Object.freeze({
20
+ I: { color: '#3ec9ff', rot: [[[0, 2], [1, 2], [2, 2], [3, 2]], [[2, 0], [2, 1], [2, 2], [2, 3]], [[0, 1], [1, 1], [2, 1], [3, 1]], [[1, 0], [1, 1], [1, 2], [1, 3]]] },
21
+ O: { color: '#f5d142', rot: [[[1, 1], [2, 1], [1, 2], [2, 2]], [[1, 1], [2, 1], [1, 2], [2, 2]], [[1, 1], [2, 1], [1, 2], [2, 2]], [[1, 1], [2, 1], [1, 2], [2, 2]]] },
22
+ T: { color: '#b06bff', rot: [[[0, 1], [1, 1], [2, 1], [1, 2]], [[1, 0], [1, 1], [1, 2], [2, 1]], [[0, 1], [1, 1], [2, 1], [1, 0]], [[1, 0], [1, 1], [1, 2], [0, 1]]] },
23
+ S: { color: '#2ecc8f', rot: [[[1, 2], [2, 2], [0, 1], [1, 1]], [[1, 2], [1, 1], [2, 1], [2, 0]], [[1, 2], [2, 2], [0, 1], [1, 1]], [[1, 2], [1, 1], [2, 1], [2, 0]]] },
24
+ Z: { color: '#ef5a5a', rot: [[[0, 2], [1, 2], [1, 1], [2, 1]], [[2, 2], [2, 1], [1, 1], [1, 0]], [[0, 2], [1, 2], [1, 1], [2, 1]], [[2, 2], [2, 1], [1, 1], [1, 0]]] },
25
+ J: { color: '#3b82f6', rot: [[[0, 2], [0, 1], [1, 1], [2, 1]], [[1, 2], [2, 2], [1, 1], [1, 0]], [[0, 1], [1, 1], [2, 1], [2, 0]], [[1, 2], [1, 1], [1, 0], [0, 0]]] },
26
+ L: { color: '#f7931a', rot: [[[2, 2], [0, 1], [1, 1], [2, 1]], [[1, 2], [1, 1], [1, 0], [2, 0]], [[0, 1], [1, 1], [2, 1], [0, 0]], [[0, 2], [1, 2], [1, 1], [1, 0]]] },
27
+ });
28
+ export const KINDS = Object.keys(PIECES);
29
+
30
+ // Scoring is the classic table, times the level: the reward for clearing four at once is well
31
+ // over four singles, which is the whole reason to leave a column open and wait for the I.
32
+ export const LINE_SCORE = [0, 100, 300, 500, 800];
33
+ export const LINES_PER_LEVEL = 10;
34
+
35
+ /** How long a piece rests on a row before gravity takes it down one, at this level. */
36
+ export function gravityMs(level) {
37
+ // brisk from the first level (operator: "Smaller, faster, playable!"): 700 ms a row at level
38
+ // one, 65 ms quicker each level, never under 80
39
+ return Math.max(80, 700 - (level - 1) * 65);
40
+ }
41
+
42
+ // A small seeded generator, so a game's piece order is reproducible under test.
43
+ function rng(seed) {
44
+ let s = (seed >>> 0) || 1;
45
+ return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
46
+ }
47
+
48
+ // THE SEVEN-BAG: every piece once, in a shuffled order, then a fresh bag. It is what keeps a game
49
+ // fair -- no run of five S pieces, and never more than twelve pieces between one I and the next.
50
+ function fillBag(g) {
51
+ const bag = [...KINDS];
52
+ for (let i = bag.length - 1; i > 0; i--) { const j = Math.floor(g.rnd() * (i + 1)); [bag[i], bag[j]] = [bag[j], bag[i]]; }
53
+ g.queue.push(...bag);
54
+ }
55
+
56
+ export function newGame(seed = Date.now()) {
57
+ const g = {
58
+ // board[y][x] = { k: kind, id } | null. Every locked cell carries an ID it keeps as rows
59
+ // shift down after a clear: the renderer animates a vanished id as a departure (the cleared
60
+ // line drifting away) and a moved id as a move, so only the cleared cells leave.
61
+ board: Array.from({ length: ROWS }, () => new Array(COLS).fill(null)),
62
+ queue: [], rnd: rng(seed), seed,
63
+ cur: null, // { kind, rot, x, y }
64
+ score: 0, lines: 0, level: 1, pieces: 0, nextId: 1,
65
+ over: false,
66
+ cleared: [], // rows cleared by the last lock, for the renderer to celebrate
67
+ clearedCells: [], // ...and the cells that were on them: { x, y, k, id }
68
+ };
69
+ fillBag(g);
70
+ spawn(g);
71
+ return g;
72
+ }
73
+
74
+ /** The cells a piece occupies at a position and orientation, in board coordinates. */
75
+ export function cellsOf(kind, rot, x, y) {
76
+ return PIECES[kind].rot[((rot % 4) + 4) % 4].map(([cx, cy]) => [x + cx, y + cy]);
77
+ }
78
+
79
+ /** True if any cell is outside the well or on a locked block. Above the well counts as blocked. */
80
+ export function collides(g, cells) {
81
+ for (const [x, y] of cells) {
82
+ if (x < 0 || x >= COLS || y < 0 || y >= ROWS) return true;
83
+ if (g.board[y][x]) return true;
84
+ }
85
+ return false;
86
+ }
87
+
88
+ export function spawn(g) {
89
+ if (g.queue.length < 7) fillBag(g);
90
+ const kind = g.queue.shift();
91
+ const cur = { kind, rot: 0, x: 3, y: ROWS - 4 };
92
+ g.cur = cur;
93
+ g.pieces++;
94
+ // GAME OVER is a piece that cannot even appear: the stack has reached the top of the well
95
+ if (collides(g, cellsOf(kind, 0, cur.x, cur.y))) { g.over = true; g.cur = null; }
96
+ return cur;
97
+ }
98
+
99
+ export function peekNext(g) { return g.queue[0]; }
100
+
101
+ /** Slide left or right; a blocked slide simply does not happen. */
102
+ export function move(g, dx) {
103
+ if (!g.cur || g.over) return false;
104
+ const c = g.cur;
105
+ if (collides(g, cellsOf(c.kind, c.rot, c.x + dx, c.y))) return false;
106
+ c.x += dx;
107
+ return true;
108
+ }
109
+
110
+ /**
111
+ * Rotate, with WALL KICKS: a rotation that lands in a wall or a block is tried again shifted one
112
+ * then two cells either way, and one cell down. Without kicks a piece against the wall cannot
113
+ * turn, which is the single most common "this game feels broken" in a Tetris.
114
+ */
115
+ export function rotate(g, dir = 1) {
116
+ if (!g.cur || g.over) return false;
117
+ const c = g.cur;
118
+ const rot = (((c.rot + dir) % 4) + 4) % 4;
119
+ for (const [kx, ky] of [[0, 0], [-1, 0], [1, 0], [-2, 0], [2, 0], [0, -1], [-1, -1], [1, -1]]) {
120
+ if (!collides(g, cellsOf(c.kind, rot, c.x + kx, c.y + ky))) { c.rot = rot; c.x += kx; c.y += ky; return true; }
121
+ }
122
+ return false;
123
+ }
124
+
125
+ /** Where the piece would come to rest if dropped: the row the ghost is drawn on. */
126
+ export function ghostY(g) {
127
+ if (!g.cur) return null;
128
+ const c = g.cur;
129
+ let y = c.y;
130
+ while (!collides(g, cellsOf(c.kind, c.rot, c.x, y - 1))) y--;
131
+ return y;
132
+ }
133
+
134
+ /** The piece kind at a cell, or null. */
135
+ export function kindAt(g, x, y) { return g.board[y]?.[x]?.k ?? null; }
136
+
137
+ function lock(g) {
138
+ const c = g.cur;
139
+ for (const [x, y] of cellsOf(c.kind, c.rot, c.x, c.y)) g.board[y][x] = { k: c.kind, id: g.nextId++ };
140
+ // LINES: every full row goes, and everything above it comes down one
141
+ const full = [];
142
+ for (let y = 0; y < ROWS; y++) if (g.board[y].every(Boolean)) full.push(y);
143
+ // the cells of the lines, kept for the screen: they DRIFT UP AND AWAY (tetrust.js) while the
144
+ // rows above come down, so the board here has already forgotten them
145
+ g.clearedCells = full.flatMap((y) => g.board[y].map((cell, x) => ({ x, y, k: cell.k, id: cell.id })));
146
+ for (const y of [...full].reverse()) { g.board.splice(y, 1); g.board.push(new Array(COLS).fill(null)); }
147
+ g.cleared = full;
148
+ if (full.length) {
149
+ g.lines += full.length;
150
+ g.score += LINE_SCORE[full.length] * g.level;
151
+ g.level = 1 + Math.floor(g.lines / LINES_PER_LEVEL);
152
+ }
153
+ spawn(g);
154
+ }
155
+
156
+ /** One step of gravity. Returns true if the piece locked (and so the board changed shape). */
157
+ export function tick(g) {
158
+ if (!g.cur || g.over) return false;
159
+ const c = g.cur;
160
+ if (!collides(g, cellsOf(c.kind, c.rot, c.x, c.y - 1))) { c.y--; g.cleared = []; return false; }
161
+ lock(g);
162
+ return true;
163
+ }
164
+
165
+ /** Soft drop: one row down now, and a point for it. */
166
+ export function softDrop(g) {
167
+ if (!g.cur || g.over) return false;
168
+ const c = g.cur;
169
+ if (collides(g, cellsOf(c.kind, c.rot, c.x, c.y - 1))) { lock(g); return true; }
170
+ c.y--; g.score += 1; g.cleared = [];
171
+ return false;
172
+ }
173
+
174
+ /** Hard drop: straight to the floor, two points a row, and lock. */
175
+ export function hardDrop(g) {
176
+ if (!g.cur || g.over) return 0;
177
+ const c = g.cur;
178
+ const y = ghostY(g);
179
+ const fell = c.y - y;
180
+ c.y = y;
181
+ g.score += 2 * fell;
182
+ lock(g);
183
+ return fell;
184
+ }
185
+
186
+ /**
187
+ * The board as tiles for the 3D engine (board3d): every locked cell a stone in its piece's
188
+ * colour, the falling piece standing a little taller, and its GHOST -- where it will land -- as a
189
+ * thin dim plate on the floor. Ids are positional for the stack and fixed for the piece, so the
190
+ * renderer sees a still board that changes, not a board that reshuffles.
191
+ */
192
+ // medium neon blue (operator, 2026-09-12: first "neon yellow wireframes", then "maybe medium neon
193
+ // blue at the bottom instead of bright neon yellow for tetrust block landing locations")
194
+ // The default. The colour is the CALLER's to choose (operator, 2026-09-12: "I want to be able to
195
+ // set the ghost wireframe color") -- this file stays pure and knows nothing of the settings store.
196
+ export const GHOST_WIRE = '#3d8bff';
197
+
198
+ export function tiles(g, wire = GHOST_WIRE) {
199
+ const out = [];
200
+ for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) {
201
+ const cell = g.board[y][x];
202
+ if (cell) out.push({ txid: `c${cell.id}`, x, y, s: 1, tall: 1, color: PIECES[cell.k].color });
203
+ }
204
+ if (g.cur && !g.over) {
205
+ const c = g.cur;
206
+ const gy = ghostY(g);
207
+ if (gy !== c.y) {
208
+ // THE GHOST IS A WIREFRAME (operator, 2026-09-12: "wireframes on teh bottom of the tetrust
209
+ // playfield, to show where the pieces are going to drop. The solid dark colored stuff is
210
+ // too difficult to see"): the outline of a full cube, no fill, in a colour nothing else on
211
+ // the board uses
212
+ cellsOf(c.kind, c.rot, c.x, gy).forEach(([x, y], i) => out.push({ txid: `g${i}`, x, y, s: 1, tall: 1, color: PIECES[c.kind].color, wire }));
213
+ }
214
+ // the falling piece carries the ids its cells will get when it locks (lock() hands them out in
215
+ // cellsOf order from nextId), so locking is the same four tiles settling, not four vanishing
216
+ // and four appearing
217
+ cellsOf(c.kind, c.rot, c.x, c.y).forEach(([x, y], i) => out.push({ txid: `c${g.nextId + i}`, x, y, s: 1, tall: 1.18, color: PIECES[c.kind].color, piece: true }));
218
+ }
219
+ return out;
220
+ }
221
+
222
+ /** The next piece alone, centred in a 4x4 box, for the preview. */
223
+ export function previewTiles(kind) {
224
+ if (!kind) return [];
225
+ return cellsOf(kind, 0, 0, 0).map(([x, y], i) => ({ txid: `n${i}`, x, y, s: 1, tall: 1, color: PIECES[kind].color }));
226
+ }
@@ -0,0 +1,356 @@
1
+ // TETRUST, the tab (operator, 2026-09-12: "Add a new tab called 'Tetrust' - I want it to be a
2
+ // playable tetris game using our 3d Engine. Pauses when you tab away, and has a Resume play
3
+ // button. Make the Tetris game absolutely beautiful leveraging our framework ... Add score. Add a
4
+ // high score table").
5
+ //
6
+ // The rules live in tetris.js and know nothing of the screen. This file is the screen: the well
7
+ // is the same board3d the Markets and Block space boards use, with the same oblique camera and
8
+ // the same domed board ("Make it spherical like our default display"), and the panel behind it is
9
+ // the same sky (drawSky). The game inherits the framework rather than imitating it.
10
+ import { board3d } from './details3d.js';
11
+ import { newGame, tick, move, rotate, softDrop, hardDrop, tiles, previewTiles, peekNext, gravityMs, PIECES, COLS, ROWS } from './tetris.js';
12
+ import { loadSettings, setSetting, tetrustOptions } from './settings.js';
13
+ import * as sound from './tetsound.js';
14
+
15
+ const SCORES_KEY = 'blockyard.tetrust.scores';
16
+ const KEEP = 10;
17
+
18
+ // the well's camera: the Block space look (an oblique board, cubes with height, the sphere) on a
19
+ // tall well. `still` so a key press lands where you pressed it; no idle effects while playing.
20
+ const WELL = {
21
+ gridW: COLS, gridH: ROWS,
22
+ oblique: { ox: 0.10, oy: 0.30, headroom: 3, flight: 0 },
23
+ dome: 5, // the default board's sphere (operator: "spherical like our default display")
24
+ light: 'overhead', // the lamp straight above, so the bottom rows are not in the dome's shade
25
+ gridStep: 1,
26
+ space: true,
27
+ background: 'rgba(0,0,0,0)', // clear: the panel's sky shows through ("Fill the entire panel with black")
28
+ spaceFloor: 'rgba(0,0,0,0.22)', // and through the board itself ("The playfield needs to be transparent to show the stars and galaxy behind it")
29
+ neonCell: 'rgba(60,200,140,0.18)',
30
+ };
31
+ const PREVIEW = { gridW: 4, gridH: 4, oblique: { ox: 0.10, oy: 0.30, headroom: 2, flight: 0 }, dome: 0, gridStep: 1, space: false, background: 'rgba(0,0,0,1)', grid: false };
32
+
33
+ // THE SKY (operator: "Have this entire panel filled black and rendering the spiral galaxy for this
34
+ // display. Have the text floating over the spiral galaxy, and then the playboard gets drawn when
35
+ // you hit play"): the panel's own canvas behind the title and the well, an empty board with no
36
+ // floor and no grid -- so nothing but black and the star field -- carrying the game's switches
37
+ // for the sky and the galaxy and the display's choices for what the sky is made of. Its own
38
+ // canvas, so the game's every-key-press repaint of the well never touches a star.
39
+ // `maxDpr: 1`: the sky is drawn at one device pixel per CSS pixel whatever the screen has. The
40
+ // star count follows the pixel count, so on a 2x screen that is a quarter of the stars a frame --
41
+ // and a panel-sized galaxy at 2x made the game stutter ("The galaxy rendering is really slow in
42
+ // teh game display. smooth that shit out"). Stars are points; nobody can tell.
43
+ // `still` here means only that the (nonexistent) tiles never fly: render3d keeps the loop alive
44
+ // for the stars themselves, which is what turns the galaxy.
45
+ const SKY = { gridW: COLS, gridH: ROWS, oblique: { ox: 0.10, oy: 0.30, headroom: 3, flight: 0 }, dome: 0, space: false, grid: false, background: 'rgba(0,0,0,1)', idleFx: false, shadows: false, still: true, transition: { rise: 0, travel: 1, drop: 0 }, maxDpr: 1 };
46
+
47
+ // THE DRIFT (operator: "have the block pieces drift up and away and completing lines", then
48
+ // "have teh blocks fly up off the screen entirely. Not dissapear just above the playfield"): a
49
+ // cleared line's cells launch off the board -- gathering speed, full colour all the way -- and
50
+ // leave through the top of the canvas
51
+ const DRIFT_MS = 1100;
52
+ const DRIFT_RISE = 80; // units of height at the end: well past the canvas top from any row
53
+
54
+ const G = {
55
+ game: null, running: false, paused: false, why: '', raf: null, last: 0, acc: 0, dirty: true, bound: false,
56
+ state: null, h: null, nextKind: undefined,
57
+ drift: [], // { id, x, y, color, dx, t0 } cells on their way up
58
+ };
59
+
60
+ function opts(base) {
61
+ // FAST AND PLAIN (operator, 2026-09-12: "the movement is way too slow. We need to stop all
62
+ // effects, and just treat the blocks differently ... Smaller, faster, playable! No effects for
63
+ // in-motion blocks"). The first cut inherited every display setting, and a forty-thousand-star
64
+ // galaxy behind a 2.4-megapixel well is not a game. The well draws the well and -- only when
65
+ // the game's own switch says so -- the sky behind it: no idle effects, no shadows, flat cubes
66
+ // with their edges, no finishes on the pieces, and every move lands at once.
67
+ const t = tetrustOptions(loadSettings());
68
+ return {
69
+ ...base,
70
+ // `space: true` is the plain translucent floor (no deck texture, no dots). The first cut had
71
+ // space off and got the textured deck back, and its per-cell neon glow turned the well into a
72
+ // loud green lattice: the glow and the halo are silenced here, and the grid is a quiet line.
73
+ space: true, idleFx: false, shadows: false,
74
+ hover: false, // a playfield does not light up under the pointer
75
+ // no sky on the well itself: the sky is the panel's canvas behind it (drawSky)
76
+ stars: false, galaxy: false,
77
+ // the grid is the operator's now (settings tetrust.grid and its colour and intensity rows).
78
+ // The glow and the halo stay silenced: a lit lattice under the stack is noise, not a board.
79
+ ...t.gridOpts, grid: t.grid,
80
+ edges: true, facetPx: Infinity, crownPx: Infinity, sheen: false,
81
+ // neon on the pieces is the game's own switch (settings tetrust.neon and its colour rows)
82
+ neon: t.neon, neonSource: t.neonSource, neonColour: t.neonColour, neonBrightness: t.neonBrightness,
83
+ wireWidth: t.ghostWidth,
84
+ // STILL: drawn as laid, no choreography at all. A transition of zero was not enough -- the
85
+ // planner's per-tile stagger (seconds between one cube's drop and the next) still applied,
86
+ // and a piece's four cells came down one after another instead of as one shape.
87
+ still: true,
88
+ transition: { rise: 0, travel: 1, drop: 0 },
89
+ };
90
+ }
91
+
92
+ // ------------------------------------------------------------------ scores, this browser's
93
+ export function loadScores(storage = globalThis.localStorage) {
94
+ try {
95
+ const raw = storage?.getItem(SCORES_KEY);
96
+ const list = raw ? JSON.parse(raw) : [];
97
+ return Array.isArray(list) ? list.filter((r) => r && Number.isFinite(r.score)).slice(0, KEEP) : [];
98
+ } catch { return []; }
99
+ }
100
+ export function recordScore(entry, storage = globalThis.localStorage) {
101
+ const list = [...loadScores(storage), { score: entry.score, lines: entry.lines, level: entry.level, at: entry.at ?? Date.now() }]
102
+ .sort((a, b) => b.score - a.score || b.lines - a.lines)
103
+ .slice(0, KEEP);
104
+ try { storage?.setItem(SCORES_KEY, JSON.stringify(list)); } catch { /* private mode, quota */ }
105
+ return list;
106
+ }
107
+ /** Where a score would land in the table, 1-based, or null if it would not make it. */
108
+ export function rankOf(score, list) {
109
+ const i = list.findIndex((r) => score > r.score);
110
+ if (i >= 0) return i + 1;
111
+ return list.length < KEEP ? list.length + 1 : null;
112
+ }
113
+
114
+ // ------------------------------------------------------------------ the drift
115
+ /**
116
+ * The tiles of the cells on their way up, at `now`: each rises on an ease-out, slides a little
117
+ * to its own side, and darkens to the black behind it. The engine's still plan pins z and alpha
118
+ * (a still board is a still board), so the rise is the tile's FLOOR and the fade is its colour.
119
+ */
120
+ export function driftTiles(drift, now, ms = DRIFT_MS) {
121
+ const out = [];
122
+ for (const d of drift) {
123
+ const t = Math.min(1, Math.max(0, (now - d.t0) / ms));
124
+ if (t >= 1) continue;
125
+ const e = t * t * (3 - 2 * t) * 0.35 + t * t * 0.65; // a launch: slow off the board, then away
126
+ const k = t < 0.85 ? 1 : 1 - (t - 0.85) / 0.15; // full colour until the last stretch, off screen by then
127
+ out.push({ txid: `d${d.id}`, x: d.x + d.dx * e, y: d.y, s: 1, tall: 1, floor: DRIFT_RISE * e, color: fade(d.color, k) });
128
+ }
129
+ return out;
130
+ }
131
+ function fade(hex, k) {
132
+ const h = hex.replace('#', '');
133
+ const ch = (i) => Math.round(parseInt(h.slice(i, i + 2), 16) * k);
134
+ return `#${[ch(0), ch(2), ch(4)].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('')}`;
135
+ }
136
+ function startDrift(cells, now) {
137
+ for (const c of cells) G.drift.push({ id: c.id, x: c.x, y: c.y, color: PIECES[c.k].color, dx: (c.x - (COLS - 1) / 2) * 0.12, t0: now });
138
+ }
139
+
140
+ // ------------------------------------------------------------------ the screen
141
+ const el = (id) => document.getElementById(id);
142
+
143
+ function overlay(msg, sub, button, dim = false) {
144
+ const ov = el('tetOver'), m = el('tetMsg'), s = el('tetSub'), b = el('tetResume');
145
+ if (!ov) return;
146
+ ov.classList.toggle('hidden', !msg);
147
+ ov.classList.toggle('dim', dim); // clear over the sky; dimmed over a held or finished board
148
+ if (m) m.textContent = msg ?? '';
149
+ if (s) s.textContent = sub ?? '';
150
+ if (b) b.textContent = button ?? 'play';
151
+ }
152
+
153
+ function drawStats() {
154
+ const g = G.game, box = el('tetStats');
155
+ if (!box) return;
156
+ const rows = g
157
+ ? [['score', g.score.toLocaleString()], ['lines', String(g.lines)], ['level', String(g.level)], ['pieces', String(g.pieces)], ['gravity', `${gravityMs(g.level)} ms`]]
158
+ : [['score', '–'], ['lines', '–'], ['level', '–']];
159
+ const html = rows.map(([k, v]) => `<i>${k}</i><b>${v}</b>`).join('');
160
+ if (box.innerHTML !== html) box.innerHTML = html;
161
+ }
162
+
163
+ function drawScores(highlightAt = null) {
164
+ const t = el('tetScores');
165
+ if (!t) return;
166
+ const list = loadScores();
167
+ const html = list.length
168
+ ? `<tr><th>#</th><th>score</th><th>lines</th><th>lvl</th><th>when</th></tr>` + list.map((r, i) =>
169
+ `<tr${r.at === highlightAt ? ' class="now"' : ''}><td>${i + 1}</td><td>${r.score.toLocaleString()}</td><td>${r.lines}</td><td>${r.level}</td><td>${new Date(r.at).toISOString().slice(0, 10)}</td></tr>`).join('')
170
+ : `<tr><td class="faint">no verified blocks yet — clear a line</td></tr>`;
171
+ if (t.innerHTML !== html) t.innerHTML = html;
172
+ }
173
+
174
+ // THE GAME'S OWN SWITCHES (operator: "Toggle for each in the game display"): three buttons on the
175
+ // HUD, the same settings the Display panel shows, so a flip in either place is a flip in both
176
+ const SWITCHES = [['tetStars', 'stars'], ['tetGalaxy', 'galaxy'], ['tetMusic', 'music'], ['tetSfx', 'sfx']];
177
+ function drawSwitches() {
178
+ const t = tetrustOptions(loadSettings());
179
+ for (const [id, key] of SWITCHES) {
180
+ const b = el(id);
181
+ if (!b) continue;
182
+ b.classList.toggle('on', !!t[key]);
183
+ b.setAttribute('aria-pressed', t[key] ? 'true' : 'false');
184
+ }
185
+ applySound(t);
186
+ }
187
+ function applySound(t) {
188
+ sound.setSfx(t.sfx);
189
+ // the tune plays while the game runs; a pause holds it; the switch off stops it
190
+ sound.setMusic(t.music && G.running && !G.paused);
191
+ }
192
+ function flip(key) {
193
+ const cur = tetrustOptions(loadSettings())[key];
194
+ setSetting(loadSettings(), `tetrust.${key}`, !cur);
195
+ sound.unlock();
196
+ drawSwitches();
197
+ if (key === 'stars' || key === 'galaxy') drawSky();
198
+ G.dirty = true;
199
+ if (!G.running || G.paused) draw();
200
+ }
201
+
202
+ function drawSky() {
203
+ const sky = el('tetSky');
204
+ if (!sky) return;
205
+ const t = tetrustOptions(loadSettings());
206
+ board3d(sky, [], {
207
+ ...SKY,
208
+ stars: t.stars, galaxy: t.stars && t.galaxy, galaxyAt: t.galaxyAt,
209
+ starDensity: t.starDensity, starBrightness: t.starBrightness,
210
+ nebulae: t.nebulae, galaxies: t.galaxies, dust: t.dust, clusters: t.clusters, starColours: t.starColours, starGlints: t.starGlints,
211
+ });
212
+ }
213
+
214
+ function draw(now = performance.now()) {
215
+ const g = G.game;
216
+ const well = el('tetWell');
217
+ el('tetWellWrap')?.classList.toggle('idle', !g);
218
+ // the board is drawn when you hit play: `.tetwell.idle` is visibility:hidden, so an idle well is
219
+ // off screen and painting it would be a board3d pass for nothing
220
+ if (well && g) board3d(well, [...tiles(g, tetrustOptions(loadSettings()).ghostColour), ...driftTiles(G.drift, now)], opts(WELL));
221
+ // the preview only when the next piece changes: it is a second board, and redrawing it on
222
+ // every key press was paying for two scenes per move
223
+ const nk = g ? peekNext(g) : null;
224
+ if (nk !== G.nextKind) {
225
+ G.nextKind = nk;
226
+ const nx = el('tetNext');
227
+ if (nx) board3d(nx, previewTiles(nk), opts({ ...PREVIEW, stars: false }));
228
+ }
229
+ drawStats();
230
+ }
231
+
232
+ // ------------------------------------------------------------------ the loop
233
+ function frame(t) {
234
+ G.raf = null;
235
+ if (!G.running) return;
236
+ // PAUSE WHEN YOU LOOK AWAY: another browser tab, or another tab of this app. A game that keeps
237
+ // falling while you read the Mempool page is a game you come back to lost.
238
+ if (document.hidden || G.state?.page !== 'tetrust') { pause(document.hidden ? 'paused — you looked away' : 'paused — you changed tabs'); return; }
239
+ const dt = G.last ? Math.min(250, t - G.last) : 0;
240
+ G.last = t;
241
+ G.acc += dt;
242
+ const g = G.game;
243
+ const ms = gravityMs(g.level);
244
+ const levelBefore = g.level;
245
+ while (G.acc >= ms && !g.over) { G.acc -= ms; if (tick(g)) { G.dirty = true; afterLock(g, t); } else G.dirty = true; }
246
+ if (g.level > levelBefore) sound.play('level');
247
+ if (g.over) { gameOver(); return; }
248
+ // the drift keeps the loop drawing until the last cell has gone
249
+ if (G.drift.length) {
250
+ G.drift = G.drift.filter((d) => t - d.t0 < DRIFT_MS);
251
+ G.dirty = true;
252
+ }
253
+ if (G.dirty) { draw(t); G.dirty = false; }
254
+ G.raf = requestAnimationFrame(frame);
255
+ }
256
+
257
+ // what happens when a piece locks: the sound of it, and the lines it made leaving
258
+ function afterLock(g, now) {
259
+ if (g.cleared?.length) {
260
+ startDrift(g.clearedCells ?? [], now);
261
+ sound.play(g.cleared.length >= 4 ? 'tetris' : 'clear');
262
+ g.cleared = []; g.clearedCells = [];
263
+ } else sound.play('lock');
264
+ }
265
+
266
+ function start() {
267
+ G.game = newGame();
268
+ G.drift = [];
269
+ G.running = true; G.paused = false; G.acc = 0; G.last = 0; G.dirty = true;
270
+ overlay(null);
271
+ drawScores();
272
+ sound.unlock();
273
+ drawSwitches();
274
+ if (!G.raf) G.raf = requestAnimationFrame(frame);
275
+ }
276
+
277
+ function pause(why) {
278
+ if (!G.running || G.paused) return;
279
+ G.paused = true; G.why = why;
280
+ overlay(why, 'the stack is holding', 'resume', true);
281
+ sound.holdMusic(true);
282
+ G.h?.toast?.(why);
283
+ }
284
+
285
+ function resume() {
286
+ if (!G.running) { start(); return; }
287
+ if (!G.paused) return;
288
+ G.paused = false; G.last = 0; G.dirty = true;
289
+ overlay(null);
290
+ sound.unlock();
291
+ sound.holdMusic(false);
292
+ if (!G.raf) G.raf = requestAnimationFrame(frame);
293
+ }
294
+
295
+ function gameOver() {
296
+ const g = G.game;
297
+ G.running = false; G.paused = false;
298
+ sound.setMusic(false);
299
+ sound.play('over');
300
+ draw();
301
+ const list = loadScores();
302
+ const rank = rankOf(g.score, list);
303
+ const at = Date.now();
304
+ if (rank) recordScore({ score: g.score, lines: g.lines, level: g.level, at });
305
+ drawScores(rank ? at : null);
306
+ overlay(
307
+ `verified ${g.lines} line${g.lines === 1 ? '' : 's'}`,
308
+ `${g.score.toLocaleString()} points at level ${g.level}${rank ? ` — #${rank} on this browser` : ''}. Trust, but verify.`,
309
+ 'play again',
310
+ true,
311
+ );
312
+ }
313
+
314
+ // ------------------------------------------------------------------ input
315
+ function onKey(e) {
316
+ if (G.state?.page !== 'tetrust') return;
317
+ const tag = e.target?.tagName;
318
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
319
+ if (e.key === 'Enter') { e.preventDefault(); resume(); return; }
320
+ if (e.key === 'p' || e.key === 'P' || e.key === 'Escape') { if (G.running) { e.preventDefault(); G.paused ? resume() : pause('paused'); } return; }
321
+ if (!G.running || G.paused || !G.game || G.game.over) return;
322
+ const g = G.game;
323
+ let used = true;
324
+ // arrows or WASD (operator: "Add WASD for tetris controls as well")
325
+ switch (e.key) {
326
+ case 'ArrowLeft': case 'a': case 'A': if (move(g, -1)) sound.play('move'); break;
327
+ case 'ArrowRight': case 'd': case 'D': if (move(g, 1)) sound.play('move'); break;
328
+ case 'ArrowUp': case 'w': case 'W': case 'x': case 'X': if (rotate(g, 1)) sound.play('rotate'); break;
329
+ case 'z': case 'Z': case 'q': case 'Q': if (rotate(g, -1)) sound.play('rotate'); break;
330
+ case 'ArrowDown': case 's': case 'S': if (softDrop(g)) afterLock(g, performance.now()); else sound.play('soft'); break;
331
+ case ' ': hardDrop(g); sound.play('drop'); afterLock(g, performance.now()); break;
332
+ default: used = false;
333
+ }
334
+ if (used) { e.preventDefault(); G.dirty = true; if (g.over) gameOver(); }
335
+ }
336
+
337
+ function bind() {
338
+ if (G.bound) return;
339
+ G.bound = true;
340
+ document.addEventListener('keydown', onKey);
341
+ document.addEventListener('visibilitychange', () => { if (document.hidden && G.running && !G.paused) pause('paused — you looked away'); });
342
+ el('tetResume')?.addEventListener('click', () => resume());
343
+ for (const [id, key] of SWITCHES) el(id)?.addEventListener('click', () => flip(key));
344
+ }
345
+
346
+ export function renderTetrust(s, state, h) {
347
+ G.state = state; G.h = h;
348
+ bind();
349
+ drawSwitches();
350
+ drawSky();
351
+ if (!G.game) { overlay('Tetrust', 'trust, but verify — every line you clear is a block you verified. Enter or the button to play; arrows move, ↑ rotates, space drops.', 'play'); drawStats(); drawScores(); draw(); return; }
352
+ if (G.running && G.paused) overlay(G.why, 'the stack is holding', 'resume', true);
353
+ // a render while we are the page and not paused: keep the loop alive
354
+ if (G.running && !G.paused && !G.raf) { G.last = 0; G.raf = requestAnimationFrame(frame); }
355
+ else G.dirty = true, draw();
356
+ }