blockyard 0.0.9 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +359 -1
  2. package/README.md +48 -27
  3. package/SECURITY.md +2 -2
  4. package/bin/blockyard.js +2 -1
  5. package/docs/API.md +17 -15
  6. package/docs/ARCHITECTURE.md +128 -10
  7. package/docs/CONFIGURATION.md +39 -30
  8. package/docs/DEFECTS.md +4 -1
  9. package/docs/GETTING-STARTED.md +18 -8
  10. package/docs/INSTALL.md +97 -37
  11. package/docs/MEASUREMENTS.md +147 -0
  12. package/docs/PLAN-SCORCHED-YARD.md +456 -0
  13. package/docs/PLAN-SKIES.md +142 -0
  14. package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
  15. package/docs/SECURITY.md +58 -22
  16. package/docs/TROUBLESHOOTING.md +44 -5
  17. package/docs/USER-GUIDE.md +505 -35
  18. package/package.json +4 -2
  19. package/public/404.html +1 -1
  20. package/public/css/app.css +393 -82
  21. package/public/donate-qr.png +0 -0
  22. package/public/index.html +353 -109
  23. package/public/js/agents.js +228 -51
  24. package/public/js/app.js +131 -16
  25. package/public/js/blockanoid.js +15 -7
  26. package/public/js/blockout.js +15 -7
  27. package/public/js/blockscene3d.js +230 -38
  28. package/public/js/charts.js +21 -21
  29. package/public/js/depthchart.js +31 -27
  30. package/public/js/details3d.js +1481 -73
  31. package/public/js/doom.js +31 -0
  32. package/public/js/dosaudio.js +48 -0
  33. package/public/js/dosgame.js +389 -0
  34. package/public/js/dosio.js +186 -0
  35. package/public/js/dospc.js +1353 -0
  36. package/public/js/dosworker.js +196 -0
  37. package/public/js/explorer.js +7 -1
  38. package/public/js/livingsky.js +494 -0
  39. package/public/js/login.js +8 -2
  40. package/public/js/markets.js +46 -8
  41. package/public/js/mining.js +314 -36
  42. package/public/js/panels.js +41 -28
  43. package/public/js/pricechart.js +14 -13
  44. package/public/js/quake.js +20 -0
  45. package/public/js/safenext.js +14 -0
  46. package/public/js/scorched.js +1051 -0
  47. package/public/js/scorchedai.js +227 -0
  48. package/public/js/scorchedair.js +286 -0
  49. package/public/js/scorchedfx.js +376 -0
  50. package/public/js/scorchedshop.js +105 -0
  51. package/public/js/scorchedwind.js +69 -0
  52. package/public/js/scorchedyard.js +1338 -0
  53. package/public/js/settings.js +368 -100
  54. package/public/js/soundcard.js +459 -0
  55. package/public/js/tetrust.js +15 -6
  56. package/public/js/tetsound.js +35 -5
  57. package/public/js/theme.js +235 -0
  58. package/public/js/wolf3d.js +22 -0
  59. package/public/js/x86.js +1978 -0
  60. package/scripts/check.js +46 -0
  61. package/scripts/donate-qr.py +12 -9
  62. package/scripts/dos-bench.js +56 -0
  63. package/scripts/index-build.js +9 -2
  64. package/scripts/pool-map.js +152 -36
  65. package/scripts/setup.js +142 -22
  66. package/scripts/shots.mjs +27 -0
  67. package/scripts/smoke.sh +7 -6
  68. package/scripts/tls.js +31 -0
  69. package/scripts/ui.js +4 -2
  70. package/server/auth/sessions.js +33 -13
  71. package/server/chain/blockfile.js +64 -5
  72. package/server/chain/index/build.js +451 -58
  73. package/server/chain/index/heights.js +29 -3
  74. package/server/chain/index/live.js +13 -7
  75. package/server/chain/index/rows.js +6 -1
  76. package/server/chain/index/store.js +28 -5
  77. package/server/chain/index/worker.js +23 -11
  78. package/server/collect/logparse.js +65 -18
  79. package/server/collect/markets.js +76 -7
  80. package/server/collect/mining.js +32 -0
  81. package/server/collect/monitor.js +54 -12
  82. package/server/collect/network.js +305 -0
  83. package/server/config.js +53 -22
  84. package/server/http/api.js +119 -18
  85. package/server/http/games.js +77 -0
  86. package/server/http/server.js +30 -5
  87. package/server/http/sse.js +53 -7
  88. package/server/main.js +66 -11
  89. package/server/rpc/allowlist.js +26 -0
  90. package/server/rpc/client.js +30 -2
  91. package/server/store/audit.js +6 -1
  92. package/server/store/history.js +19 -3
  93. package/server/store/ledger.js +15 -4
  94. package/server/tls/selfsigned.js +160 -0
  95. package/systemd/blockyard.service +41 -8
  96. package/docs/PRIVATE-LEADERBOARD.md +0 -230
  97. package/docs/STATE-2026-09-09.md +0 -200
@@ -19,9 +19,27 @@
19
19
  // survivable, and `sky` below is the first one to take it.
20
20
 
21
21
  export const SETTINGS_KEY = 'blockyard.settings';
22
- export const SCHEMA_VERSION = 4;
22
+ export const SCHEMA_VERSION = 6;
23
23
 
