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,1014 @@
1
+ // SETTINGS THE VIEWER KEEPS (operator, 2026-09-12: "We need to add a configuration panel, allow
2
+ // persistent settings for our app ... tweaking the visual effects of the Block space. eg: remove
3
+ // shadows, simple cubes, lower level of detail etc... anything to make it run faster ... settings
4
+ // that affect the star field").
5
+ //
6
+ // Every switch here maps to an option the renderer already honours, or to one added for it
7
+ // (`shadows`, `starDensity`, `starBrightness`). Nothing in this file draws: it holds the values,
8
+ // clamps them, persists them on the server, and hands the renderers their options. A setting that
9
+ // did not change what is drawn would be a lie told in a checkbox.
10
+ //
11
+ // Stored on the server in config/blockyard.json (GET/POST /api/settings), so a phone and a desktop
12
+ // pointed at the same monitor agree; the browser keeps a copy under `blockyard.settings` so a board
13
+ // still draws when the server cannot be reached. (They were per browser until 2026-09-13.)
14
+ //
15
+ // THE STORE IS VERSIONED (operator, 2026-09-12: "Scope an improved durable settings menu", and
16
+ // the choice to build the foundation first). Regrouping a key used to be unaffordable: normalise
17
+ // dropped what it did not recognise and setSetting ignored what it could not place, so any move
18
+ // silently discarded the operator's stored choice. A version and a migration chain make a move
19
+ // survivable, and `sky` below is the first one to take it.
20
+
21
+ export const SETTINGS_KEY = 'blockyard.settings';
22
+ export const SCHEMA_VERSION = 4;
23
+
24
+ export const DEFAULTS = Object.freeze({
25
+ space: Object.freeze({
26
+ // OFF by default (operator, 2026-09-12: "make simple cubes the default, disable shadows by
27
+ // default"). Shadows are the costliest single thing the board draws -- one per resting stone
28
+ // and more in flight -- and the board is the first thing most people open.
29
+ shadows: false, // cube-on-cube and resting shadows (blockscene3d shadowOps)
30
+ idleFx: true, // the effects at rest: ripples, light cycles, the lightning ball
31
+ edges: true, // the dark seam around each stone
32
+ grid: true, // the neon grid on the board
33
+ // ITS COLOUR AND ITS BRIGHTNESS (operator, 2026-09-12: "Also add a color selector and
34
+ // brightness setting for the grid lighting for blockspace"). One hex drives the core, the
35
+ // halo, the glow and the bright edge line together -- see gridColours() for why it cannot be
36
+ // a single value. The shipped pair reproduces the hand-tuned green exactly, so the board does
37
+ // not change appearance until someone changes it.
38
+ gridColour: '#32be7d',
39
+ gridBrightness: 1,
40
+ // THE FINISH (operator, 2026-09-12: "consider neon-izing each of teh blocks, and adding an
41
+ // optional specular metallic sheen to the blocks. Have it toggle. I want to be able to apply
42
+ // the sheen onto simple cube mode if I want. think maximum configuration options"). Both
43
+ // opt-in: the shipped look is the deliberate one, these are finishes laid over it, and they
44
+ // work at every level of detail -- a Simple cube takes the sheen as well as a full one.
45
+ neon: false, // the edges of every block stroked in its own colour, lit
46
+ // THE NEON TUBES TUNED (operator, 2026-09-12: "preferences for tuning the neon outline colors
47
+ // ... Slider for brightness, color selection, realtime preview ... optionally have the neon
48
+ // color tied to the block temperature"): the tube takes the block's feerate colour, or one
49
+ // colour of the operator's choosing, at a brightness of their choosing
50
+ neonSource: 'temperature', // 'temperature' (the block's own colour) | 'colour' (neonColour)
51
+ neonColour: '#3d8bff', // the one colour, when chosen
52
+ neonBrightness: 1, // multiplies the tubes' alpha and width (0.2 .. 2)
53
+ sheen: false, // a metallic highlight along the lit edge of each top face
54
+ // WHICH METAL (operator, 2026-09-14: "give it a chrome or faux reflective effect ... really improve
55
+ // the metallic look"). 'chrome' mirrors a horizon in every face that slides as cubes move;
56
+ // 'satin' is the edge highlight the sheen always was. Chrome is the default because it is the
57
+ // answer to that ask; satin stays for anyone who preferred the quieter finish.
58
+ sheenStyle: 'chrome', // 'chrome' | 'satin'
59
+ // ON (operator, 2026-09-13: "the current settings I have saved out should be the shipping
60
+ // defaults"). It was opt-in because a twinkling field means the board never stops repainting.
61
+ // That cost is now paid deliberately rather than avoided: it is the look that was chosen.
62
+ stars: true,
63
+ dome: 5, // how far the board bows toward the viewer, 0 = flat
64
+ // HEIGHT THAT FORESHORTENS (operator, 2026-09-13: "I'm not seeing the bottom or the front face
65
+ // changing at all during movement ... fix camera stuff"). The oblique camera is parallel: height
66
+ // moves a cube up the screen and nothing scales, so a face is the same size at z=0 and z=80 --
67
+ // measured at 11.0px either way. This scales a point about the vanishing point by 1 + gz*rise,
68
+ // so a cube's top is larger than its base and a flying cube grows as it comes toward you.
69
+ //
70
+ // Off by default: a size that changes with height is exactly what the oblique camera was chosen
71
+ // to avoid ("nothing ever changes size -- so there is no growth to police and nothing to
72
+ // flicker"), and this area has already regressed the paint order three times. It ships as a
73
+ // control to be looked at and compared, not as a new default.
74
+ // RANGE: 0.01, then 0.08 (operator, 2026-09-14: "allow us a much greater range on the slider to
75
+ // adjust Depth"), then CAPPED AT 0.03 the same day ("Lock depth at 0.03. too many issues higher than
76
+ // that"). Measured on the paint order across a refresh, pops rise with depth -- 6 at 0.01, 24 at
77
+ // 0.03, 44 at 0.08 -- and past 0.03 a rising cube swells over its neighbours enough to be seen.
78
+ // A stored value above the cap is clamped to it on load; the default stays 0.
79
+ // ...AND THEN 0.001 (operator, 2026-09-14: "The board depth slider should be a limit between 0 and
80
+ // 0.001. It starts looking bad any higher than 0.001"), in steps of 0.0001 so the range still has
81
+ // ten stops. A stored value above it clamps on load.
82
+ perspective: 0, // 0 = the parallel camera; 0.001 the most allowed
83
+ light: 'overhead', // where the lamp is (operator, 2026-09-12: "directly above the board centered")
84
+ detail: 'simple', // 'full' | 'simple' | 'flat' -- facet and crown thresholds below; simple by default
85
+ motion: 'full', // 'full' | 'quick' | 'still' -- the refresh choreography
86
+ // HOW CUBES LEAVE AND ARRIVE (operator, 2026-09-13: "all the left and right side blocks are
87
+ // arcing towards/away from the sides instead of just traveling straight up ... make it a toggle
88
+ // for Linear vs Arcing", then "give me 3 choices to see and toggle between", then, having seen
89
+ // all three on the live board: "Get rid of straight up. Make Along the board's curve the
90
+ // default.")
91
+ //
92
+ // Measured before any of it: the shipped path is already nearly straight (the drawn slope dx/dy
93
+ // moves only 0.7335 -> 0.7481 over a whole climb at the left edge). What reads as arcing is
94
+ // that the straight line is STEEP -- three pixels sideways for every four up at the rim,
95
+ // against -0.017 over the middle. A `vertical` mode that removed the fan entirely was built and
96
+ // shown alongside these two, and cut after the comparison: the fan is the domed board being
97
+ // honest about itself.
98
+ //
99
+ // A stored 'vertical' from that round is not a valid value any more, and normalise resolves an
100
+ // unknown choice to this default, so those boards land on 'normal' rather than on nothing.
101
+ departures: 'normal', // 'normal' | 'arcing'
102
+ }),
103
+ // THE SKY IS ONE SKY. density and brightness lived under `markets` and were passed only to the
104
+ // markets board, so the Block space star field -- the same stars, drawn by the same code -- had
105
+ // no density or brightness control at all. Whether each board shows stars stays per board
106
+ // (space.stars, markets.stars); what the stars LOOK like belongs to neither.
107
+ sky: Object.freeze({
108
+ density: 3, // multiplies the star count (0.2 .. 3) -- the shipped look sits at the top of the range
109
+ brightness: 1, // multiplies each star's alpha (0.2 .. 1.5)
110
+ galaxy: true, // the same stars laid on spiral arms, turning once a quarter hour
111
+ galaxyAt: 'bottom-left', // where its middle sits: behind the board, or any of the corners
112
+ // THE LAYERS OF THE SKY, each its own switch (operator, 2026-09-12: "We should have toggles for
113
+ // all these sub-options in preferences"). All on: they were asked for, and a feature shipped
114
+ // behind an off switch is not shipped.
115
+ nebulae: true, // gas clouds on the arms
116
+ galaxies: true, // distant galaxies in the deep field behind everything
117
+ dust: true, // dark lanes along the inner edge of each arm
118
+ clusters: true, // tight knots of stars out in the halo
119
+ colours: true, // stars coloured by population: warm bulge, blue-white arms
120
+ glints: true, // the halo and cross glint on the brightest stars
121
+ }),
122
+ // `glow` was here and is gone (operator, 2026-09-12: "on markets and price. we should never show
123
+ // the grid glow. that's just terrible"). Never-show makes the switch a control nobody may use,
124
+ // and a control that must stay off is worse than no control: the board forces it off now.
125
+ markets: Object.freeze({
126
+ stars: true,
127
+ effects: true, // the idle effects and the flight when the candles refresh
128
+ // THE TOOLBAR REMEMBERS (operator, 2026-09-12: "We need to remember the user settings for the
129
+ // Markets page"). Which exchange and how many hours were a click that survived until the tab
130
+ // was closed; they are preferences, and they live here now. Strings, because a <select> hands
131
+ // back a string and a number that arrives as "24" must still match its own control.
132
+ exchange: 'coinbase',
133
+ range: '24',
134
+ // ONE VIEW OR THE OTHER, never both (operator, 2026-09-12: "For markets page, have a selector
135
+ // for either the 3D view or 2D view for price. Not both at the same time. Too much waste of
136
+ // space for that screen"). The page drew the 3D board AND the flat candlestick chart on every
137
+ // render, one above the other, which is two tall panels of the same hours. A string for the
138
+ // same reason `range` is one: a control hands back a string, and a remembered choice has to
139
+ // match what its own control offers.
140
+ // 2D by default (operator, 2026-09-12: "make 2D view the default"): the flat chart is the one
141
+ // that answers "what is the price doing" at a glance, with axes and a crosshair. The board is
142
+ // the showpiece, and it is one click away.
143
+ priceView: '2d', // '2d' (the flat chart) | '3d' (the candle board)
144
+ // THE SUMMARY LINE ON OVERVIEW (operator, 2026-09-13: "We really need to squeeze this line into
145
+ // the top of the Overview, between Sync status and Block Flow").
146
+ //
147
+ // ON by default (operator, 2026-09-13: "In Display and Settings, enable 'Price Line on
148
+ // Overview' checked as default"). It shipped OFF, and the reason it was off still stands and
149
+ // is worth stating plainly rather than deleting: those four figures come from the full
150
+ // exchange feed, which is otherwise parked unless someone is on Markets or Kiosk -- and
151
+ // Overview is the page the app OPENS on. So with this on, EVERY deployment contacts five
152
+ // exchanges the moment anyone looks at it, not only when they go looking for a price.
153
+ //
154
+ // That is the operator's call to make, and it is made. What must not happen is the code
155
+ // quietly disagreeing with the promise: docs/SECURITY.md's outbound table and the README row
156
+ // both now say Overview reaches out by default, and the switch is still here for anyone who
157
+ // wants the old behaviour.
158
+ overviewSummary: true,
159
+ }),
160
+ // EVERY EFFECT ITS OWN SWITCH (operator, 2026-09-12: "at least 25 total different effects, all
161
+ // toggleable"), AND EACH BOARD ITS OWN LIST (operator, 2026-09-14: "I want the markets tab to have
162
+ // a separate effects list ... the Block Space effects specific to that panel, and settings specific
163
+ // to market panel"). This group is the block board's: its keys are exactly details3d's SPACE_FX --
164
+ // every effect but the two drawn on a price line -- and a test asserts the lists match, so an
165
+ // effect cannot ship without a switch or a switch outlive its effect. All on: they were asked
166
+ // for, and the board picks among whatever is left on (scheduleFx). Turn them all off and the
167
+ // board simply rests, which `idleFx` also does in one click.
168
+ effects: Object.freeze({
169
+ ripple: true, outline: true, tide: true, cascade: true, twinkle: true, scan: true,
170
+ lightcycle: true, ball: true,
171
+ shockwave: true, nova: true, firework: true, flare: true, wave: true, quake: true,
172
+ rain: true, sparkle: true, checker: true, radar: true, vortex: true, powerup: true, combo: true, aurora: true, plasma: true,
173
+ // the agents: something happening on the board, rather than a pattern over it
174
+ centipede: true, tractor: true, missile: true, boulderdash: true, stormball: true,
175
+ // NO REPEATS (operator, 2026-09-14: "add a config field that defaults to 12. Make sure to pick a
176
+ // random effect to play, but never pick one that has been played in the last 12 sequences"). A
177
+ // number, not a switch: enabledEffects reads only the switches.
178
+ noRepeat: 12,
179
+ // THE CADENCE (operator, 2026-09-14: "add sliders to each the market and block space effects
180
+ // panels so we can tune the randomized timing of the events being triggered ... maximum
181
+ // configurability on how often they see effects trigger for each panel, based on the current
182
+ // defaults ... delay effects being triggered even longer than they are now"). The scheduler
183
+ // waits a random span between pauseMin and pauseMax seconds after one effect before the next
184
+ // (details3d idleEvery, 5-9 s since 2026-09-11), and firstAfter seconds, give or take a third,
185
+ // for the first after the board lands (idleFirst, 0.8-1.6 s). Up to ten minutes between.
186
+ pauseMin: 5,
187
+ pauseMax: 9,
188
+ firstAfter: 1.2,
189
+ }),
190
+ // THE PRICE BOARD'S OWN LIST: details3d's MARKET_FX -- the twelve that translate to a candle
191
+ // chart (operator, 2026-09-14: "the selection I have made for the market effects is what we
192
+ // should ship with ... Many of the effects don't translate over to the market chart"). Its own
193
+ // no-repeat window too. All on, twelve.
194
+ marketEffects: Object.freeze({
195
+ ripple: true, outline: true, tide: true, cascade: true, twinkle: true, scan: true,
196
+ pulse: true, // the surge that runs the price line
197
+ bulge: true, // a sphere rolls through the pipe and it swells round it
198
+ firework: true, flare: true, wave: true, stormball: true,
199
+ noRepeat: 12,
200
+ // no firstAfter here: the candle board does not land (operator, 2026-09-14: "there is no
201
+ // 'landing' for the markets display"); after a refresh its first effect keeps the cadence below
202
+ pauseMin: 5,
203
+ pauseMax: 9,
204
+ }),
205
+ // BLOCKOUT (operator, 2026-09-12: "take the classic Atari Breakout game, and make a clone of it,
206
+ // in another tab, using our engine"). The same shape as the Tetrust group: the game says whether
207
+ // there is a sky and a galaxy; what the sky is MADE of comes from the Sky group.
208
+ blockout: Object.freeze({
209
+ stars: true,
210
+ galaxy: true,
211
+ galaxyAt: 'center',
212
+ neon: false, // the bricks as dim bodies under lit tubes
213
+ neonSource: 'brick', // 'brick' (each row's own colour) | 'colour'
214
+ neonColour: '#3d8bff',
215
+ neonBrightness: 1,
216
+ // the grid under the court, its own now rather than the engine's hardcoded green
217
+ grid: true,
218
+ gridColour: '#3cc88c',
219
+ gridBrightness: 0.35, // well down from full: the court reads better with the lattice faint
220
+ sfx: true,
221
+ }),
222
+ // BLOCKANOID (operator, 2026-09-12: "Take blockout, and make rip off of Arkanoid using our
223
+ // engine, and make it a new Diversion called 'Blockanoid'"). Blockout's shape plus the two
224
+ // switches Arkanoid earns: whether capsules fall at all, and whether the minions turn up.
225
+ blockanoid: Object.freeze({
226
+ stars: true,
227
+ galaxy: true,
228
+ galaxyAt: 'center',
229
+ neon: false,
230
+ neonSource: 'brick', // 'brick' (the brick's own colour) | 'colour'
231
+ neonColour: '#3d8bff',
232
+ neonBrightness: 1,
233
+ capsules: true, // the falling letters
234
+ enemies: true, // the minions drifting down the court
235
+ grid: true, // the grid under the court
236
+ gridColour: '#332c63', // deep indigo rather than the engine green
237
+ gridBrightness: 1,
238
+ sfx: true,
239
+ }),
240
+ // TETRUST (operator, 2026-09-12: "Have this entire panel filled black and rendering the spiral
241
+ // galaxy for this display. Have the text floating over the spiral galaxy ... add toggles for
242
+ // those settings in the game and have them persistent in settings"). The sky is the panel's own
243
+ // canvas behind the well, so it costs the game nothing per key press.
244
+ tetrust: Object.freeze({
245
+ stars: true, // the star field across the whole panel
246
+ galaxy: true, // the spiral galaxy in it
247
+ galaxyAt: 'center', // where its centre sits: behind the title
248
+ // the landing marker's colour (operator: "I want to be able to set the ghost wireframe
249
+ // color"). Its own setting, not the neon tubes': the wireframe is drawn instead of a block,
250
+ // so the neon finish never touches it.
251
+ ghostColour: '#2f2c44',
252
+ ghostWidth: 0.5, // multiplies the marker's line thickness (operator: "thinner lines")
253
+ music: true, // the tune
254
+ sfx: true, // the effects: move, rotate, drop, clear, game over
255
+ neon: false, // neon tubes on the pieces and the stack
256
+ neonSource: 'piece', // 'piece' (each piece's colour) | 'colour' (neonColour)
257
+ neonColour: '#3d8bff',
258
+ neonBrightness: 1,
259
+ grid: true, // the grid under the well
260
+ gridColour: '#1844bf', // blue rather than the engine green
261
+ gridBrightness: 1.55, // and above full, which the blue needs to read at all
262
+ }),
263
+ });
264
+
265
+ const DETAIL = {
266
+ // facetPx/crownPx are "draw this extra detail only when a stone is at least this many device
267
+ // pixels across". Raising them is the cheap way to simplify: the same scene, fewer polygons.
268
+ full: { facetPx: 9, crownPx: 18 },
269
+ simple: { facetPx: 26, crownPx: Infinity }, // the divot IS the crown: never, at any size
270
+ flat: { facetPx: Infinity, crownPx: Infinity }, // facets and crown only: the seam is the operator's call
271
+ };
272
+
273
+ const MOTION = {
274
+ full: null, // the shipped 20 s choreography
275
+ quick: { rise: 900, travel: 3200, drop: 1800 },
276
+ still: { rise: 0, travel: 1, drop: 0 }, // lands immediately; no flight
277
+ };
278
+
279
+ // What the panel draws. Kept beside the values so a new setting cannot be added without a
280
+ // control, or a control without a value -- and, since normalise reads its bounds from here, so
281
+ // that a slider and the clamp behind it cannot disagree. They used to be written out twice.
282
+ // THE EFFECT ROWS, one table for both boards' groups: the label and the hint of each effect are
283
+ // written once, and each group below lists the keys it offers.
284
+ const FX_ROW = Object.freeze({
285
+ ripple: Object.freeze({ label: 'Ripple', hint: 'A ring spreading from a point on the board' }),
286
+ outline: Object.freeze({ label: 'Outline sweep', hint: 'A front that traces each block\u2019s edges as it passes' }),
287
+ tide: Object.freeze({ label: 'Tide', hint: 'A swell that lifts the blocks it passes under' }),
288
+ cascade: Object.freeze({ label: 'Cascade', hint: 'The blocks light in feerate order, richest first' }),
289
+ twinkle: Object.freeze({ label: 'Twinkle', hint: 'Scattered blocks flash white, each on its own beat' }),
290
+ scan: Object.freeze({ label: 'Scan line', hint: 'A tight line crossing the board, edge to edge' }),
291
+ lightcycle: Object.freeze({ label: 'Light cycles', hint: 'Two riders from opposite edges, leaving light walls, until one crashes' }),
292
+ ball: Object.freeze({ label: 'Lightning ball', hint: 'A plasma ball tracing the grid, throwing bolts and a dust trail' }),
293
+ pulse: Object.freeze({ label: 'Energy pulse', hint: 'The surge that runs the price line on Markets, blue behind the head' }),
294
+ bulge: Object.freeze({ label: 'Pipe bulge', hint: 'On Markets: a glowing sphere rolls through the price line left to right, and the pipe swells around it as it passes' }),
295
+ shockwave: Object.freeze({ label: 'Shockwave', hint: 'A hard ring that throws the blocks it passes into the air' }),
296
+ nova: Object.freeze({ label: 'Nova', hint: 'An implosion to the middle, then a brighter blast back out' }),
297
+ firework: Object.freeze({ label: 'Fireworks', hint: 'Three bursts, each at its own moment and place' }),
298
+ flare: Object.freeze({ label: 'Solar flare', hint: 'One block goes supernova and lights its neighbourhood' }),
299
+ wave: Object.freeze({ label: 'Wave', hint: 'Several crests rolling across the board, the blocks riding them' }),
300
+ quake: Object.freeze({ label: 'Quake', hint: 'The board shakes, hardest at the start, and settles' }),
301
+ rain: Object.freeze({ label: 'Code rain', hint: 'A drop falls down every column with a white head and a green tail' }),
302
+ sparkle: Object.freeze({ label: 'Sparkle', hint: 'A constellation lighting a few blocks at a time, each its own colour' }),
303
+ checker: Object.freeze({ label: 'Checkerboard', hint: 'The board flips like a chessboard, dark squares against light' }),
304
+ radar: Object.freeze({ label: 'Radar', hint: 'A sweep hand turning once, the blocks behind it fading like phosphor' }),
305
+ vortex: Object.freeze({ label: 'Vortex', hint: 'Spiral arms turning, draining the board inward' }),
306
+ powerup: Object.freeze({ label: 'Power-up', hint: 'The board charges from the floor up, gold, with a bright lip' }),
307
+ combo: Object.freeze({ label: 'Combo chain', hint: 'A chain reaction running the diagonal, each link popping in turn' }),
308
+ aurora: Object.freeze({ label: 'Aurora', hint: 'Slow curtains of colour drifting over the board' }),
309
+ plasma: Object.freeze({ label: 'Plasma', hint: 'The demoscene plasma: sines over the board, the colour cycling' }),
310
+ centipede: Object.freeze({ label: 'Centipede', hint: 'A column weaving down the board that splits in two partway, each half carrying on' }),
311
+ tractor: Object.freeze({ label: 'Tractor beam', hint: 'A saucer draws the tallest transaction up in a beam, and puts it back' }),
312
+ missile: Object.freeze({ label: 'Interception', hint: 'Arcs rain toward the board while interceptors rise to meet them, each catch a ring of light' }),
313
+ boulderdash: Object.freeze({ label: 'Collapse', hint: 'The board gives way from a point and the blocks fall in, cascading outward' }),
314
+ stormball: Object.freeze({ label: 'Ball lightning', hint: 'An electric blue sphere in a nebula drifts across the view, crackling, throwing arcs that electrify the blocks they strike; a struck block often throws a green arc on to another, and that one sometimes on to a third' }),
315
+ });
316
+ // where an effect reads differently on the price board, the price board's hint
317
+ const MARKET_HINT = Object.freeze({
318
+ twinkle: 'Scattered candles flash white, each on its own beat',
319
+ pulse: 'The surge that runs the price line, blue behind the head',
320
+ bulge: 'A glowing sphere rolls through the price line left to right, and the pipe swells around it as it passes',
321
+ stormball: 'An electric blue sphere in a nebula flies through the chart, striking candles and charging the price line where it passes; a struck candle often throws a green arc on to another, and that one sometimes on to a third',
322
+ cascade: 'The candles light in order, tallest first',
323
+ flare: 'One candle goes supernova and lights its neighbourhood',
324
+ });
325
+ // THE WINDOW'S TOP IS THE LIST'S LENGTH (operator, 2026-09-14: "for the markets page, all we have
326
+ // is 12 effects, so max the slider out at max effects"): a window wider than the list is the same
327
+ // as one the list's length (chooseIdleFx clamps it), so the slider stops where the meaning does.
328
+ const noRepeatRow = (max) => Object.freeze({ key: 'noRepeat', label: 'No repeats within', kind: 'range', min: 0, max, step: 1, hint: `An effect is never played again until this many other effects have played since (at ${max}, every effect on the list plays before any comes round again). Where fewer effects are switched on, the one that has waited longest plays next` });
329
+ // THE CADENCE SLIDERS, on both tabs: how long the board rests between effects, as a random span
330
+ // between a floor and a ceiling, and how soon the first one comes after the board lands. Ten
331
+ // minutes is the top: an operator who wants a still board has the switches and all off.
332
+ // `top`: the sliders' ceiling in seconds -- ten minutes on the block board, five on the candle board
333
+ // (operator, 2026-09-14: "600 seconds is too long for the market effects time sliders. Have the
334
+ // range on the bars be between 1 and 300 seconds")
335
+ const cadenceRows = (top) => Object.freeze([
336
+ Object.freeze({ key: 'pauseMin', label: 'Between effects, at least', kind: 'range', min: 1, max: top, step: 1, hint: 'Seconds the board rests after one effect before the next may start. The wait is a random span between this and the ceiling below; if this is set above the ceiling, the two swap' }),
337
+ Object.freeze({ key: 'pauseMax', label: 'Between effects, at most', kind: 'range', min: 1, max: top, step: 1, hint: 'The ceiling on that wait, in seconds. Set both high for an effect only now and then; set both low for a busy board' }),
338
+ ]);
339
+ // only the block board lands (the flight when the pool changes); the candle board has no landing
340
+ // to time from, so it gets the two sliders and its first effect after a refresh keeps the cadence
341
+ const LANDING_ROW = Object.freeze({ key: 'firstAfter', label: 'First effect after landing', kind: 'range', min: 0, max: 120, step: 0.1, hint: 'Seconds after the blocks land before the first effect, give or take a third. The board re-lays on every refresh, so this is also how soon one follows each refresh' });
342
+ const fxRows = (keys, hintFor = {}, { landing = true, top = 600 } = {}) => Object.freeze([noRepeatRow(keys.length), ...cadenceRows(top), ...(landing ? [LANDING_ROW] : []), ...keys.map((key) => Object.freeze({ key, label: FX_ROW[key].label, kind: 'toggle', hint: hintFor[key] ?? FX_ROW[key].hint }))]);
343
+
344
+ /** The scheduler's timers from a group's sliders, in ms: [floor, ceiling] between effects, and the first after landing (the same span where the board has no landing). */
345
+ export function fxCadence(g) {
346
+ const lo = Math.min(g.pauseMin, g.pauseMax), hi = Math.max(g.pauseMin, g.pauseMax);
347
+ const idleEvery = [lo * 1000, hi * 1000];
348
+ const idleFirst = Number.isFinite(g.firstAfter) ? [Math.round(g.firstAfter * 1000 * (2 / 3)), Math.round(g.firstAfter * 1000 * (4 / 3))] : idleEvery;
349
+ return { idleEvery, idleFirst };
350
+ }
351
+
352
+ export const PANEL = Object.freeze([
353
+ Object.freeze({
354
+ group: 'space',
355
+ title: 'Block space',
356
+ note: 'The 3D board on Overview, Block space, Mempool and Kiosk. Turn things off here if the board is heavy on this machine.',
357
+ rows: Object.freeze([
358
+ Object.freeze({ key: 'shadows', label: 'Shadows', kind: 'toggle', hint: 'Cubes casting shadows on the board and on each other' }),
359
+ Object.freeze({ key: 'idleFx', label: 'Idle effects', kind: 'toggle', hint: 'The effects while the board rests: which of them is the Space effects tab' }),
360
+ Object.freeze({ key: 'edges', label: 'Stone edges', kind: 'toggle', hint: 'The dark seam around each stone' }),
361
+ Object.freeze({ key: 'grid', label: 'Neon grid', kind: 'toggle', hint: 'The glowing grid on the board' }),
362
+ Object.freeze({ key: 'gridColour', label: 'Grid colour', kind: 'colour', hint: 'The grid’s colour: its lit core, and the halo and glow around it, all take it together' }),
363
+ Object.freeze({ key: 'gridBrightness', label: 'Grid brightness', kind: 'range', min: 0, max: 2, step: 0.05, hint: 'How hard the grid burns; 1 is the shipped grid, 0 leaves the lines unlit' }),
364
+ Object.freeze({ key: 'neon', label: 'Neon blocks', kind: 'toggle', hint: 'Every block a dim solid body under lit tubes on its edges. Works at any level of detail' }),
365
+ Object.freeze({
366
+ key: 'neonSource', label: 'Neon colour from', kind: 'choice', hint: 'The tubes in each block’s own feerate colour, or all in one colour',
367
+ options: Object.freeze([['temperature', 'The block’s feerate colour'], ['colour', 'One colour']]),
368
+ }),
369
+ Object.freeze({ key: 'neonColour', label: 'Neon colour', kind: 'colour', hint: 'The one colour, when chosen above' }),
370
+ Object.freeze({ key: 'neonBrightness', label: 'Neon brightness', kind: 'range', min: 0.2, max: 2, step: 0.1, hint: 'How hard the tubes glow; 1 is the shipped glow' }),
371
+ Object.freeze({ key: 'sheen', label: 'Metallic sheen', kind: 'toggle', hint: 'A specular highlight along the lit edge of each block’s top face. Works on Simple cubes too' }),
372
+ Object.freeze({
373
+ key: 'sheenStyle', label: 'Metallic finish', kind: 'choice', hint: 'Chrome mirrors a horizon in every face that slides as the blocks move; satin is a softer highlight along the lit edge. Needs Metallic sheen on',
374
+ options: Object.freeze([['chrome', 'Chrome'], ['satin', 'Satin']]),
375
+ }),
376
+ Object.freeze({ key: 'stars', label: 'Star field', kind: 'toggle', hint: 'Stars behind the board. They twinkle, so the board keeps repainting while they are on' }),
377
+ Object.freeze({
378
+ key: 'detail', label: 'Level of detail', kind: 'choice', hint: 'Simpler cubes draw fewer polygons at the same size',
379
+ options: Object.freeze([['full', 'Full'], ['simple', 'Simple cubes'], ['flat', 'Flat tiles']]),
380
+ }),
381
+ Object.freeze({
382
+ key: 'motion', label: 'Refresh animation', kind: 'choice', hint: 'How blocks travel when the board refreshes',
383
+ options: Object.freeze([['full', 'Full flight'], ['quick', 'Quick'], ['still', 'None']]),
384
+ }),
385
+ Object.freeze({
386
+ key: 'departures', label: 'Departures and arrivals', kind: 'choice',
387
+ hint: 'The path a block takes as it leaves or arrives. Both follow the board’s curve outward from the middle; along the curve is a straight line, arcing bends as the block climbs',
388
+ options: Object.freeze([['normal', 'Along the board’s curve'], ['arcing', 'Arcing (original)']]),
389
+ }),
390
+ Object.freeze({ key: 'dome', label: 'Board curve', kind: 'range', min: 0, max: 12, step: 1, hint: 'How far the board bows toward you; 0 is flat' }),
391
+ Object.freeze({ key: 'perspective', label: 'Depth', kind: 'range', min: 0, max: 0.001, step: 0.0001, hint: 'How much height foreshortens. 0 is the flat parallel camera the board shipped with: a cube is the same size however high it flies. Raise it and a cube’s top grows a little wider than its base and a flying block swells slightly as it rises' }),
392
+ Object.freeze({
393
+ key: 'light', label: 'Light', kind: 'choice', hint: 'Where the lamp hangs. Straight above lights the whole board evenly; a corner shades the far slope of the curve and the sides turned away',
394
+ options: Object.freeze([['overhead', 'Straight above'], ['upper-left', 'Upper left'], ['upper-right', 'Upper right'], ['front', 'From the viewer']]),
395
+ }),
396
+ ]),
397
+ }),
398
+ Object.freeze({
399
+ group: 'sky',
400
+ title: 'Sky',
401
+ note: 'The star field itself, wherever it is drawn — behind the Block space board and behind the candles. Each board decides whether to show it; this decides what it looks like.',
402
+ rows: Object.freeze([
403
+ Object.freeze({ key: 'galaxy', label: 'Spiral galaxy', kind: 'toggle', hint: 'Lay the stars on slowly turning spiral arms instead of scattering them evenly. One turn takes about fifteen minutes' }),
404
+ Object.freeze({
405
+ key: 'galaxyAt', label: 'Galaxy centre', kind: 'choice',
406
+ hint: 'Where its middle sits. A corner crowds the bright centre there and sweeps the arms across; behind the board shows the whole spiral',
407
+ options: Object.freeze([['center', 'Behind the board'], ['top-left', 'Top left'], ['top-right', 'Top right'], ['bottom-left', 'Bottom left'], ['bottom-right', 'Bottom right']]),
408
+ }),
409
+ Object.freeze({ key: 'nebulae', label: 'Nebulae', kind: 'toggle', hint: 'Clouds of gas along the spiral arms, in the colours of star-forming lanes' }),
410
+ Object.freeze({ key: 'dust', label: 'Dust lanes', kind: 'toggle', hint: 'Dark ribbons along the inner edge of each arm, the way a real spiral carries them' }),
411
+ Object.freeze({ key: 'clusters', label: 'Star clusters', kind: 'toggle', hint: 'Tight knots of stars out in the halo, turning with the galaxy' }),
412
+ Object.freeze({ key: 'galaxies', label: 'Distant galaxies', kind: 'toggle', hint: 'Other galaxies, small and faint and far, behind everything else' }),
413
+ Object.freeze({ key: 'colours', label: 'Star colours', kind: 'toggle', hint: 'Warm old stars in the middle, blue-white young ones in the arms. Off is one colour of starlight' }),
414
+ Object.freeze({ key: 'glints', label: 'Star glints', kind: 'toggle', hint: 'The halo and cross glint on the brightest stars' }),
415
+ Object.freeze({ key: 'density', label: 'Star density', kind: 'range', min: 0.2, max: 8, step: 0.1, hint: 'How many stars, against the shipped number. High values are a lot of drawing on a big panel' }),
416
+ Object.freeze({ key: 'brightness', label: 'Star brightness', kind: 'range', min: 0.2, max: 1.5, step: 0.1, hint: 'How brightly they burn' }),
417
+ ]),
418
+ }),
419
+ Object.freeze({
420
+ group: 'markets',
421
+ title: 'Markets & Price',
422
+ note: 'The candle board on Markets and Kiosk.',
423
+ rows: Object.freeze([
424
+ Object.freeze({ key: 'stars', label: 'Star field', kind: 'toggle', hint: 'The twinkling sky behind the candles' }),
425
+ Object.freeze({ key: 'effects', label: 'Board effects', kind: 'toggle', hint: 'The idle effects while the board rests (which of them is the Market effects tab) and the flight when the candles refresh. Off draws the board and leaves it alone' }),
426
+ Object.freeze({
427
+ key: 'exchange', label: 'Exchange', kind: 'choice', hint: 'Whose candles the chart and the 3D board draw. The others stay as overlay lines',
428
+ options: Object.freeze([['coinbase', 'Coinbase'], ['kraken', 'Kraken'], ['bitstamp', 'Bitstamp'], ['bitfinex', 'Bitfinex'], ['okx', 'OKX']]),
429
+ }),
430
+ Object.freeze({
431
+ key: 'range', label: 'Range', kind: 'choice', hint: 'How many hours the chart covers when the page opens',
432
+ options: Object.freeze([['24', '24 hours'], ['48', '48 hours'], ['168', '7 days']]),
433
+ }),
434
+ Object.freeze({
435
+ key: 'overviewSummary', label: 'Price line on Overview', kind: 'toggle',
436
+ hint: 'Median, spread, 24 h volume and how many books reported, at the top of Overview. '
437
+ + 'On by default: it needs the exchange feed, so leaving it on means this monitor '
438
+ + 'contacts five exchanges whenever Overview is open, not only on Markets and Kiosk. '
439
+ + 'Switch it off and the landing page talks to nothing but your node.',
440
+ }),
441
+ Object.freeze({
442
+ key: 'priceView', label: 'Price view', kind: 'choice', hint: 'Which one the Markets page draws. Only one at a time — they show the same hours, and two tall panels of it filled the screen',
443
+ options: Object.freeze([['2d', 'Flat chart'], ['3d', '3D candle board']]),
444
+ }),
445
+ ]),
446
+ }),
447
+ Object.freeze({
448
+ group: 'blockout',
449
+ title: 'Blockout',
450
+ note: 'The Breakout court. These switches are also on the game\u2019s own panel; the sky takes its density, brightness and layers from Sky above.',
451
+ rows: Object.freeze([
452
+ Object.freeze({ key: 'stars', label: 'Star field', kind: 'toggle', hint: 'The sky across the whole panel, behind the court' }),
453
+ Object.freeze({ key: 'galaxy', label: 'Spiral galaxy', kind: 'toggle', hint: 'The galaxy in that sky, turning' }),
454
+ Object.freeze({
455
+ key: 'galaxyAt', label: 'Galaxy centre', kind: 'choice', hint: 'Where the galaxy\u2019s centre sits on the panel',
456
+ options: Object.freeze([['center', 'Behind the court'], ['top-left', 'Top left'], ['top-right', 'Top right'], ['bottom-left', 'Bottom left'], ['bottom-right', 'Bottom right']]),
457
+ }),
458
+ Object.freeze({ key: 'neon', label: 'Neon bricks', kind: 'toggle', hint: 'The wall, the bat and the ball as dim bodies under lit tubes' }),
459
+ Object.freeze({
460
+ key: 'neonSource', label: 'Neon colour from', kind: 'choice', hint: 'Each brick row\u2019s own colour, or all in one colour',
461
+ options: Object.freeze([['brick', 'The brick\u2019s colour'], ['colour', 'One colour']]),
462
+ }),
463
+ Object.freeze({ key: 'neonColour', label: 'Neon colour', kind: 'colour', hint: 'The one colour, when chosen above' }),
464
+ Object.freeze({ key: 'neonBrightness', label: 'Neon brightness', kind: 'range', min: 0.2, max: 2, step: 0.1, hint: 'How hard the tubes glow' }),
465
+ Object.freeze({ key: 'grid', label: 'Grid', kind: 'toggle', hint: 'The grid under the court' }),
466
+ Object.freeze({ key: 'gridColour', label: 'Grid colour', kind: 'colour', hint: 'The colour of the grid under the court' }),
467
+ Object.freeze({ key: 'gridBrightness', label: 'Grid intensity', kind: 'range', min: 0, max: 2, step: 0.05, hint: 'How strongly the grid shows; 1 is the shipped weight, 0 hides it' }),
468
+ Object.freeze({ key: 'sfx', label: 'Sound effects', kind: 'toggle', hint: 'The bat, the bricks, the walls and a lost ball' }),
469
+ ]),
470
+ }),
471
+ Object.freeze({
472
+ group: 'blockanoid',
473
+ title: 'Blockanoid',
474
+ note: 'The Arkanoid court: silver bricks that take more than one hit, gold that takes none, and capsules that fall out of what you break. These switches are also on the game’s own panel.',
475
+ rows: Object.freeze([
476
+ Object.freeze({ key: 'stars', label: 'Star field', kind: 'toggle', hint: 'The sky across the whole panel, behind the court' }),
477
+ Object.freeze({ key: 'galaxy', label: 'Spiral galaxy', kind: 'toggle', hint: 'The galaxy in that sky, turning' }),
478
+ Object.freeze({
479
+ key: 'galaxyAt', label: 'Galaxy centre', kind: 'choice', hint: 'Where the galaxy’s centre sits on the panel',
480
+ options: Object.freeze([['center', 'Behind the court'], ['top-left', 'Top left'], ['top-right', 'Top right'], ['bottom-left', 'Bottom left'], ['bottom-right', 'Bottom right']]),
481
+ }),
482
+ Object.freeze({ key: 'neon', label: 'Neon bricks', kind: 'toggle', hint: 'The wall, Vaus and the ball as dim bodies under lit tubes' }),
483
+ Object.freeze({
484
+ key: 'neonSource', label: 'Neon colour from', kind: 'choice', hint: 'Each brick’s own colour, or all in one colour',
485
+ options: Object.freeze([['brick', 'The brick’s colour'], ['colour', 'One colour']]),
486
+ }),
487
+ Object.freeze({ key: 'neonColour', label: 'Neon colour', kind: 'colour', hint: 'The one colour, when chosen above' }),
488
+ Object.freeze({ key: 'neonBrightness', label: 'Neon brightness', kind: 'range', min: 0.2, max: 2, step: 0.1, hint: 'How hard the tubes glow' }),
489
+ Object.freeze({ key: 'capsules', label: 'Capsules', kind: 'toggle', hint: 'The letters that fall out of broken bricks: laser, wide, catch, slow, three balls, a life, skip' }),
490
+ Object.freeze({ key: 'enemies', label: 'Minions', kind: 'toggle', hint: 'The drifting shapes that spoil your aim, and pay when you hit one' }),
491
+ Object.freeze({ key: 'grid', label: 'Grid', kind: 'toggle', hint: 'The grid under the court' }),
492
+ Object.freeze({ key: 'gridColour', label: 'Grid colour', kind: 'colour', hint: 'The colour of the grid under the court' }),
493
+ Object.freeze({ key: 'gridBrightness', label: 'Grid intensity', kind: 'range', min: 0, max: 2, step: 0.05, hint: 'How strongly the grid shows; 1 is the shipped weight, 0 hides it' }),
494
+ Object.freeze({ key: 'sfx', label: 'Sound effects', kind: 'toggle', hint: 'Vaus, the bricks, the capsules, the laser and a lost ball' }),
495
+ ]),
496
+ }),
497
+ // TWO EFFECTS TABS, one per board (operator, 2026-09-14: "I want the markets tab to have a
498
+ // separate effects list"). The rows come from FX_ROW above; the key lists are the boards' own and
499
+ // test/effects.test.js holds them to details3d's SPACE_FX and MARKET_FX.
500
+ Object.freeze({
501
+ group: 'effects',
502
+ title: 'Space effects',
503
+ note: 'What the Block space board may play while it rests. One is chosen at random every seven to thirteen seconds, never one played within the no-repeat window \u2014 so the more you leave on, the less often you see any one of them. The Markets board has a list of its own, on the next tab.',
504
+ bulk: true,
505
+ rows: fxRows([
506
+ 'ripple', 'outline', 'tide', 'cascade', 'twinkle', 'scan', 'lightcycle', 'ball', 'shockwave', 'nova',
507
+ 'firework', 'flare', 'wave', 'quake', 'rain', 'sparkle', 'checker', 'radar', 'vortex', 'powerup',
508
+ 'combo', 'aurora', 'plasma', 'centipede', 'tractor', 'missile', 'boulderdash', 'stormball',
509
+ ]),
510
+ }),
511
+ Object.freeze({
512
+ group: 'marketEffects',
513
+ title: 'Market effects',
514
+ note: 'What the candle board on Markets and Kiosk may play while it rests, chosen the same way as on Block space but from the twelve that translate to a chart: fronts along the hours, bursts from the candle row, the candles lit where they stand, and the price line\u2019s own pulse, bulge and ball lightning. The Board effects switch on the Markets & Price tab is the master.',
515
+ bulk: true,
516
+ rows: fxRows([
517
+ 'ripple', 'outline', 'tide', 'cascade', 'twinkle', 'scan', 'pulse', 'bulge', 'firework', 'flare', 'wave', 'stormball',
518
+ ], MARKET_HINT, { landing: false, top: 300 }),
519
+ }),
520
+ Object.freeze({
521
+ group: 'tetrust',
522
+ title: 'Tetrust',
523
+ note: 'The game. These switches are also on the game’s own panel; the sky takes the star field’s density, brightness and layers from Sky above.',
524
+ rows: Object.freeze([
525
+ Object.freeze({ key: 'stars', label: 'Star field', kind: 'toggle', hint: 'The sky across the whole panel, behind the well' }),
526
+ Object.freeze({ key: 'galaxy', label: 'Spiral galaxy', kind: 'toggle', hint: 'The galaxy in that sky, turning' }),
527
+ Object.freeze({
528
+ key: 'galaxyAt', label: 'Galaxy centre', kind: 'choice', hint: 'Where the galaxy’s centre sits on the panel: behind the title, or a corner',
529
+ options: Object.freeze([['center', 'Behind the title'], ['top-left', 'Top left'], ['top-right', 'Top right'], ['bottom-left', 'Bottom left'], ['bottom-right', 'Bottom right']]),
530
+ }),
531
+ Object.freeze({ key: 'ghostColour', label: 'Landing marker', kind: 'colour', hint: 'The wireframe on the floor of the well showing where the falling piece will land. Its own colour: the neon finish below never touches it, because the marker is drawn instead of a block rather than over one' }),
532
+ Object.freeze({ key: 'ghostWidth', label: 'Landing marker thickness', kind: 'range', min: 0.3, max: 2.5, step: 0.1, hint: 'How heavy the marker\u2019s lines are. 1 is the shipped weight; below it the outline thins out of the way of the stack behind it' }),
533
+ Object.freeze({ key: 'music', label: 'Music', kind: 'toggle', hint: 'Korobeiniki, on oscillators' }),
534
+ Object.freeze({ key: 'sfx', label: 'Sound effects', kind: 'toggle', hint: 'Move, rotate, drop, clear, game over' }),
535
+ Object.freeze({ key: 'neon', label: 'Neon pieces', kind: 'toggle', hint: 'The pieces and the stack as dim bodies under lit tubes' }),
536
+ Object.freeze({
537
+ key: 'neonSource', label: 'Neon colour from', kind: 'choice', hint: 'Each piece’s own colour, or all in one colour',
538
+ options: Object.freeze([['piece', 'The piece’s colour'], ['colour', 'One colour']]),
539
+ }),
540
+ Object.freeze({ key: 'neonColour', label: 'Neon colour', kind: 'colour', hint: 'The one colour, when chosen above' }),
541
+ Object.freeze({ key: 'neonBrightness', label: 'Neon brightness', kind: 'range', min: 0.2, max: 2, step: 0.1, hint: 'How hard the tubes glow' }),
542
+ Object.freeze({ key: 'grid', label: 'Grid', kind: 'toggle', hint: 'The grid under the well' }),
543
+ Object.freeze({ key: 'gridColour', label: 'Grid colour', kind: 'colour', hint: 'The colour of the grid under the well' }),
544
+ Object.freeze({ key: 'gridBrightness', label: 'Grid intensity', kind: 'range', min: 0, max: 2, step: 0.05, hint: 'How strongly the grid shows; 1 is the shipped weight, 0 hides it' }),
545
+ ]),
546
+ }),
547
+ ]);
548
+
549
+ // "group.key" -> the row that defines it. The bounds live in exactly one place now.
550
+ const ROWS = new Map();
551
+ for (const g of PANEL) for (const r of g.rows) ROWS.set(`${g.group}.${r.key}`, r);
552
+
553
+ const clamp = (v, lo, hi, fallback) => {
554
+ const n = Number(v);
555
+ return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : fallback;
556
+ };
557
+ const bool = (v, fallback) => (typeof v === 'boolean' ? v : fallback);
558
+
559
+ /** Clamp a number to the bounds its own slider advertises. */
560
+ function clampRow(row, v, fallback) {
561
+ return clamp(v, row?.min ?? -Infinity, row?.max ?? Infinity, fallback);
562
+ }
563
+ /** Accept a choice only if its own control offers it. */
564
+ const HEX = /^#[0-9a-f]{6}$/i;
565
+ function pickRow(row, v, fallback) {
566
+ if (row?.kind === 'colour') return typeof v === 'string' && HEX.test(v) ? v.toLowerCase() : fallback;
567
+ const allowed = (row?.options ?? []).map(([val]) => val);
568
+ return allowed.includes(v) ? v : fallback;
569
+ }
570
+
571
+ // THE GRID'S COLOUR (operator, 2026-09-12: "we need to break out the green grid settings per game.
572
+ // We should also add a grid color picker, and a transparency slider. I don't want to see the grid
573
+ // in bitlaga for example, and I really want to turn down the intensity on blockanoid", and "Also
574
+ // add a color selector and brightness setting for the grid lighting for blockspace").
575
+ //
576
+ // A picker CANNOT simply overwrite one value. The board draws its grid as a family: an OPAQUE core
577
+ // with a translucent halo and glow around it and a brighter edge line over it, and that
578
+ // relationship is deliberate -- a see-through core reads dimmer wherever the floor beneath it is
579
+ // shadowed, and composite modes are off the table (details3d.js, "I want that neon light cutting
580
+ // through darkness entirely"). So one hex recolours the whole family while each layer keeps its
581
+ // RELATIVE alpha and lift toward white, and brightness multiplies those alphas together.
582
+ //
583
+ // [lift toward white, alpha at brightness 1, brighten first?] -- reverse-engineered from
584
+ // details3d.js's own tuned defaults, and MEASURED against them rather than guessed.
585
+ //
586
+ // The first cut lifted the base hue toward WHITE for the rings and got
587
+ // rgba(122,213,171) where the board draws rgba(40,255,140): the whole grid came out greyer. The
588
+ // palette does not lighten, it SATURATES -- every ring layer has its green channel pinned at 255 --
589
+ // so the ring colours brighten to full first, and only then lift toward white. Checked against the
590
+ // originals that puts gridEdgeColor at (123,255,194) against (120,255,190), and neonLine at
591
+ // (170,255,216) against (170,255,210). The core alone is the base hue untouched, which is exact.
592
+ const GRID_LAYERS = Object.freeze({
593
+ neonCell: [0, 1, false], // the opaque core: the chosen colour itself
594
+ neonHalo: [0, 0.07, true],
595
+ neonGlow: [0, 0.2, true],
596
+ gridGlow: [0, 0.05, true],
597
+ gridColor: [0, 0.16, true],
598
+ gridEdgeColor: [0.3, 1, true],
599
+ neonLine: [0.55, 1, true],
600
+ });
601
+ const rgbOf = (hex, fallback) => {
602
+ const m = /^#([0-9a-f]{6})$/i.exec(String(hex ?? ''));
603
+ if (!m) return fallback;
604
+ const n = parseInt(m[1], 16);
605
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
606
+ };
607
+ const liftRgb = ([r, g, b], t) => [r + (255 - r) * t, g + (255 - g) * t, b + (255 - b) * t].map((v) => Math.round(v));
608
+ const rgbaOf = ([r, g, b], a) => `rgba(${r},${g},${b},${Math.round(Math.max(0, Math.min(1, a)) * 1000) / 1000})`;
609
+
610
+ /** The neon form of a colour: the same hue with its brightest channel pushed to full. */
611
+ const brighten = ([r, g, b]) => {
612
+ const m = Math.max(r, g, b) || 1;
613
+ return [r, g, b].map((v) => Math.min(255, Math.round((v * 255) / m)));
614
+ };
615
+
616
+ /** The block-space board's grid layers, from one colour and a brightness. */
617
+ export function gridColours(hex, brightness = 1) {
618
+ const base = rgbOf(hex, [50, 190, 125]);
619
+ const k = clamp(brightness, 0, 2, 1);
620
+ const lit = brighten(base);
621
+ const out = {};
622
+ for (const [key, [lift, alpha, saturate]] of Object.entries(GRID_LAYERS)) {
623
+ const c = saturate ? lit : base;
624
+ out[key] = rgbaOf(lift ? liftRgb(c, lift) : c, alpha * k);
625
+ }
626
+ return out;
627
+ }
628
+
629
+ /**
630
+ * A game court's grid. The courts deliberately suppress the halo and the glow -- a playfield wants
631
+ * a quiet grid under the pieces, not a lit one -- so only the core is coloured and the two ring
632
+ * layers stay off. `alpha` is the court's own weight: Tetrust draws its grid fainter (0.06) than
633
+ * the brick games do (0.18), which is how they were hand-tuned before this was configurable.
634
+ */
635
+ export function courtGridColours(hex, brightness = 1, alpha = 0.18) {
636
+ const base = rgbOf(hex, [60, 200, 140]);
637
+ const k = clamp(brightness, 0, 2, 1);
638
+ // THE KEYS THE FLOOR ACTUALLY DRAWS WITH. This used to set neonCell, neonHalo and gridGlow only
639
+ // -- and on a `space` board the visible grid is NOT neonCell. details3d strokes the floor twice:
640
+ // `gridGlow` wide and faint, then `gridColor` thin and bright, with `gridEdgeColor` round the
641
+ // board's rim. neonCell feeds boardGridLayers, a different layer. So the picker recoloured
642
+ // something invisible while gridColor kept the engine's default green, and the slider scaled an
643
+ // alpha nobody could see: measured, red-at-1 against blue-at-2 differed in neonCell and in
644
+ // nothing else. That is the whole of "the slider does not update the games grid".
645
+ //
646
+ // The courts stay QUIET, which is the part worth keeping: the lit ring and the wide halo are the
647
+ // board's treatment, not a playfield's. The line is the chosen colour, the glow under it is the
648
+ // same colour at a fraction of the alpha, and the rim is a touch brighter so the court still has
649
+ // an edge. Everything scales with `alpha` (the court's own weight) and `k` (the operator's).
650
+ const lit = liftRgb(base, 0.35);
651
+ // `neonLine` IS THE COURT'S GRID. boardGridLayers splits the lattice with
652
+ // `(i % gridStep ? cell : line)`: cells take `neonCell`, every gridStep-th line takes `neonLine`.
653
+ // The courts set gridStep: 1, so `i % 1` is always 0 -- EVERY line takes neonLine and neonCell is
654
+ // never used on a court at all. Omitting neonLine left the whole lattice at the engine default
655
+ // rgba(170,255,210,1), an opaque pale green, while the rim obediently changed colour: "grid color
656
+ // only seems to affect the border, not the grid itself for the games. I can't change the entire
657
+ // grid color from green."
658
+ // It is stroked at a higher alpha than the cell lines because it is the ONLY line on these
659
+ // boards -- at 0.18 the court would be a whisper -- and it is the colour that was picked, not a
660
+ // lifted one, so what is chosen is what is seen.
661
+ return {
662
+ neonLine: rgbaOf(base, Math.min(1, alpha * 3.6 * k)),
663
+ gridColor: rgbaOf(base, alpha * k),
664
+ gridGlow: rgbaOf(base, alpha * 0.35 * k),
665
+ gridEdgeColor: rgbaOf(lit, Math.min(1, alpha * 3.2 * k)),
666
+ neonCell: rgbaOf(base, alpha * k),
667
+ neonHalo: 'rgba(0,0,0,0)',
668
+ neonGlow: 'rgba(0,0,0,0)',
669
+ };
670
+ }
671
+
672
+ /**
673
+ * v1 -> v2: the sky keys move out of `markets` into their own group. A v1 store keeps the values
674
+ * the operator chose; it does not get them reset for having been written yesterday.
675
+ * A store with no `version` at all is v1: that is every store written before this.
676
+ */
677
+ const MIGRATIONS = {
678
+ 1: (raw) => {
679
+ const mk = raw.markets && typeof raw.markets === 'object' ? raw.markets : {};
680
+ const { starDensity, starBrightness, ...markets } = mk;
681
+ const moved = {};
682
+ if (starDensity !== undefined) moved.density = starDensity;
683
+ if (starBrightness !== undefined) moved.brightness = starBrightness;
684
+ // anything already under `sky` wins: it was written by a newer schema than the one being read
685
+ return { ...raw, markets, sky: { ...moved, ...(raw.sky && typeof raw.sky === 'object' ? raw.sky : {}) } };
686
+ },
687
+ // v2 -> v3: `markets.glow` is dropped. The markets board never draws the grid glow now, so the
688
+ // stored value has nothing left to mean. Written out rather than left to normalise (which would
689
+ // drop the key anyway) so the chain says what changed and when.
690
+ 2: (raw) => {
691
+ const mk = raw.markets && typeof raw.markets === 'object' ? raw.markets : {};
692
+ const { glow, ...markets } = mk;
693
+ return { ...raw, markets };
694
+ },
695
+ // v3 -> v4: the price board gets its own effects group. Until now one list governed both boards,
696
+ // so the store's list is the operator's choice for both: it is copied to `marketEffects` (normalise
697
+ // drops the keys the price board does not offer). A store that already has the group keeps it.
698
+ 3: (raw) => {
699
+ if (raw.marketEffects && typeof raw.marketEffects === 'object') return raw;
700
+ const fx = raw.effects && typeof raw.effects === 'object' ? raw.effects : {};
701
+ return { ...raw, marketEffects: { ...fx } };
702
+ },
703
+ };
704
+
705
+ function migrate(raw) {
706
+ if (!raw || typeof raw !== 'object') return raw;
707
+ let v = Number.isFinite(raw.version) ? raw.version : 1;
708
+ // Written by a build newer than this one: its shape is unknown, so the honest answer is the
709
+ // defaults rather than a guess at what its keys meant.
710
+ if (v > SCHEMA_VERSION) return null;
711
+ let out = raw;
712
+ while (v < SCHEMA_VERSION) {
713
+ const step = MIGRATIONS[v];
714
+ if (!step) return null;
715
+ out = step(out);
716
+ v++;
717
+ }
718
+ return out;
719
+ }
720
+
721
+ /**
722
+ * Merge anything (a parsed store, a patch) onto the defaults, clamping every value.
723
+ * Driven by DEFAULTS and PANEL rather than written out key by key, so adding a setting is one
724
+ * entry in each and a new key cannot arrive unclamped or unlisted.
725
+ */
726
+ export function normalise(raw) {
727
+ const s = raw && typeof raw === 'object' ? raw : {};
728
+ const out = {};
729
+ for (const group of Object.keys(DEFAULTS)) {
730
+ const given = s[group] && typeof s[group] === 'object' ? s[group] : {};
731
+ const settled = {};
732
+ for (const [key, def] of Object.entries(DEFAULTS[group])) {
733
+ const row = ROWS.get(`${group}.${key}`);
734
+ const v = given[key];
735
+ if (typeof def === 'boolean') settled[key] = bool(v, def);
736
+ else if (typeof def === 'number') settled[key] = clampRow(row, v, def);
737
+ else settled[key] = pickRow(row, v, def);
738
+ }
739
+ out[group] = Object.freeze(settled);
740
+ }
741
+ return Object.freeze(out);
742
+ }
743
+
744
+ // Parsing localStorage on every board paint was a JSON.parse per frame (markets.js and mining.js
745
+ // both call loadSettings() as they draw). The parse is memoised on the raw string, so an
746
+ // unchanged store costs a getItem and a comparison.
747
+ let cache = { raw: null, value: null };
748
+
749
+ export function loadSettings(storage = globalThis.localStorage) {
750
+ try {
751
+ const raw = storage?.getItem(SETTINGS_KEY) ?? null;
752
+ if (cache.value && cache.raw === raw) return cache.value;
753
+ const value = normalise(migrate(raw ? JSON.parse(raw) : null));
754
+ cache = { raw, value };
755
+ return value;
756
+ } catch {
757
+ return normalise(null); // unreadable or corrupt: the defaults, never a crash
758
+ }
759
+ }
760
+
761
+ // Consumers that want to know the moment a value changes, rather than waiting for the next paint.
762
+ const listeners = new Set();
763
+ /** Subscribe to settled settings. Returns an unsubscribe. */
764
+ export function onSettingsChange(fn) {
765
+ listeners.add(fn);
766
+ return () => listeners.delete(fn);
767
+ }
768
+ function emit(s) {
769
+ for (const fn of [...listeners]) {
770
+ try { fn(s); } catch { /* a broken listener must not break the save */ }
771
+ }
772
+ }
773
+
774
+ // THE SERVER HOLDS THESE NOW (operator, 2026-09-13: "This is a server app. Should store things on
775
+ // a server"). localStorage stays as a LOCAL CACHE of what the server has, for one reason:
776
+ // loadSettings() is called on every board paint and must answer synchronously -- fetching per paint
777
+ // is not on the table, and waiting on a fetch before the first render would stall the page. So the
778
+ // boot seeds this cache from GET /api/settings (app.js), every change writes through to
779
+ // POST /api/settings, and a browser that cannot reach the server still draws with the last values
780
+ // it saw instead of snapping back to the defaults.
781
+ let push = null; // injected by app.js: (settled) => Promise, debounced below
782
+ let pushTimer = null;
783
+ /** app.js hands us the poster once it has a CSRF-capable api(). */
784
+ export function setSettingsPush(fn) { push = fn; }
785
+
786
+ /**
787
+ * Seed the cache from the server, before the first paint.
788
+ * Returns the settled settings so the caller can tell whether anything was stored.
789
+ */
790
+ export function seedSettings(stored, storage = globalThis.localStorage) {
791
+ const s = normalise(migrate(stored));
792
+ try { storage?.setItem(SETTINGS_KEY, JSON.stringify({ version: SCHEMA_VERSION, ...s })); } catch { /* fine */ }
793
+ cache = { raw: null, value: null };
794
+ emit(s);
795
+ return s;
796
+ }
797
+
798
+ // Coalesced: dragging a slider fires an input event per step, and each one must not be its own
799
+ // write to disk. The value is already applied locally and on screen; the file catches up.
800
+ function pushSoon(s) {
801
+ if (!push) return;
802
+ if (pushTimer) clearTimeout(pushTimer);
803
+ pushTimer = setTimeout(() => {
804
+ pushTimer = null;
805
+ try { Promise.resolve(push(s)).catch(() => {}); } catch { /* offline: the local cache stands */ }
806
+ }, 400);
807
+ }
808
+
809
+ export function saveSettings(next, storage = globalThis.localStorage) {
810
+ const s = normalise(next);
811
+ try {
812
+ storage?.setItem(SETTINGS_KEY, JSON.stringify({ version: SCHEMA_VERSION, ...s }));
813
+ } catch { /* private mode, quota: keep it in memory */ }
814
+ cache = { raw: null, value: null };
815
+ emit(s);
816
+ pushSoon(s);
817
+ return s;
818
+ }
819
+
820
+ /**
821
+ * Set one value by "group.key" and persist the result. Returns the whole settled object.
822
+ *
823
+ * An unknown path THROWS. It used to be ignored, which made a typo indistinguishable from a
824
+ * saved setting -- the control would move and nothing would persist. Every path comes from PANEL
825
+ * (app.js builds the markup from it, and a test asserts every row names a real setting), so this
826
+ * is unreachable unless something is genuinely wrong, and then it should say so.
827
+ */
828
+ export function setSetting(current, path, value, storage = globalThis.localStorage) {
829
+ const [group, key] = String(path).split('.');
830
+ const base = normalise(current);
831
+ if (!base[group] || !(key in base[group])) {
832
+ throw new TypeError(`unknown setting "${path}" (groups: ${Object.keys(DEFAULTS).join(', ')})`);
833
+ }
834
+ return saveSettings({ ...base, [group]: { ...base[group], [key]: value } }, storage);
835
+ }
836
+
837
+ export function resetSettings(storage = globalThis.localStorage) {
838
+ try { storage?.removeItem(SETTINGS_KEY); } catch { /* nothing to remove */ }
839
+ cache = { raw: null, value: null };
840
+ const s = normalise(null);
841
+ emit(s);
842
+ // Reset is a change like any other: the server has to hear it, or the next browser to open the
843
+ // page would be handed the settings this one just discarded.
844
+ pushSoon(s);
845
+ return s;
846
+ }
847
+
848
+ /** True when nothing has been changed from the shipped defaults. */
849
+ export function isDefault(s) {
850
+ return JSON.stringify(normalise(s)) === JSON.stringify(normalise(null));
851
+ }
852
+
853
+ /**
854
+ * The renderer options for a block-space board (mining.js: Overview, Block space, Mempool, Kiosk).
855
+ * Merged OVER the caller's own options, so a mode's resolution and slab still win where they are
856
+ * the point of the mode; these are the user's preferences about how it is drawn.
857
+ */
858
+ export function spaceOptions(s) {
859
+ const n = normalise(s);
860
+ const sp = n.space;
861
+ const d = DETAIL[sp.detail] ?? DETAIL.full;
862
+ const out = {
863
+ shadows: sp.shadows,
864
+ idleFx: sp.idleFx,
865
+ grid: sp.grid,
866
+ neon: sp.neon,
867
+ sheen: sp.sheen,
868
+ sheenStyle: sp.sheenStyle,
869
+ // `space` is the board STYLE (no deck texture, a translucent floor); `stars` is the sky. They
870
+ // travel together here, which is the block-space board's shipped behaviour, but they are two
871
+ // options now so the markets board can keep its style while turning its sky off.
872
+ space: sp.stars,
873
+ stars: sp.stars,
874
+ dome: sp.dome,
875
+ facetPx: d.facetPx,
876
+ crownPx: d.crownPx,
877
+ // the same sky the markets board draws: this board had stars and no way to thin them
878
+ starDensity: n.sky.density,
879
+ starBrightness: n.sky.brightness,
880
+ galaxy: n.sky.galaxy,
881
+ galaxyAt: n.sky.galaxyAt,
882
+ nebulae: n.sky.nebulae, galaxies: n.sky.galaxies, dust: n.sky.dust, clusters: n.sky.clusters,
883
+ starColours: n.sky.colours, starGlints: n.sky.glints,
884
+ };
885
+ // the seam belongs to the Stone edges switch at every level of detail: a control that does
886
+ // nothing in one mode is worse than no control (operator: "stone edges don't work in flat tile
887
+ // display mode"). Detail decides facets and the crown; this decides the outline.
888
+ out.edges = sp.edges;
889
+ if (!sp.edges) out.seamAlpha = 0;
890
+ const motion = MOTION[sp.motion];
891
+ if (motion) out.transition = motion;
892
+ out.departures = sp.departures;
893
+ out.light = sp.light;
894
+ // merged into the camera by details3d (it owns the oblique constants); 0 leaves it exactly as it
895
+ // has always been, so the switch costs nothing until someone moves it
896
+ out.obliqueRise = sp.perspective;
897
+ out.fxKinds = enabledEffects(n);
898
+ out.fxNoRepeat = n.effects.noRepeat;
899
+ Object.assign(out, fxCadence(n.effects));
900
+ out.neonSource = sp.neonSource; out.neonColour = sp.neonColour; out.neonBrightness = sp.neonBrightness;
901
+ // THE GRID'S OWN COLOUR. Until now these were never set here at all, so the board fell through to
902
+ // the hand-tuned greens in details3d.js and there was no way to change them. The shipped values
903
+ // reproduce those greens, so this is a new control rather than a new look.
904
+ Object.assign(out, gridColours(sp.gridColour, sp.gridBrightness));
905
+ return out;
906
+ }
907
+
908
+ /**
909
+ * The game's switches, with the sky's density, brightness and layers from the Sky group: the game
910
+ * decides whether there is a sky and a galaxy and where the galaxy sits; what the sky is made of
911
+ * is one preference for every board.
912
+ */
913
+ // A slider's value as the panel prints it: to its step's decimals, always (operator, 2026-09-14: "these
914
+ // sliders jump around when changing values. Many of our bars do this"). The panel row is a grid whose
915
+ // control column sizes to fit, and the value was printed with String(): `1`, `0.9`, `0.35`, `0.0005`
916
+ // -- a different width almost every notch, so the column resized and the slider moved under the
917
+ // pointer. Fixed decimals make every value of a row the same width, and the CSS box (.cfgrow .val,
918
+ // 6.5ch) holds the widest any row can print, which test/settings.test.js checks for every slider.
919
+ export function formatRangeValue(step, v) {
920
+ const s = String(step);
921
+ const decimals = s.includes('e-') ? Number(s.split('e-')[1]) : (s.split('.')[1] ?? '').length;
922
+ return Number(v).toFixed(decimals);
923
+ }
924
+
925
+ export function enabledEffects(s, group = 'effects') {
926
+ const n = normalise(s);
927
+ return Object.keys(n[group]).filter((k) => n[group][k] === true);
928
+ }
929
+
930
+ /** Blockout's switches, with the sky's make-up from the Sky group (as tetrustOptions does). */
931
+ export function blockoutOptions(s) {
932
+ const n = normalise(s);
933
+ const sky = spaceOptions(s);
934
+ return {
935
+ stars: n.blockout.stars, galaxy: n.blockout.galaxy, galaxyAt: n.blockout.galaxyAt, sfx: n.blockout.sfx,
936
+ neon: n.blockout.neon,
937
+ // the engine calls the data-coloured source "temperature"; here that is the brick's own row
938
+ neonSource: n.blockout.neonSource === 'colour' ? 'colour' : 'temperature',
939
+ neonColour: n.blockout.neonColour, neonBrightness: n.blockout.neonBrightness,
940
+ grid: n.blockout.grid, gridColour: n.blockout.gridColour, gridBrightness: n.blockout.gridBrightness,
941
+ // composed here rather than in the screen, so the court needs no new import and the renderer
942
+ // keys stay in one place. 0.18 is the weight this court was hand-tuned at.
943
+ gridOpts: courtGridColours(n.blockout.gridColour, n.blockout.gridBrightness, 0.18),
944
+ starDensity: sky.starDensity, starBrightness: sky.starBrightness,
945
+ nebulae: sky.nebulae, galaxies: sky.galaxies, dust: sky.dust, clusters: sky.clusters,
946
+ starColours: sky.starColours, starGlints: sky.starGlints,
947
+ };
948
+ }
949
+
950
+ /** Blockanoid's switches. Blockout's shape, plus the two that are Arkanoid's own. */
951
+ export function blockanoidOptions(s) {
952
+ const n = normalise(s);
953
+ const sky = spaceOptions(s);
954
+ return {
955
+ stars: n.blockanoid.stars, galaxy: n.blockanoid.galaxy, galaxyAt: n.blockanoid.galaxyAt, sfx: n.blockanoid.sfx,
956
+ capsules: n.blockanoid.capsules, enemies: n.blockanoid.enemies,
957
+ neon: n.blockanoid.neon,
958
+ neonSource: n.blockanoid.neonSource === 'colour' ? 'colour' : 'temperature',
959
+ neonColour: n.blockanoid.neonColour, neonBrightness: n.blockanoid.neonBrightness,
960
+ grid: n.blockanoid.grid, gridColour: n.blockanoid.gridColour, gridBrightness: n.blockanoid.gridBrightness,
961
+ gridOpts: courtGridColours(n.blockanoid.gridColour, n.blockanoid.gridBrightness, 0.18),
962
+ starDensity: sky.starDensity, starBrightness: sky.starBrightness,
963
+ nebulae: sky.nebulae, galaxies: sky.galaxies, dust: sky.dust, clusters: sky.clusters,
964
+ starColours: sky.starColours, starGlints: sky.starGlints,
965
+ };
966
+ }
967
+
968
+ export function tetrustOptions(s) {
969
+ const n = normalise(s);
970
+ const sky = spaceOptions(s);
971
+ return {
972
+ stars: n.tetrust.stars, galaxy: n.tetrust.galaxy, galaxyAt: n.tetrust.galaxyAt, music: n.tetrust.music, sfx: n.tetrust.sfx,
973
+ ghostColour: n.tetrust.ghostColour, ghostWidth: n.tetrust.ghostWidth,
974
+ neon: n.tetrust.neon, neonSource: n.tetrust.neonSource === 'colour' ? 'colour' : 'temperature', neonColour: n.tetrust.neonColour, neonBrightness: n.tetrust.neonBrightness,
975
+ grid: n.tetrust.grid, gridColour: n.tetrust.gridColour, gridBrightness: n.tetrust.gridBrightness,
976
+ // 0.06, not 0.18: the well draws its grid fainter than the brick courts do, because the stack
977
+ // sits on top of it. That difference was hardcoded in tetrust.js; it lives here now.
978
+ gridOpts: courtGridColours(n.tetrust.gridColour, n.tetrust.gridBrightness, 0.06),
979
+ starDensity: sky.starDensity, starBrightness: sky.starBrightness,
980
+ nebulae: sky.nebulae, galaxies: sky.galaxies, dust: sky.dust, clusters: sky.clusters, starColours: sky.starColours, starGlints: sky.starGlints,
981
+ };
982
+ }
983
+
984
+ /** The renderer options for the markets board (markets.js board3d). */
985
+ export function marketsOptions(s) {
986
+ const n = normalise(s);
987
+ const mk = n.markets;
988
+ return {
989
+ // ALWAYS a space board: `space` carries the deck texture, the floor and the floor line as well
990
+ // as the sky, so driving it from the star switch restyled the whole board when the operator
991
+ // only wanted the stars gone. The sky has its own option now.
992
+ space: true,
993
+ stars: mk.stars,
994
+ starDensity: n.sky.density,
995
+ starBrightness: n.sky.brightness,
996
+ galaxy: n.sky.galaxy,
997
+ galaxyAt: n.sky.galaxyAt,
998
+ nebulae: n.sky.nebulae, galaxies: n.sky.galaxies, dust: n.sky.dust, clusters: n.sky.clusters,
999
+ starColours: n.sky.colours, starGlints: n.sky.glints,
1000
+ // never, at any setting: the halo under the grid lines is not wanted on this board
1001
+ neonHalo: 'rgba(0,0,0,0)',
1002
+ gridGlow: 'rgba(0,0,0,0)',
1003
+ // One switch for everything that MOVES on this board (operator, 2026-09-12: "we need a toggle
1004
+ // for disable effects in the market and price"). The candle board inherited idleFx from the
1005
+ // renderer's defaults and ran the refresh flight, and neither had a control of its own: the
1006
+ // Block space switches next to them govern a different board entirely.
1007
+ ...(mk.effects ? {} : { idleFx: false, transition: MOTION.still }),
1008
+ // ITS OWN SWITCHES (operator, 2026-09-14: "settings specific to market panel"): the Market
1009
+ // effects group, not the block board's
1010
+ fxKinds: enabledEffects(n, 'marketEffects'),
1011
+ fxNoRepeat: n.marketEffects.noRepeat,
1012
+ ...fxCadence(n.marketEffects),
1013
+ };
1014
+ }