24
24
  export const DEFAULTS = Object.freeze({
25
+ // APPEARANCE (operator, 2026-09-16: "add an appearance section in preferences to change the colors
26
+ // of our layout. Our current scheme should be the default appearance ... as well as options for
27
+ // configure custom schemes by allowing users to pick colors and save to settings so it sticks").
28
+ // The faces themselves live in theme.js; this holds the choice and the nine custom colours, whose
29
+ // defaults are the shipped dark look so that Custom starts where the page starts.
30
+ appearance: Object.freeze({
31
+ mode: 'dark', // 'light' | 'dark' | 'system' -- which face of the theme
32
+ theme: 'blockyard', // a preset in theme.js PRESETS, or 'custom'
33
+ customBg: '#0b0d10', // the page
34
+ customPanel: '#12151a', // cards, the header, the settings sheet
35
+ customText: '#dfe5ee',
36
+ customMuted: '#96a0b0',
37
+ customAccent: '#f7931a',
38
+ customLine: '#262c36',
39
+ customOk: '#2ecc8f',
40
+ customWarn: '#f0b429',
41
+ customBad: '#ef5a5a',
42
+ }),
25
43
  space: Object.freeze({
26
44
  // OFF by default (operator, 2026-09-12: "make simple cubes the default, disable shadows by
27
45
  // default"). Shadows are the costliest single thing the board draws -- one per resting stone
@@ -59,7 +77,7 @@ export const DEFAULTS = Object.freeze({
59
77
  // ON (operator, 2026-09-13: "the current settings I have saved out should be the shipping
60
78
  // defaults"). It was opt-in because a twinkling field means the board never stops repainting.
61
79
  // That cost is now paid deliberately rather than avoided: it is the look that was chosen.
62
- stars: true,
80
+ sky: 'galaxy', // 'galaxy' | 'earth' | 'none' -- which sky this board draws (docs/PLAN-SKIES.md)
63
81
  dome: 5, // how far the board bows toward the viewer, 0 = flat
64
82
  // HEIGHT THAT FORESHORTENS (operator, 2026-09-13: "I'm not seeing the bottom or the front face
65
83
  // changing at all during movement ... fix camera stuff"). The oblique camera is parallel: height
@@ -81,6 +99,7 @@ export const DEFAULTS = Object.freeze({
81
99
  // ten stops. A stored value above it clamps on load.
82
100
  perspective: 0, // 0 = the parallel camera; 0.001 the most allowed
83
101
  light: 'overhead', // where the lamp is (operator, 2026-09-12: "directly above the board centered")
102
+ lightHeight: 'middle', // and how high it hangs when it is not overhead: 'low' | 'middle' | 'high'
84
103
  detail: 'simple', // 'full' | 'simple' | 'flat' -- facet and crown thresholds below; simple by default
85
104
  motion: 'full', // 'full' | 'quick' | 'still' -- the refresh choreography
86
105
  // HOW CUBES LEAVE AND ARRIVE (operator, 2026-09-13: "all the left and right side blocks are
@@ -100,11 +119,24 @@ export const DEFAULTS = Object.freeze({
100
119
  // unknown choice to this default, so those boards land on 'normal' rather than on nothing.
101
120
  departures: 'normal', // 'normal' | 'arcing'
102
121
  }),
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.
122
+ // TWO SKIES, GALAXY AND EARTH (docs/PLAN-SKIES.md; operator, 2026-09-16: "The skybox settings are
123
+ // not clear in the preferences, for which sky settings apply to which panel"). This group is what
124
+ // each sky is MADE of. WHICH sky a board draws is that board's own `sky` key -- 'galaxy', 'earth'
125
+ // or 'none' -- and the Sky tab opens with a table of every board's choice. There is no global
126
+ // switch any more: the one that was here picked Space or Living for every board at once, beside
127
+ // six scattered "Star field" toggles, and nobody could tell which board drew what.
107
128
  sky: Object.freeze({
129
+ // THE EARTH SKY (operator: "something realistic with blue skies, clouds, the sun, change of
130
+ // day, night with the moon coming out"; livingsky.js)
131
+ clock: 'real', // 'real' (this machine's time) | 'cycle' (a day every 24 minutes) | 'fixed' (the hour below)
132
+ hour: 17.5, // the hour to admire when the clock is fixed
133
+ weather: 'scattered', // 'clear' | 'scattered' | 'overcast' | 'storm'
134
+ cover: -1, // cloud cover 0..1; -1 leaves it to the weather
135
+ lat: -100, // latitude for real sunrise and season; -100 means "not set": a six-to-six day
136
+ rays: true, // crepuscular rays when the sun is low
137
+ rainbow: true, // a rainbow opposite a low sun in scattered weather
138
+ shooting: true, // shooting stars at night
139
+ moon: 'night', // 'night' (up every night, highest at midnight) | 'real' (its real track and phase)
108
140
  density: 3, // multiplies the star count (0.2 .. 3) -- the shipped look sits at the top of the range
109
141
  brightness: 1, // multiplies each star's alpha (0.2 .. 1.5)
110
142
  galaxy: true, // the same stars laid on spiral arms, turning once a quarter hour
@@ -123,7 +155,11 @@ export const DEFAULTS = Object.freeze({
123
155
  // the grid glow. that's just terrible"). Never-show makes the switch a control nobody may use,
124
156
  // and a control that must stay off is worse than no control: the board forces it off now.
125
157
  markets: Object.freeze({
126
- stars: true,
158
+ // THE SWITCH (operator, 2026-09-15: "an app wide 'Enable Market Polling' checkbox"): off out
159
+ // of the box, so a fresh monitor makes no outbound connection but to the node. The server
160
+ // reads it from the shared settings file on every market request (http/api.js).
161
+ polling: false,
162
+ sky: 'galaxy', // 'galaxy' | 'earth' | 'none'
127
163
  effects: true, // the idle effects and the flight when the candles refresh
128
164
  // THE TOOLBAR REMEMBERS (operator, 2026-09-12: "We need to remember the user settings for the
129
165
  // Markets page"). Which exchange and how many hours were a click that survived until the tab
@@ -166,9 +202,12 @@ export const DEFAULTS = Object.freeze({
166
202
  // for, and the board picks among whatever is left on (scheduleFx). Turn them all off and the
167
203
  // board simply rests, which `idleFx` also does in one click.
168
204
  effects: Object.freeze({
169
- ripple: true, outline: true, tide: true, cascade: true, twinkle: true, scan: true,
205
+ ripple: true, outline: true, tide: true, cascade: true, twinkle: true, scan: true, xray: true,
170
206
  lightcycle: true, ball: true,
171
- shockwave: true, nova: true, firework: true, flare: true, wave: true, quake: true,
207
+ // FIREWORKS ARE KEPT FOR OCCASIONS (operator, 2026-09-15: "make it disabled by default. I want
208
+ // to re-use that one for special occasions in the future, and don't want it in the rotation
209
+ // unless user selects it"): off on both boards until someone ticks it
210
+ shockwave: true, nova: true, firework: false, flare: true, wave: true, quake: true,
172
211
  rain: true, sparkle: true, checker: true, radar: true, vortex: true, powerup: true, combo: true, aurora: true, plasma: true,
173
212
  // the agents: something happening on the board, rather than a pattern over it
174
213
  centipede: true, tractor: true, missile: true, boulderdash: true, stormball: true,
@@ -192,10 +231,14 @@ export const DEFAULTS = Object.freeze({
192
231
  // should ship with ... Many of the effects don't translate over to the market chart"). Its own
193
232
  // no-repeat window too. All on, twelve.
194
233
  marketEffects: Object.freeze({
195
- ripple: true, outline: true, tide: true, cascade: true, twinkle: true, scan: true,
234
+ ripple: true, outline: true, tide: true, cascade: true, twinkle: true, scan: true, xray: true,
196
235
  pulse: true, // the surge that runs the price line
197
236
  bulge: true, // a sphere rolls through the pipe and it swells round it
198
- firework: true, flare: true, wave: true, stormball: true,
237
+ breathe: true, // the line breathes between the wire and the pulse's heat
238
+ saber: true, // the line ignites as a light saber
239
+ blackhole: true, // the chart collapses into a black hole
240
+ firework: false, // kept for occasions, off until ticked (see the block board's list)
241
+ flare: true, wave: true, stormball: true,
199
242
  noRepeat: 12,
200
243
  // no firstAfter here: the candle board does not land (operator, 2026-09-14: "there is no
201
244
  // 'landing' for the markets display"); after a refresh its first effect keeps the cadence below
@@ -206,9 +249,7 @@ export const DEFAULTS = Object.freeze({
206
249
  // in another tab, using our engine"). The same shape as the Tetrust group: the game says whether
207
250
  // there is a sky and a galaxy; what the sky is MADE of comes from the Sky group.
208
251
  blockout: Object.freeze({
209
- stars: true,
210
- galaxy: true,
211
- galaxyAt: 'center',
252
+ sky: 'galaxy', // 'galaxy' | 'earth' | 'none'
212
253
  neon: false, // the bricks as dim bodies under lit tubes
213
254
  neonSource: 'brick', // 'brick' (each row's own colour) | 'colour'
214
255
  neonColour: '#3d8bff',
@@ -223,9 +264,7 @@ export const DEFAULTS = Object.freeze({
223
264
  // engine, and make it a new Diversion called 'Blockanoid'"). Blockout's shape plus the two
224
265
  // switches Arkanoid earns: whether capsules fall at all, and whether the minions turn up.
225
266
  blockanoid: Object.freeze({
226
- stars: true,
227
- galaxy: true,
228
- galaxyAt: 'center',
267
+ sky: 'galaxy', // 'galaxy' | 'earth' | 'none'
229
268
  neon: false,
230
269
  neonSource: 'brick', // 'brick' (the brick's own colour) | 'colour'
231
270
  neonColour: '#3d8bff',
@@ -242,9 +281,7 @@ export const DEFAULTS = Object.freeze({
242
281
  // those settings in the game and have them persistent in settings"). The sky is the panel's own
243
282
  // canvas behind the well, so it costs the game nothing per key press.
244
283
  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
284
+ sky: 'galaxy', // 'galaxy' | 'earth' | 'none' -- the panel's own canvas behind the well
248
285
  // the landing marker's colour (operator: "I want to be able to set the ghost wireframe
249
286
  // color"). Its own setting, not the neon tubes': the wireframe is drawn instead of a block,
250
287
  // so the neon finish never touches it.
@@ -260,6 +297,33 @@ export const DEFAULTS = Object.freeze({
260
297
  gridColour: '#1844bf', // blue rather than the engine green
261
298
  gridBrightness: 1.55, // and above full, which the blue needs to read at all
262
299
  }),
300
+ // SCORCHED YARD (operator, 2026-09-16; docs/PLAN-SCORCHED-YARD.md): the sky and the sound as the
301
+ // other games have them, and the rules the original exposed in its own menus
302
+ scorched: Object.freeze({
303
+ sky: 'earth', // an artillery duel wants a day and a horizon (docs/PLAN-SKIES.md §3)
304
+ sfx: true,
305
+ music: true, // the march
306
+ talk: true, // what the tanks say, over the field
307
+ roundSky: true, // under the Living sky, each round draws its own hour
308
+ fast: false, // shells fly at three times the pace
309
+ cheat: false, // cheat mode: the firing solution drawn live, wind and gravity and all
310
+ aimGuide: 'short', // 'off' | 'short' (the first fifth of the flight) | 'full': the ranging arc while you aim
311
+ helper: true, // mark a target, see how your last shot missed it, and C corrects the power
312
+ confirmLast: true, // the last of a one-of-a-kind weapon asks once before it goes
313
+ demo: false, // attract mode: every seat is a computer player, and it plays on by itself
314
+ grid: false, // the quiet grid under the field, off: the land is the picture
315
+ gridColour: '#2a5a8f',
316
+ gridBrightness: 1,
317
+ opponents: 2, // computer players against the one human; up to seven, for the original's eight seats
318
+ opponentKind: 'mix', // 'mix' (Shooter, Tosser, Chooser, Spoiler, Cyborg, Poolshark in turn) or one of the manual's eight
319
+ rounds: 5,
320
+ walls: 'none', // the manual's default: 'none' | 'concrete' | 'padded' | 'rubber' | 'spring' | 'wrap'
321
+ wind: 'round', // 'round' (one wind a round, the shipped mode) | 'turn' | 'shot' | 'none'
322
+ gravity: 1, // 1 = Earth
323
+ land: 'hills', // 'hills' | 'mountains' | 'valley' | 'flat'
324
+ cash: 10000, // to start, dollars
325
+ interest: 5, // per cent, on what is left between rounds
326
+ }),
263
327
  });
264
328
 
265
329
  const DETAIL = {
@@ -287,15 +351,19 @@ const FX_ROW = Object.freeze({
287
351
  tide: Object.freeze({ label: 'Tide', hint: 'A swell that lifts the blocks it passes under' }),
288
352
  cascade: Object.freeze({ label: 'Cascade', hint: 'The blocks light in feerate order, richest first' }),
289
353
  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' }),
354
+ scan: Object.freeze({ label: 'Scan line', hint: 'A curtain of light standing on the board, floor to top, sweeping across: a white core, soft cyan faces, raster rippling down it, a bar it hangs from, a glowing foot with a phosphor tail, motes in the beam' }),
355
+ xray: Object.freeze({ label: 'X-ray', hint: 'A front sweeps the board and everything behind it goes x-ray -- bodies to glass, edges and a raster lit -- then develops back to solid' }),
291
356
  lightcycle: Object.freeze({ label: 'Light cycles', hint: 'Two riders from opposite edges, leaving light walls, until one crashes' }),
292
357
  ball: Object.freeze({ label: 'Lightning ball', hint: 'A plasma ball tracing the grid, throwing bolts and a dust trail' }),
293
358
  pulse: Object.freeze({ label: 'Energy pulse', hint: 'The surge that runs the price line on Markets, blue behind the head' }),
294
359
  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' }),
360
+ breathe: Object.freeze({ label: 'Breathe', hint: 'The price line breathes: three slow swells from the plain wire to the pulse\u2019s white heat and back' }),
361
+ saber: Object.freeze({ label: 'Light saber', hint: 'The price line ignites from its left end as a light saber -- blue, green, red or purple -- hums, spits sparks, and retracts' }),
362
+ blackhole: Object.freeze({ label: 'Black hole', hint: 'A point of darkness opens on the price line and grows, then drifts slowly across the chart from one side to the other: the line bends round it, the candles nearest are swallowed into orbit, and an accretion disk of the chart\u2019s own colours spirals in round the shadow -- brighter on the side coming toward you, its far side arched over the top -- until it lets everything go' }),
295
363
  shockwave: Object.freeze({ label: 'Shockwave', hint: 'A hard ring that throws the blocks it passes into the air' }),
296
364
  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' }),
365
+ firework: Object.freeze({ label: 'Fireworks', hint: 'A display of up to ten shells, each at its own moment and place, with its smoke. Off by default -- kept for special occasions; tick it to put it in the rotation' }),
366
+ flare: Object.freeze({ label: 'Supernova', hint: 'One block goes supernova: a star swells and blows out in a white-out, a debris cloud of gas expands and cools white to blue to violet, a shockwave throws what it crosses, and a pulsar is left beating at the centre' }),
299
367
  wave: Object.freeze({ label: 'Wave', hint: 'Several crests rolling across the board, the blocks riding them' }),
300
368
  quake: Object.freeze({ label: 'Quake', hint: 'The board shakes, hardest at the start, and settles' }),
301
369
  rain: Object.freeze({ label: 'Code rain', hint: 'A drop falls down every column with a white head and a green tail' }),
@@ -315,12 +383,13 @@ const FX_ROW = Object.freeze({
315
383
  });
316
384
  // where an effect reads differently on the price board, the price board's hint
317
385
  const MARKET_HINT = Object.freeze({
386
+ blackhole: 'A point of darkness opens on the price line and grows, then drifts slowly along the hours from one side to the other: the line bends round it, the candles nearest are swallowed into orbit, and an accretion disk of the chart\u2019s own colours spirals in round the shadow -- brighter on the side coming toward you, its far side arched over the top -- until it lets everything go',
318
387
  twinkle: 'Scattered candles flash white, each on its own beat',
319
- pulse: 'The surge that runs the price line, blue behind the head',
388
+ pulse: 'The surge that runs the price line: the tube swells round the head and goes white-hot behind it',
320
389
  bulge: 'A glowing sphere rolls through the price line left to right, and the pipe swells around it as it passes',
321
390
  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
391
  cascade: 'The candles light in order, tallest first',
323
- flare: 'One candle goes supernova and lights its neighbourhood',
392
+ flare: 'One candle goes supernova: it swells white-hot, blows out in a flash and a lens flare, a shockwave rings out across the chart, plasma is flung on every side, and a ring nebula expands and cools for the rest of the run',
324
393
  });
325
394
  // THE WINDOW'S TOP IS THE LIST'S LENGTH (operator, 2026-09-14: "for the markets page, all we have
326
395
  // is 12 effects, so max the slider out at max effects"): a window wider than the list is the same
@@ -349,7 +418,56 @@ export function fxCadence(g) {
349
418
  return { idleEvery, idleFirst };
350
419
  }
351
420
 
352
- export const PANEL = Object.freeze([
421
+ // THE TABS, IN ORDER AND IN ROWS (operator, 2026-09-16: "we really need to group these better.
422
+ // Diversions go at the very end"). The groups are written below in the order they were added; the
423
+ // sheet shows them in this order, each row of the strip labelled: the boards, their effects, the
424
+ // games last.
425
+ export const TAB_ROWS = Object.freeze([
426
+ Object.freeze({ label: 'Boards', groups: Object.freeze(['appearance', 'space', 'sky', 'markets']) }),
427
+ Object.freeze({ label: 'Effects', groups: Object.freeze(['effects', 'marketEffects']) }),
428
+ Object.freeze({ label: 'Diversions', groups: Object.freeze(['tetrust', 'blockout', 'blockanoid', 'scorched']) }),
429
+ ]);
430
+ /** The three answers to "which sky": the Galaxy, the Earth, or none. */
431
+ export const SKIES = Object.freeze(['galaxy', 'earth', 'none']);
432
+ export const SKY_CHOICES = Object.freeze([['galaxy', 'Galaxy'], ['earth', 'Earth'], ['none', 'None']]);
433
+ export const SKY_LABELS = Object.freeze({ galaxy: 'Galaxy', earth: 'Earth', none: 'no sky' });
434
+ /** Every board that has a sky behind it, in the order the Sky tab's table lists them. */
435
+ export const SKY_BOARDS = Object.freeze([
436
+ Object.freeze({ group: 'space', label: 'Block space', note: 'also the Kiosk\u2019s left panel' }),
437
+ Object.freeze({ group: 'markets', label: 'Markets & Price', note: 'also the Kiosk\u2019s right panel' }),
438
+ Object.freeze({ group: 'tetrust', label: 'Tetrust', note: '' }),
439
+ Object.freeze({ group: 'blockout', label: 'Blockout', note: '' }),
440
+ Object.freeze({ group: 'blockanoid', label: 'Blockanoid', note: '' }),
441
+ Object.freeze({ group: 'scorched', label: 'Scorched Yard', note: '' }),
442
+ ]);
443
+ /** The next sky round the ring, for the games' one-button switch. */
444
+ export function nextSky(v) { return SKIES[(SKIES.indexOf(v) + 1) % SKIES.length]; }
445
+
446
+ const PANEL_GROUPS = Object.freeze([
447
+ Object.freeze({
448
+ group: 'appearance',
449
+ title: 'Appearance',
450
+ note: 'The colours of the layout: the page, its cards, text, lines, accent and charts. The 3D boards are space whatever is chosen here, and the Explorer\u2019s block and transaction pages keep their own dark cards.',
451
+ rows: Object.freeze([
452
+ Object.freeze({
453
+ key: 'mode', label: 'Theme mode', kind: 'segment', hint: 'Light or dark, or whichever the operating system asks for (System follows it as it changes)',
454
+ options: Object.freeze([['light', 'Light'], ['dark', 'Dark'], ['system', 'System']]),
455
+ }),
456
+ Object.freeze({
457
+ key: 'theme', label: 'Theme', kind: 'cards', hint: 'Each has a light and a dark face; the mode above picks which. Custom is the nine colours below',
458
+ options: Object.freeze([['blockyard', 'BlockYard'], ['mono', 'Mono'], ['nous', 'Nous'], ['github', 'GitHub'], ['catppuccin', 'Catppuccin'], ['custom', 'Custom']]),
459
+ }),
460
+ Object.freeze({ key: 'customBg', label: 'Page', kind: 'colour', hint: 'The colour behind everything. Whether Custom is a light or a dark theme follows from this one: the derived shades go the other way', custom: true }),
461
+ Object.freeze({ key: 'customPanel', label: 'Panels', kind: 'colour', hint: 'Cards, the header, tables and the settings sheet. Raised panels and hovers are this nudged toward the text colour', custom: true }),
462
+ Object.freeze({ key: 'customText', label: 'Text', kind: 'colour', hint: 'Body text and figures', custom: true }),
463
+ Object.freeze({ key: 'customMuted', label: 'Muted text', kind: 'colour', hint: 'Labels, hints and notes. The fainter text is this halfway to the page colour', custom: true }),
464
+ Object.freeze({ key: 'customAccent', label: 'Accent', kind: 'colour', hint: 'Titles, the active tab, links, buttons and the primary lines of charts', custom: true }),
465
+ Object.freeze({ key: 'customLine', label: 'Lines', kind: 'colour', hint: 'Card borders and table rules. The softer rules are this halfway to the page colour', custom: true }),
466
+ Object.freeze({ key: 'customOk', label: 'Good', kind: 'colour', hint: 'Synced, fresh, a block on time', custom: true }),
467
+ Object.freeze({ key: 'customWarn', label: 'Warning', kind: 'colour', hint: 'Late, stale, a caveat', custom: true }),
468
+ Object.freeze({ key: 'customBad', label: 'Bad', kind: 'colour', hint: 'Overdue, failed, offline', custom: true }),
469
+ ]),
470
+ }),
353
471
  Object.freeze({
354
472
  group: 'space',
355
473
  title: 'Block space',
@@ -373,7 +491,7 @@ export const PANEL = Object.freeze([
373
491
  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
492
  options: Object.freeze([['chrome', 'Chrome'], ['satin', 'Satin']]),
375
493
  }),
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' }),
494
+ Object.freeze({ key: 'sky', label: 'Sky', kind: 'choice', hint: 'Which sky stands behind the board: the Galaxy, the Earth, or none. What each sky is made of is the Sky tab\u2019s; this is only the choice. The Kiosk\u2019s left panel is this board', options: SKY_CHOICES }),
377
495
  Object.freeze({
378
496
  key: 'detail', label: 'Level of detail', kind: 'choice', hint: 'Simpler cubes draw fewer polygons at the same size',
379
497
  options: Object.freeze([['full', 'Full'], ['simple', 'Simple cubes'], ['flat', 'Flat tiles']]),
@@ -391,37 +509,78 @@ export const PANEL = Object.freeze([
391
509
  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
510
  Object.freeze({
393
511
  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']]),
512
+ options: Object.freeze([['overhead', 'Straight above'], ['top-left', 'Top left'], ['top-right', 'Top right'], ['bottom-left', 'Bottom left'], ['bottom-right', 'Bottom right'], ['front', 'From the viewer']]),
513
+ }),
514
+ Object.freeze({
515
+ key: 'lightHeight', label: 'Light height', kind: 'choice', hint: 'How high the lamp hangs over that place: low rakes the sides and shades the far slope hard, high is nearly overhead',
516
+ options: Object.freeze([['low', 'Low'], ['middle', 'Middle'], ['high', 'High']]),
395
517
  }),
396
518
  ]),
397
519
  }),
398
520
  Object.freeze({
399
521
  group: 'sky',
400
522
  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.',
523
+ note: 'Two skies, and which board draws which. The table is the whole map: every board that has a sky behind it, and the sky it is drawing \u2014 change one here or in the board\u2019s own tab, it is the same setting. Below it, what each sky is made of.',
402
524
  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' }),
525
+ Object.freeze({ key: 'map', label: 'Which sky, where', kind: 'skymap', hint: 'Every board with a sky, and the one it draws' }),
526
+ Object.freeze({ key: 'galaxyHead', label: 'The Galaxy', kind: 'heading', hint: 'The star field: stars on slowly turning spiral arms, with the layers of a real galaxy. BlockYard\u2019s own sky' }),
527
+ Object.freeze({ key: 'galaxy', label: 'Spiral arms', kind: 'toggle', hint: 'Lay the stars on slowly turning spiral arms instead of scattering them evenly. One turn takes about fifteen minutes' }),
404
528
  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',
529
+ key: 'galaxyAt', label: 'Arms centre', kind: 'choice',
530
+ hint: 'Where the middle of the spiral sits. A corner crowds the bright centre there and sweeps the arms across; behind the board shows the whole spiral',
407
531
  options: Object.freeze([['center', 'Behind the board'], ['top-left', 'Top left'], ['top-right', 'Top right'], ['bottom-left', 'Bottom left'], ['bottom-right', 'Bottom right']]),
408
532
  }),
533
+ 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' }),
534
+ Object.freeze({ key: 'brightness', label: 'Star brightness', kind: 'range', min: 0.2, max: 1.5, step: 0.1, hint: 'How brightly they burn' }),
535
+ 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' }),
536
+ Object.freeze({ key: 'glints', label: 'Star glints', kind: 'toggle', hint: 'The halo and cross glint on the brightest stars' }),
409
537
  Object.freeze({ key: 'nebulae', label: 'Nebulae', kind: 'toggle', hint: 'Clouds of gas along the spiral arms, in the colours of star-forming lanes' }),
410
538
  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' }),
539
+ Object.freeze({ key: 'clusters', label: 'Star clusters', kind: 'toggle', hint: 'Tight knots of stars out in the halo, turning with the arms' }),
412
540
  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' }),
541
+ Object.freeze({ key: 'earthHead', label: 'The Earth', kind: 'heading', hint: 'A real day from this machine\u2019s clock: the sun, clouds, dusk, the moon at its phase, and the stars at night' }),
542
+ Object.freeze({
543
+ key: 'clock', label: 'Clock', kind: 'choice', hint: 'What time the Earth sky shows',
544
+ options: Object.freeze([['real', 'Real time'], ['cycle', 'A day every 24 minutes'], ['fixed', 'A fixed hour']]),
545
+ }),
546
+ Object.freeze({ key: 'hour', label: 'Fixed hour', kind: 'range', min: 0, max: 24, step: 0.25, hint: 'The hour the Earth sky holds when the clock is fixed; 17.5 is late afternoon' }),
547
+ Object.freeze({
548
+ key: 'weather', label: 'Weather', kind: 'choice', hint: 'How much cloud, and whether it rains',
549
+ options: Object.freeze([['clear', 'Clear'], ['scattered', 'Scattered cloud'], ['overcast', 'Overcast'], ['storm', 'Storm: rain and lightning']]),
550
+ }),
551
+ Object.freeze({ key: 'cover', label: 'Cloud cover', kind: 'range', min: -1, max: 1, step: 0.05, hint: 'Overrides the weather\u2019s cloud amount, 0 to 1; -1 leaves it to the weather' }),
552
+ Object.freeze({ key: 'lat', label: 'Latitude', kind: 'range', min: -100, max: 90, step: 1, hint: 'Your latitude, for real sunrise and sunset and the season; -100 leaves a six-to-six day' }),
553
+ Object.freeze({ key: 'rays', label: 'Sun rays', kind: 'toggle', hint: 'Crepuscular rays when the sun is low' }),
554
+ Object.freeze({ key: 'rainbow', label: 'Rainbow', kind: 'toggle', hint: 'A rainbow opposite a low sun in scattered weather' }),
555
+ Object.freeze({ key: 'shooting', label: 'Shooting stars', kind: 'toggle', hint: 'Now and then, at night' }),
556
+ Object.freeze({
557
+ key: 'moon', label: 'Moon', kind: 'choice', hint: 'Up every night, highest at midnight, never thinner than a fat crescent \u2014 or on its real track at its real phase, which some nights means no moon at all',
558
+ options: Object.freeze([['night', 'Up every night'], ['real', 'Its real track and phase']]),
559
+ }),
417
560
  ]),
418
561
  }),
419
562
  Object.freeze({
420
563
  group: 'markets',
421
564
  title: 'Markets & Price',
422
- note: 'The candle board on Markets and Kiosk.',
565
+ note: 'The exchange feed, and the candle board on Markets and Kiosk.',
423
566
  rows: Object.freeze([
424
- Object.freeze({ key: 'stars', label: 'Star field', kind: 'toggle', hint: 'The twinkling sky behind the candles' }),
567
+ Object.freeze({
568
+ key: 'polling', label: 'Enable market polling', kind: 'toggle',
569
+ hint: 'Let the server ask five exchanges (Coinbase, Kraken, Bitstamp, Bitfinex, OKX) for prices, candles and order books. '
570
+ + 'Off out of the box: this is the only thing the monitor ever says to anyone but your node, and until it is on, '
571
+ + 'Markets and Kiosk say so and the explorer shows no dollar figures. On, the server asks only while someone is looking, '
572
+ + 'and stops ten minutes after the last look. Shared by every screen of this monitor',
573
+ }),
574
+ // right under the polling switch it depends on (operator, 2026-09-15: "Move price line on Overview
575
+ // underneath Enable Market Polling")
576
+ Object.freeze({
577
+ key: 'overviewSummary', label: 'Price line on Overview', kind: 'toggle',
578
+ hint: 'Median, spread, 24 h volume and how many books reported, at the top of Overview. '
579
+ + 'It needs market polling (above); with that on, leaving this on means this monitor contacts '
580
+ + 'five exchanges whenever Overview is open, not only on Markets and Kiosk. '
581
+ + 'Switch it off and the landing page talks to nothing but your node.',
582
+ }),
583
+ Object.freeze({ key: 'sky', label: 'Sky', kind: 'choice', hint: 'Which sky stands behind the candles: the Galaxy, the Earth, or none. The Kiosk\u2019s right panel is this board', options: SKY_CHOICES }),
425
584
  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
585
  Object.freeze({
427
586
  key: 'exchange', label: 'Exchange', kind: 'choice', hint: 'Whose candles the chart and the 3D board draw. The others stay as overlay lines',
@@ -431,13 +590,6 @@ export const PANEL = Object.freeze([
431
590
  key: 'range', label: 'Range', kind: 'choice', hint: 'How many hours the chart covers when the page opens',
432
591
  options: Object.freeze([['24', '24 hours'], ['48', '48 hours'], ['168', '7 days']]),
433
592
  }),
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
593
  Object.freeze({
442
594
  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
595
  options: Object.freeze([['2d', 'Flat chart'], ['3d', '3D candle board']]),
@@ -449,12 +601,7 @@ export const PANEL = Object.freeze([
449
601
  title: 'Blockout',
450
602
  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
603
  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
- }),
604
+ Object.freeze({ key: 'sky', label: 'Sky', kind: 'choice', hint: 'Which sky stands behind the court: the Galaxy, the Earth, or none. Also a button on the game\u2019s panel', options: SKY_CHOICES }),
458
605
  Object.freeze({ key: 'neon', label: 'Neon bricks', kind: 'toggle', hint: 'The wall, the bat and the ball as dim bodies under lit tubes' }),
459
606
  Object.freeze({
460
607
  key: 'neonSource', label: 'Neon colour from', kind: 'choice', hint: 'Each brick row\u2019s own colour, or all in one colour',
@@ -473,12 +620,7 @@ export const PANEL = Object.freeze([
473
620
  title: 'Blockanoid',
474
621
  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
622
  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
- }),
623
+ Object.freeze({ key: 'sky', label: 'Sky', kind: 'choice', hint: 'Which sky stands behind the court: the Galaxy, the Earth, or none. Also a button on the game\u2019s panel', options: SKY_CHOICES }),
482
624
  Object.freeze({ key: 'neon', label: 'Neon bricks', kind: 'toggle', hint: 'The wall, Vaus and the ball as dim bodies under lit tubes' }),
483
625
  Object.freeze({
484
626
  key: 'neonSource', label: 'Neon colour from', kind: 'choice', hint: 'Each brick’s own colour, or all in one colour',
@@ -503,7 +645,7 @@ export const PANEL = Object.freeze([
503
645
  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
646
  bulk: true,
505
647
  rows: fxRows([
506
- 'ripple', 'outline', 'tide', 'cascade', 'twinkle', 'scan', 'lightcycle', 'ball', 'shockwave', 'nova',
648
+ 'ripple', 'outline', 'tide', 'cascade', 'twinkle', 'scan', 'xray', 'lightcycle', 'ball', 'shockwave', 'nova',
507
649
  'firework', 'flare', 'wave', 'quake', 'rain', 'sparkle', 'checker', 'radar', 'vortex', 'powerup',
508
650
  'combo', 'aurora', 'plasma', 'centipede', 'tractor', 'missile', 'boulderdash', 'stormball',
509
651
  ]),
@@ -511,10 +653,10 @@ export const PANEL = Object.freeze([
511
653
  Object.freeze({
512
654
  group: 'marketEffects',
513
655
  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.',
656
+ 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 sixteen 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
657
  bulk: true,
516
658
  rows: fxRows([
517
- 'ripple', 'outline', 'tide', 'cascade', 'twinkle', 'scan', 'pulse', 'bulge', 'firework', 'flare', 'wave', 'stormball',
659
+ 'ripple', 'outline', 'tide', 'cascade', 'twinkle', 'scan', 'xray', 'pulse', 'bulge', 'breathe', 'saber', 'blackhole', 'firework', 'flare', 'wave', 'stormball',
518
660
  ], MARKET_HINT, { landing: false, top: 300 }),
519
661
  }),
520
662
  Object.freeze({
@@ -522,12 +664,7 @@ export const PANEL = Object.freeze([
522
664
  title: 'Tetrust',
523
665
  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
666
  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
- }),
667
+ Object.freeze({ key: 'sky', label: 'Sky', kind: 'choice', hint: 'Which sky stands behind the well: the Galaxy, the Earth, or none. Also a button on the game\u2019s panel', options: SKY_CHOICES }),
531
668
  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
669
  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
670
  Object.freeze({ key: 'music', label: 'Music', kind: 'toggle', hint: 'Korobeiniki, on oscillators' }),
@@ -544,7 +681,58 @@ export const PANEL = Object.freeze([
544
681
  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
682
  ]),
546
683
  }),
684
+ Object.freeze({
685
+ group: 'scorched',
686
+ title: 'Scorched Yard',
687
+ note: 'The artillery game. The switches are also on the game\u2019s own panel; the rules below take effect at the next new game.',
688
+ rows: Object.freeze([
689
+ Object.freeze({ key: 'sky', label: 'Sky', kind: 'choice', hint: 'Which sky stands behind the field: the Galaxy, the Earth, or none. Earth is the shipped choice here: an artillery duel wants a day and a horizon, and each round draws its own hour of it. Also a button on the game\u2019s panel', options: SKY_CHOICES }),
690
+ Object.freeze({ key: 'sfx', label: 'Sound effects', kind: 'toggle', hint: 'The shot, the blast, a hit, a fall, a death' }),
691
+ Object.freeze({ key: 'music', label: 'Music', kind: 'toggle', hint: 'A march in D minor, on oscillators' }),
692
+ Object.freeze({ key: 'talk', label: 'Talk', kind: 'toggle', hint: 'What the tanks say when they fire, are hit, or die' }),
693
+ Object.freeze({ key: 'roundSky', label: 'A sky per round', kind: 'toggle', hint: 'Under the Living sky, each round draws its own hour: dawn, noon, dusk, night' }),
694
+ Object.freeze({ key: 'fast', label: 'Fast shells', kind: 'toggle', hint: 'Shells fly at three times the pace, for the impatient' }),
695
+ Object.freeze({
696
+ key: 'aimGuide', label: 'Aim guide', kind: 'choice', hint: 'A ghost of the shell\u2019s path while you aim, under this round\u2019s wind and gravity. Short shows the first fifth of the flight: enough to read the lean of the shot without giving the landing away. Off is the original',
697
+ options: Object.freeze([['short', 'Short'], ['full', 'The whole flight'], ['off', 'Off']]),
698
+ }),
699
+ Object.freeze({ key: 'helper', label: 'Correction helper', kind: 'toggle', hint: 'Click an enemy tank to mark it: the panel then says how far your last shot fell short of it or went over, and C corrects your power from that miss. Nothing is solved for you \u2014 the wind and the hills are still yours to read' }),
700
+ Object.freeze({ key: 'confirmLast', label: 'Confirm the last of a weapon', kind: 'toggle', hint: 'The last Nuke, the last Death\u2019s Head \u2014 any weapon sold one at a time \u2014 asks once before it goes: press fire again to send it' }),
701
+ Object.freeze({ key: 'cheat', label: 'Cheat mode', kind: 'toggle', hint: 'Draw the firing solution while you aim: the shell\u2019s own path under this round\u2019s wind and the game\u2019s gravity, clipped where it meets the dirt, with a ring where it lands. It moves as you move' }),
702
+ Object.freeze({ key: 'demo', label: 'Attract mode', kind: 'toggle', hint: 'Every seat is a computer player and the war runs on by itself, for a wall display' }),
703
+ Object.freeze({ key: 'grid', label: 'Grid', kind: 'toggle', hint: 'A quiet grid under the field' }),
704
+ Object.freeze({ key: 'gridColour', label: 'Grid colour', kind: 'colour', hint: 'The colour of that grid' }),
705
+ Object.freeze({ key: 'gridBrightness', label: 'Grid intensity', kind: 'range', min: 0, max: 2, step: 0.05, hint: 'How strongly the grid shows; 0 hides it' }),
706
+ Object.freeze({ key: 'opponents', label: 'Computer players', kind: 'range', min: 1, max: 7, step: 1, hint: 'How many tanks the computer fields against you. Two is the shipped game; seven fills the original\u2019s eight seats' }),
707
+ Object.freeze({
708
+ key: 'opponentKind', label: 'Their kind', kind: 'choice', hint: 'The manual\u2019s personalities: a mix climbs from the easy ones, or every seat the one you name. Moron fires at random; Shooter takes straight shots; Poolshark banks off rubber walls; Tosser lobs and corrects; Chooser picks its method; Spoiler nearly never misses; Cyborg is a Spoiler with a grudge; Unknown is one of them, drawn each round',
709
+ options: Object.freeze([['mix', 'A mix'], ['moron', 'Morons'], ['shooter', 'Shooters'], ['poolshark', 'Poolsharks'], ['tosser', 'Tossers'], ['chooser', 'Choosers'], ['spoiler', 'Spoilers'], ['cyborg', 'Cyborgs'], ['unknown', 'Unknowns']]),
710
+ }),
711
+ Object.freeze({ key: 'rounds', label: 'Rounds', kind: 'range', min: 1, max: 10, step: 1, hint: 'A game is this many rounds; the highest score at the end wins' }),
712
+ Object.freeze({
713
+ key: 'walls', label: 'Walls', kind: 'choice', hint: 'What a shell does at the edge of the field',
714
+ options: Object.freeze([['none', 'None: it is lost (the original\u2019s default)'], ['concrete', 'Concrete: it explodes there'], ['padded', 'Padded: it stops and drops'], ['rubber', 'Rubber: it bounces'], ['spring', 'Spring: it bounces back harder'], ['wrap', 'Wraparound: it comes in the other side']]),
715
+ }),
716
+ Object.freeze({
717
+ key: 'wind', label: 'Wind', kind: 'choice', hint: 'Whether the wind blows, and how often it changes. Once a round holds its direction and strength for the whole round, so the air blows one way while you read it; every turn is the original\u2019s, and it can turn right around between two shots',
718
+ options: Object.freeze([['round', 'Once a round'], ['turn', 'Changes every turn'], ['shot', 'Changes every shot'], ['none', 'No wind']]),
719
+ }),
720
+ Object.freeze({ key: 'gravity', label: 'Gravity', kind: 'range', min: 0.4, max: 2, step: 0.1, hint: 'How hard shells fall; 1 is Earth' }),
721
+ Object.freeze({
722
+ key: 'land', label: 'Landscape', kind: 'choice', hint: 'The shape of the land each round is drawn from',
723
+ options: Object.freeze([['hills', 'Rolling hills'], ['mountains', 'Mountains'], ['valley', 'A valley'], ['flat', 'Flat']]),
724
+ }),
725
+ Object.freeze({ key: 'cash', label: 'Starting cash', kind: 'range', min: 0, max: 100000, step: 5000, hint: 'What every tank has to spend at the first shop; damage and kills earn more' }),
726
+ Object.freeze({ key: 'interest', label: 'Interest', kind: 'range', min: 0, max: 25, step: 1, hint: 'Per cent paid on unspent cash between rounds' }),
727
+ ]),
728
+ }),
547
729
  ]);
730
+ /** The groups in the sheet's order: row by row of TAB_ROWS, then anything a row forgot (a test says nothing is). */
731
+ export const PANEL = Object.freeze([
732
+ ...TAB_ROWS.flatMap((row) => row.groups.map((id) => PANEL_GROUPS.find((g) => g.group === id)).filter(Boolean)),
733
+ ...PANEL_GROUPS.filter((g) => !TAB_ROWS.some((row) => row.groups.includes(g.group))),
734
+ ]);
735
+
548
736
 
549
737
  // "group.key" -> the row that defines it. The bounds live in exactly one place now.
550
738
  const ROWS = new Map();
@@ -700,6 +888,35 @@ const MIGRATIONS = {
700
888
  const fx = raw.effects && typeof raw.effects === 'object' ? raw.effects : {};
701
889
  return { ...raw, marketEffects: { ...fx } };
702
890
  },
891
+ // v4 -> v5: the lamp's placements were renamed as six places at three heights (2026-09-16):
892
+ // upper-left is top-left, upper-right top-right; front stays; the height is new (middle)
893
+ 4: (raw) => {
894
+ const sp = raw.space && typeof raw.space === 'object' ? raw.space : {};
895
+ const map = { 'upper-left': 'top-left', 'upper-right': 'top-right' };
896
+ return { ...raw, space: { ...sp, light: map[sp.light] ?? sp.light } };
897
+ },
898
+
899
+ // v5 -> v6: ONE SKY PER BOARD (docs/PLAN-SKIES.md). One global `sky.type` (space | living) plus a
900
+ // `stars` toggle on each of six boards becomes a `sky` choice on each board (galaxy | earth |
901
+ // none), and the games' private `galaxy` / `galaxyAt` are dropped in favour of the Sky tab's.
902
+ // The mapping keeps every installation drawing exactly what it drew.
903
+ //
904
+ // IDEMPOTENT ON PURPOSE: settings stored on the server carry no schema version, so this runs on
905
+ // every boot. It acts only on a board that still has the old key and lacks the new one, and a
906
+ // store already in the new shape passes through untouched.
907
+ 5: (raw) => {
908
+ const type = raw.sky && typeof raw.sky === 'object' ? raw.sky.type : undefined;
909
+ const out = { ...raw };
910
+ if (out.sky && typeof out.sky === 'object' && 'type' in out.sky) { const { type: _t, ...rest } = out.sky; out.sky = rest; }
911
+ for (const board of ['space', 'markets', 'tetrust', 'blockout', 'blockanoid', 'scorched']) {
912
+ const g = raw[board];
913
+ if (!g || typeof g !== 'object') continue;
914
+ const { stars, galaxy: _g, galaxyAt: _a, ...rest } = g;
915
+ if (g.sky === undefined && stars !== undefined) rest.sky = stars === false ? 'none' : type === 'living' ? 'earth' : 'galaxy';
916
+ out[board] = rest;
917
+ }
918
+ return out;
919
+ },
703
920
  };
704
921
 
705
922
  function migrate(raw) {
@@ -855,13 +1072,71 @@ export function isDefault(s) {
855
1072
  * Merged OVER the caller's own options, so a mode's resolution and slab still win where they are
856
1073
  * the point of the mode; these are the user's preferences about how it is drawn.
857
1074
  */
1075
+ /** The living sky's settings as board options, for every board that draws a sky. */
1076
+ /** Which sky a board draws, from its own key: 'galaxy' | 'earth' | 'none'. */
1077
+ export function skyOf(n, board) {
1078
+ const v = n[board]?.sky;
1079
+ return SKIES.includes(v) ? v : 'galaxy';
1080
+ }
1081
+
1082
+ /**
1083
+ * THE DEEP SKY belongs to the Galaxy: spiral arms, nebulae, dust lanes, halo clusters and distant
1084
+ * galaxies are wrong over a real day and a real night (operator, 2026-09-16: "It's counter
1085
+ * intuitive in the preferences to display a spiral galaxy for a night/day scene"). Gated by the
1086
+ * board's choice, so a board back on the Galaxy gets them all exactly as saved.
1087
+ */
1088
+ export function deepSky(n, sky = 'galaxy') {
1089
+ const off = sky !== 'galaxy';
1090
+ return {
1091
+ galaxy: n.sky.galaxy && !off,
1092
+ galaxyAt: n.sky.galaxyAt,
1093
+ nebulae: n.sky.nebulae && !off,
1094
+ galaxies: n.sky.galaxies && !off,
1095
+ dust: n.sky.dust && !off,
1096
+ clusters: n.sky.clusters && !off,
1097
+ };
1098
+ }
1099
+
1100
+ /** The Earth sky's settings as renderer options. */
1101
+ export function skyExtras(n) {
1102
+ const sky = n.sky;
1103
+ return {
1104
+ skyClock: sky.clock, skyHour: sky.hour, skyWeather: sky.weather,
1105
+ skyCover: sky.cover < 0 ? undefined : sky.cover, skyLat: sky.lat <= -95 ? undefined : sky.lat,
1106
+ skyRays: sky.rays, skyRainbow: sky.rainbow, skyShooting: sky.shooting, skyMoon: sky.moon,
1107
+ };
1108
+ }
1109
+
1110
+ /**
1111
+ * ONE ANSWER PER BOARD (docs/PLAN-SKIES.md): everything the renderer needs to draw the sky this
1112
+ * board chose. `sky` is the choice, `stars` whether any sky is drawn at all, `skyType` which one
1113
+ * ('galaxy' or 'earth'); then the Earth's settings and the Galaxy's, the deep layers gated off
1114
+ * under the Earth. Every board's option builder spreads this and nothing else about the sky.
1115
+ */
1116
+ export function skyFor(n, board) {
1117
+ const sky = skyOf(n, board);
1118
+ return {
1119
+ sky,
1120
+ stars: sky !== 'none',
1121
+ skyType: sky === 'earth' ? 'earth' : 'galaxy',
1122
+ ...skyExtras(n),
1123
+ starDensity: n.sky.density,
1124
+ starBrightness: n.sky.brightness,
1125
+ starColours: n.sky.colours, starGlints: n.sky.glints,
1126
+ ...deepSky(n, sky),
1127
+ };
1128
+ }
1129
+
858
1130
  export function spaceOptions(s) {
859
1131
  const n = normalise(s);
860
1132
  const sp = n.space;
861
1133
  const d = DETAIL[sp.detail] ?? DETAIL.full;
862
1134
  const out = {
863
1135
  shadows: sp.shadows,
864
- idleFx: sp.idleFx,
1136
+ // THE SPACE EFFECTS GO OFF UNDER THE LIVING SKY (operator, 2026-09-16: "We have to toggle all
1137
+ // the Space effects off when Living Sky is selected"): supernovae and black holes over a blue
1138
+ // afternoon are wrong. Gated here, not by flipping the switches, so space gets them back as saved.
1139
+ idleFx: sp.idleFx && skyOf(n, 'space') !== 'earth',
865
1140
  grid: sp.grid,
866
1141
  neon: sp.neon,
867
1142
  sheen: sp.sheen,
@@ -869,18 +1144,12 @@ export function spaceOptions(s) {
869
1144
  // `space` is the board STYLE (no deck texture, a translucent floor); `stars` is the sky. They
870
1145
  // travel together here, which is the block-space board's shipped behaviour, but they are two
871
1146
  // options now so the markets board can keep its style while turning its sky off.
872
- space: sp.stars,
873
- stars: sp.stars,
1147
+ space: skyOf(n, 'space') !== 'none',
874
1148
  dome: sp.dome,
875
1149
  facetPx: d.facetPx,
876
1150
  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,
1151
+ // the sky this board chose, and what it is made of (skyFor): the Kiosk's left panel is this board
1152
+ ...skyFor(n, 'space'),
884
1153
  };
885
1154
  // the seam belongs to the Stone edges switch at every level of detail: a control that does
886
1155
  // nothing in one mode is worse than no control (operator: "stone edges don't work in flat tile
@@ -891,10 +1160,11 @@ export function spaceOptions(s) {
891
1160
  if (motion) out.transition = motion;
892
1161
  out.departures = sp.departures;
893
1162
  out.light = sp.light;
1163
+ out.lightHeight = sp.lightHeight;
894
1164
  // merged into the camera by details3d (it owns the oblique constants); 0 leaves it exactly as it
895
1165
  // has always been, so the switch costs nothing until someone moves it
896
1166
  out.obliqueRise = sp.perspective;
897
- out.fxKinds = enabledEffects(n);
1167
+ out.fxKinds = skyOf(n, 'space') === 'earth' ? [] : enabledEffects(n);
898
1168
  out.fxNoRepeat = n.effects.noRepeat;
899
1169
  Object.assign(out, fxCadence(n.effects));
900
1170
  out.neonSource = sp.neonSource; out.neonColour = sp.neonColour; out.neonBrightness = sp.neonBrightness;
@@ -930,9 +1200,8 @@ export function enabledEffects(s, group = 'effects') {
930
1200
  /** Blockout's switches, with the sky's make-up from the Sky group (as tetrustOptions does). */
931
1201
  export function blockoutOptions(s) {
932
1202
  const n = normalise(s);
933
- const sky = spaceOptions(s);
934
1203
  return {
935
- stars: n.blockout.stars, galaxy: n.blockout.galaxy, galaxyAt: n.blockout.galaxyAt, sfx: n.blockout.sfx,
1204
+ sky: skyFor(n, 'blockout'), sfx: n.blockout.sfx,
936
1205
  neon: n.blockout.neon,
937
1206
  // the engine calls the data-coloured source "temperature"; here that is the brick's own row
938
1207
  neonSource: n.blockout.neonSource === 'colour' ? 'colour' : 'temperature',
@@ -941,43 +1210,45 @@ export function blockoutOptions(s) {
941
1210
  // composed here rather than in the screen, so the court needs no new import and the renderer
942
1211
  // keys stay in one place. 0.18 is the weight this court was hand-tuned at.
943
1212
  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
1213
  };
948
1214
  }
949
1215
 
950
1216
  /** Blockanoid's switches. Blockout's shape, plus the two that are Arkanoid's own. */
951
1217
  export function blockanoidOptions(s) {
952
1218
  const n = normalise(s);
953
- const sky = spaceOptions(s);
954
1219
  return {
955
- stars: n.blockanoid.stars, galaxy: n.blockanoid.galaxy, galaxyAt: n.blockanoid.galaxyAt, sfx: n.blockanoid.sfx,
1220
+ sky: skyFor(n, 'blockanoid'), sfx: n.blockanoid.sfx,
956
1221
  capsules: n.blockanoid.capsules, enemies: n.blockanoid.enemies,
957
1222
  neon: n.blockanoid.neon,
958
1223
  neonSource: n.blockanoid.neonSource === 'colour' ? 'colour' : 'temperature',
959
1224
  neonColour: n.blockanoid.neonColour, neonBrightness: n.blockanoid.neonBrightness,
960
1225
  grid: n.blockanoid.grid, gridColour: n.blockanoid.gridColour, gridBrightness: n.blockanoid.gridBrightness,
961
1226
  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
1227
  };
966
1228
  }
967
1229
 
968
1230
  export function tetrustOptions(s) {
969
1231
  const n = normalise(s);
970
- const sky = spaceOptions(s);
971
1232
  return {
972
- stars: n.tetrust.stars, galaxy: n.tetrust.galaxy, galaxyAt: n.tetrust.galaxyAt, music: n.tetrust.music, sfx: n.tetrust.sfx,
1233
+ sky: skyFor(n, 'tetrust'), music: n.tetrust.music, sfx: n.tetrust.sfx,
973
1234
  ghostColour: n.tetrust.ghostColour, ghostWidth: n.tetrust.ghostWidth,
974
1235
  neon: n.tetrust.neon, neonSource: n.tetrust.neonSource === 'colour' ? 'colour' : 'temperature', neonColour: n.tetrust.neonColour, neonBrightness: n.tetrust.neonBrightness,
975
1236
  grid: n.tetrust.grid, gridColour: n.tetrust.gridColour, gridBrightness: n.tetrust.gridBrightness,
976
1237
  // 0.06, not 0.18: the well draws its grid fainter than the brick courts do, because the stack
977
1238
  // sits on top of it. That difference was hardcoded in tetrust.js; it lives here now.
978
1239
  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,
1240
+ };
1241
+ }
1242
+
1243
+ /** Scorched Yard's switches and rules, with the sky's make-up from the Sky group. */
1244
+ export function scorchedOptions(s) {
1245
+ const n = normalise(s);
1246
+ const sc = n.scorched;
1247
+ return {
1248
+ sky: skyFor(n, 'scorched'), sfx: sc.sfx, music: sc.music, talk: sc.talk, roundSky: sc.roundSky, fast: sc.fast, cheat: sc.cheat, aimGuide: sc.aimGuide, helper: sc.helper, confirmLast: sc.confirmLast, demo: sc.demo,
1249
+ grid: sc.grid, gridColour: sc.gridColour, gridBrightness: sc.gridBrightness,
1250
+ gridOpts: courtGridColours(sc.gridColour, sc.gridBrightness, 0.08),
1251
+ opponents: sc.opponents, opponentKind: sc.opponentKind, rounds: sc.rounds, walls: sc.walls, wind: sc.wind, gravity: sc.gravity, land: sc.land, cash: sc.cash, interest: sc.interest,
981
1252
  };
982
1253
  }
983
1254
 
@@ -990,13 +1261,8 @@ export function marketsOptions(s) {
990
1261
  // as the sky, so driving it from the star switch restyled the whole board when the operator
991
1262
  // only wanted the stars gone. The sky has its own option now.
992
1263
  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,
1264
+ // the sky this board chose (skyFor): the Kiosk's right panel is this board
1265
+ ...skyFor(n, 'markets'),
1000
1266
  // never, at any setting: the halo under the grid lines is not wanted on this board
1001
1267
  neonHalo: 'rgba(0,0,0,0)',
1002
1268
  gridGlow: 'rgba(0,0,0,0)',
@@ -1007,7 +1273,9 @@ export function marketsOptions(s) {
1007
1273
  ...(mk.effects ? {} : { idleFx: false, transition: MOTION.still }),
1008
1274
  // ITS OWN SWITCHES (operator, 2026-09-14: "settings specific to market panel"): the Market
1009
1275
  // effects group, not the block board's
1010
- fxKinds: enabledEffects(n, 'marketEffects'),
1276
+ // the market effects go off under the Earth the way the Space effects do: a supernova over a
1277
+ // blue afternoon is wrong on this board too
1278
+ fxKinds: skyOf(n, 'markets') === 'earth' ? [] : enabledEffects(n, 'marketEffects'),
1011
1279
  fxNoRepeat: n.marketEffects.noRepeat,
1012
1280
  ...fxCadence(n.marketEffects),
1013
1281
  };