blockyard 0.1.0 → 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 (71) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +13 -11
  3. package/SECURITY.md +2 -2
  4. package/docs/API.md +1 -1
  5. package/docs/ARCHITECTURE.md +36 -5
  6. package/docs/CONFIGURATION.md +6 -4
  7. package/docs/DEFECTS.md +4 -1
  8. package/docs/GETTING-STARTED.md +14 -7
  9. package/docs/INSTALL.md +7 -4
  10. package/docs/PLAN-SCORCHED-YARD.md +456 -0
  11. package/docs/PLAN-SKIES.md +142 -0
  12. package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
  13. package/docs/SECURITY.md +26 -7
  14. package/docs/TROUBLESHOOTING.md +10 -5
  15. package/docs/USER-GUIDE.md +239 -9
  16. package/package.json +4 -2
  17. package/public/css/app.css +87 -0
  18. package/public/index.html +58 -6
  19. package/public/js/app.js +60 -19
  20. package/public/js/blockanoid.js +15 -7
  21. package/public/js/blockout.js +15 -7
  22. package/public/js/blockscene3d.js +51 -11
  23. package/public/js/depthchart.js +1 -1
  24. package/public/js/details3d.js +25 -2
  25. package/public/js/explorer.js +7 -1
  26. package/public/js/livingsky.js +494 -0
  27. package/public/js/login.js +3 -2
  28. package/public/js/mining.js +4 -4
  29. package/public/js/panels.js +27 -18
  30. package/public/js/safenext.js +14 -0
  31. package/public/js/scorched.js +1051 -0
  32. package/public/js/scorchedai.js +227 -0
  33. package/public/js/scorchedair.js +286 -0
  34. package/public/js/scorchedfx.js +376 -0
  35. package/public/js/scorchedshop.js +105 -0
  36. package/public/js/scorchedwind.js +69 -0
  37. package/public/js/scorchedyard.js +1338 -0
  38. package/public/js/settings.js +266 -80
  39. package/public/js/tetrust.js +15 -6
  40. package/public/js/tetsound.js +35 -5
  41. package/scripts/check.js +46 -0
  42. package/scripts/index-build.js +9 -2
  43. package/scripts/pool-map.js +152 -36
  44. package/scripts/setup.js +108 -10
  45. package/scripts/shots.mjs +21 -0
  46. package/scripts/smoke.sh +6 -5
  47. package/scripts/ui.js +4 -2
  48. package/server/auth/sessions.js +33 -13
  49. package/server/chain/blockfile.js +64 -5
  50. package/server/chain/index/build.js +432 -56
  51. package/server/chain/index/heights.js +29 -3
  52. package/server/chain/index/live.js +13 -7
  53. package/server/chain/index/rows.js +6 -1
  54. package/server/chain/index/store.js +28 -5
  55. package/server/chain/index/worker.js +23 -11
  56. package/server/collect/logparse.js +65 -18
  57. package/server/collect/markets.js +76 -7
  58. package/server/collect/mining.js +32 -0
  59. package/server/collect/monitor.js +24 -11
  60. package/server/collect/network.js +19 -9
  61. package/server/config.js +7 -0
  62. package/server/http/api.js +70 -13
  63. package/server/http/server.js +22 -5
  64. package/server/http/sse.js +53 -7
  65. package/server/main.js +13 -3
  66. package/server/rpc/allowlist.js +26 -0
  67. package/server/rpc/client.js +30 -2
  68. package/server/store/audit.js +6 -1
  69. package/server/store/history.js +19 -3
  70. package/server/store/ledger.js +15 -4
  71. package/systemd/blockyard.service +34 -3
@@ -19,7 +19,7 @@
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
25
  // APPEARANCE (operator, 2026-09-16: "add an appearance section in preferences to change the colors
@@ -77,7 +77,7 @@ export const DEFAULTS = Object.freeze({
77
77
  // ON (operator, 2026-09-13: "the current settings I have saved out should be the shipping
78
78
  // defaults"). It was opt-in because a twinkling field means the board never stops repainting.
79
79
  // That cost is now paid deliberately rather than avoided: it is the look that was chosen.
80
- stars: true,
80
+ sky: 'galaxy', // 'galaxy' | 'earth' | 'none' -- which sky this board draws (docs/PLAN-SKIES.md)
81
81
  dome: 5, // how far the board bows toward the viewer, 0 = flat
82
82
  // HEIGHT THAT FORESHORTENS (operator, 2026-09-13: "I'm not seeing the bottom or the front face
83
83
  // changing at all during movement ... fix camera stuff"). The oblique camera is parallel: height
@@ -99,6 +99,7 @@ export const DEFAULTS = Object.freeze({
99
99
  // ten stops. A stored value above it clamps on load.
100
100
  perspective: 0, // 0 = the parallel camera; 0.001 the most allowed
101
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'
102
103
  detail: 'simple', // 'full' | 'simple' | 'flat' -- facet and crown thresholds below; simple by default
103
104
  motion: 'full', // 'full' | 'quick' | 'still' -- the refresh choreography
104
105
  // HOW CUBES LEAVE AND ARRIVE (operator, 2026-09-13: "all the left and right side blocks are
@@ -118,11 +119,24 @@ export const DEFAULTS = Object.freeze({
118
119
  // unknown choice to this default, so those boards land on 'normal' rather than on nothing.
119
120
  departures: 'normal', // 'normal' | 'arcing'
120
121
  }),
121
- // THE SKY IS ONE SKY. density and brightness lived under `markets` and were passed only to the
122
- // markets board, so the Block space star field -- the same stars, drawn by the same code -- had
123
- // no density or brightness control at all. Whether each board shows stars stays per board
124
- // (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.
125
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)
126
140
  density: 3, // multiplies the star count (0.2 .. 3) -- the shipped look sits at the top of the range
127
141
  brightness: 1, // multiplies each star's alpha (0.2 .. 1.5)
128
142
  galaxy: true, // the same stars laid on spiral arms, turning once a quarter hour
@@ -145,7 +159,7 @@ export const DEFAULTS = Object.freeze({
145
159
  // of the box, so a fresh monitor makes no outbound connection but to the node. The server
146
160
  // reads it from the shared settings file on every market request (http/api.js).
147
161
  polling: false,
148
- stars: true,
162
+ sky: 'galaxy', // 'galaxy' | 'earth' | 'none'
149
163
  effects: true, // the idle effects and the flight when the candles refresh
150
164
  // THE TOOLBAR REMEMBERS (operator, 2026-09-12: "We need to remember the user settings for the
151
165
  // Markets page"). Which exchange and how many hours were a click that survived until the tab
@@ -235,9 +249,7 @@ export const DEFAULTS = Object.freeze({
235
249
  // in another tab, using our engine"). The same shape as the Tetrust group: the game says whether
236
250
  // there is a sky and a galaxy; what the sky is MADE of comes from the Sky group.
237
251
  blockout: Object.freeze({
238
- stars: true,
239
- galaxy: true,
240
- galaxyAt: 'center',
252
+ sky: 'galaxy', // 'galaxy' | 'earth' | 'none'
241
253
  neon: false, // the bricks as dim bodies under lit tubes
242
254
  neonSource: 'brick', // 'brick' (each row's own colour) | 'colour'
243
255
  neonColour: '#3d8bff',
@@ -252,9 +264,7 @@ export const DEFAULTS = Object.freeze({
252
264
  // engine, and make it a new Diversion called 'Blockanoid'"). Blockout's shape plus the two
253
265
  // switches Arkanoid earns: whether capsules fall at all, and whether the minions turn up.
254
266
  blockanoid: Object.freeze({
255
- stars: true,
256
- galaxy: true,
257
- galaxyAt: 'center',
267
+ sky: 'galaxy', // 'galaxy' | 'earth' | 'none'
258
268
  neon: false,
259
269
  neonSource: 'brick', // 'brick' (the brick's own colour) | 'colour'
260
270
  neonColour: '#3d8bff',
@@ -271,9 +281,7 @@ export const DEFAULTS = Object.freeze({
271
281
  // those settings in the game and have them persistent in settings"). The sky is the panel's own
272
282
  // canvas behind the well, so it costs the game nothing per key press.
273
283
  tetrust: Object.freeze({
274
- stars: true, // the star field across the whole panel
275
- galaxy: true, // the spiral galaxy in it
276
- galaxyAt: 'center', // where its centre sits: behind the title
284
+ sky: 'galaxy', // 'galaxy' | 'earth' | 'none' -- the panel's own canvas behind the well
277
285
  // the landing marker's colour (operator: "I want to be able to set the ghost wireframe
278
286
  // color"). Its own setting, not the neon tubes': the wireframe is drawn instead of a block,
279
287
  // so the neon finish never touches it.
@@ -289,6 +297,33 @@ export const DEFAULTS = Object.freeze({
289
297
  gridColour: '#1844bf', // blue rather than the engine green
290
298
  gridBrightness: 1.55, // and above full, which the blue needs to read at all
291
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
+ }),
292
327
  });
293
328
 
294
329
  const DETAIL = {
@@ -390,8 +425,24 @@ export function fxCadence(g) {
390
425
  export const TAB_ROWS = Object.freeze([
391
426
  Object.freeze({ label: 'Boards', groups: Object.freeze(['appearance', 'space', 'sky', 'markets']) }),
392
427
  Object.freeze({ label: 'Effects', groups: Object.freeze(['effects', 'marketEffects']) }),
393
- Object.freeze({ label: 'Diversions', groups: Object.freeze(['tetrust', 'blockout', 'blockanoid']) }),
428
+ Object.freeze({ label: 'Diversions', groups: Object.freeze(['tetrust', 'blockout', 'blockanoid', 'scorched']) }),
394
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
+
395
446
  const PANEL_GROUPS = Object.freeze([
396
447
  Object.freeze({
397
448
  group: 'appearance',
@@ -440,7 +491,7 @@ const PANEL_GROUPS = Object.freeze([
440
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',
441
492
  options: Object.freeze([['chrome', 'Chrome'], ['satin', 'Satin']]),
442
493
  }),
443
- 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 }),
444
495
  Object.freeze({
445
496
  key: 'detail', label: 'Level of detail', kind: 'choice', hint: 'Simpler cubes draw fewer polygons at the same size',
446
497
  options: Object.freeze([['full', 'Full'], ['simple', 'Simple cubes'], ['flat', 'Flat tiles']]),
@@ -458,29 +509,54 @@ const PANEL_GROUPS = Object.freeze([
458
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' }),
459
510
  Object.freeze({
460
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',
461
- 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']]),
462
517
  }),
463
518
  ]),
464
519
  }),
465
520
  Object.freeze({
466
521
  group: 'sky',
467
522
  title: 'Sky',
468
- 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.',
469
524
  rows: Object.freeze([
470
- 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' }),
471
528
  Object.freeze({
472
- key: 'galaxyAt', label: 'Galaxy centre', kind: 'choice',
473
- 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',
474
531
  options: Object.freeze([['center', 'Behind the board'], ['top-left', 'Top left'], ['top-right', 'Top right'], ['bottom-left', 'Bottom left'], ['bottom-right', 'Bottom right']]),
475
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' }),
476
537
  Object.freeze({ key: 'nebulae', label: 'Nebulae', kind: 'toggle', hint: 'Clouds of gas along the spiral arms, in the colours of star-forming lanes' }),
477
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' }),
478
- 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' }),
479
540
  Object.freeze({ key: 'galaxies', label: 'Distant galaxies', kind: 'toggle', hint: 'Other galaxies, small and faint and far, behind everything else' }),
480
- 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' }),
481
- Object.freeze({ key: 'glints', label: 'Star glints', kind: 'toggle', hint: 'The halo and cross glint on the brightest stars' }),
482
- 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' }),
483
- 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
+ }),
484
560
  ]),
485
561
  }),
486
562
  Object.freeze({
@@ -504,7 +580,7 @@ const PANEL_GROUPS = Object.freeze([
504
580
  + 'five exchanges whenever Overview is open, not only on Markets and Kiosk. '
505
581
  + 'Switch it off and the landing page talks to nothing but your node.',
506
582
  }),
507
- Object.freeze({ key: 'stars', label: 'Star field', kind: 'toggle', hint: 'The twinkling sky behind the candles' }),
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 }),
508
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' }),
509
585
  Object.freeze({
510
586
  key: 'exchange', label: 'Exchange', kind: 'choice', hint: 'Whose candles the chart and the 3D board draw. The others stay as overlay lines',
@@ -525,12 +601,7 @@ const PANEL_GROUPS = Object.freeze([
525
601
  title: 'Blockout',
526
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.',
527
603
  rows: Object.freeze([
528
- Object.freeze({ key: 'stars', label: 'Star field', kind: 'toggle', hint: 'The sky across the whole panel, behind the court' }),
529
- Object.freeze({ key: 'galaxy', label: 'Spiral galaxy', kind: 'toggle', hint: 'The galaxy in that sky, turning' }),
530
- Object.freeze({
531
- key: 'galaxyAt', label: 'Galaxy centre', kind: 'choice', hint: 'Where the galaxy\u2019s centre sits on the panel',
532
- options: Object.freeze([['center', 'Behind the court'], ['top-left', 'Top left'], ['top-right', 'Top right'], ['bottom-left', 'Bottom left'], ['bottom-right', 'Bottom right']]),
533
- }),
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 }),
534
605
  Object.freeze({ key: 'neon', label: 'Neon bricks', kind: 'toggle', hint: 'The wall, the bat and the ball as dim bodies under lit tubes' }),
535
606
  Object.freeze({
536
607
  key: 'neonSource', label: 'Neon colour from', kind: 'choice', hint: 'Each brick row\u2019s own colour, or all in one colour',
@@ -549,12 +620,7 @@ const PANEL_GROUPS = Object.freeze([
549
620
  title: 'Blockanoid',
550
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.',
551
622
  rows: Object.freeze([
552
- Object.freeze({ key: 'stars', label: 'Star field', kind: 'toggle', hint: 'The sky across the whole panel, behind the court' }),
553
- Object.freeze({ key: 'galaxy', label: 'Spiral galaxy', kind: 'toggle', hint: 'The galaxy in that sky, turning' }),
554
- Object.freeze({
555
- key: 'galaxyAt', label: 'Galaxy centre', kind: 'choice', hint: 'Where the galaxy’s centre sits on the panel',
556
- options: Object.freeze([['center', 'Behind the court'], ['top-left', 'Top left'], ['top-right', 'Top right'], ['bottom-left', 'Bottom left'], ['bottom-right', 'Bottom right']]),
557
- }),
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 }),
558
624
  Object.freeze({ key: 'neon', label: 'Neon bricks', kind: 'toggle', hint: 'The wall, Vaus and the ball as dim bodies under lit tubes' }),
559
625
  Object.freeze({
560
626
  key: 'neonSource', label: 'Neon colour from', kind: 'choice', hint: 'Each brick’s own colour, or all in one colour',
@@ -598,12 +664,7 @@ const PANEL_GROUPS = Object.freeze([
598
664
  title: 'Tetrust',
599
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.',
600
666
  rows: Object.freeze([
601
- Object.freeze({ key: 'stars', label: 'Star field', kind: 'toggle', hint: 'The sky across the whole panel, behind the well' }),
602
- Object.freeze({ key: 'galaxy', label: 'Spiral galaxy', kind: 'toggle', hint: 'The galaxy in that sky, turning' }),
603
- Object.freeze({
604
- key: 'galaxyAt', label: 'Galaxy centre', kind: 'choice', hint: 'Where the galaxy’s centre sits on the panel: behind the title, or a corner',
605
- options: Object.freeze([['center', 'Behind the title'], ['top-left', 'Top left'], ['top-right', 'Top right'], ['bottom-left', 'Bottom left'], ['bottom-right', 'Bottom right']]),
606
- }),
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 }),
607
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' }),
608
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' }),
609
670
  Object.freeze({ key: 'music', label: 'Music', kind: 'toggle', hint: 'Korobeiniki, on oscillators' }),
@@ -620,6 +681,51 @@ const PANEL_GROUPS = Object.freeze([
620
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' }),
621
682
  ]),
622
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
+ }),
623
729
  ]);
624
730
  /** The groups in the sheet's order: row by row of TAB_ROWS, then anything a row forgot (a test says nothing is). */
625
731
  export const PANEL = Object.freeze([
@@ -782,6 +888,35 @@ const MIGRATIONS = {
782
888
  const fx = raw.effects && typeof raw.effects === 'object' ? raw.effects : {};
783
889
  return { ...raw, marketEffects: { ...fx } };
784
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
+ },
785
920
  };
786
921
 
787
922
  function migrate(raw) {
@@ -937,13 +1072,71 @@ export function isDefault(s) {
937
1072
  * Merged OVER the caller's own options, so a mode's resolution and slab still win where they are
938
1073
  * the point of the mode; these are the user's preferences about how it is drawn.
939
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
+
940
1130
  export function spaceOptions(s) {
941
1131
  const n = normalise(s);
942
1132
  const sp = n.space;
943
1133
  const d = DETAIL[sp.detail] ?? DETAIL.full;
944
1134
  const out = {
945
1135
  shadows: sp.shadows,
946
- 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',
947
1140
  grid: sp.grid,
948
1141
  neon: sp.neon,
949
1142
  sheen: sp.sheen,
@@ -951,18 +1144,12 @@ export function spaceOptions(s) {
951
1144
  // `space` is the board STYLE (no deck texture, a translucent floor); `stars` is the sky. They
952
1145
  // travel together here, which is the block-space board's shipped behaviour, but they are two
953
1146
  // options now so the markets board can keep its style while turning its sky off.
954
- space: sp.stars,
955
- stars: sp.stars,
1147
+ space: skyOf(n, 'space') !== 'none',
956
1148
  dome: sp.dome,
957
1149
  facetPx: d.facetPx,
958
1150
  crownPx: d.crownPx,
959
- // the same sky the markets board draws: this board had stars and no way to thin them
960
- starDensity: n.sky.density,
961
- starBrightness: n.sky.brightness,
962
- galaxy: n.sky.galaxy,
963
- galaxyAt: n.sky.galaxyAt,
964
- nebulae: n.sky.nebulae, galaxies: n.sky.galaxies, dust: n.sky.dust, clusters: n.sky.clusters,
965
- 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'),
966
1153
  };
967
1154
  // the seam belongs to the Stone edges switch at every level of detail: a control that does
968
1155
  // nothing in one mode is worse than no control (operator: "stone edges don't work in flat tile
@@ -973,10 +1160,11 @@ export function spaceOptions(s) {
973
1160
  if (motion) out.transition = motion;
974
1161
  out.departures = sp.departures;
975
1162
  out.light = sp.light;
1163
+ out.lightHeight = sp.lightHeight;
976
1164
  // merged into the camera by details3d (it owns the oblique constants); 0 leaves it exactly as it
977
1165
  // has always been, so the switch costs nothing until someone moves it
978
1166
  out.obliqueRise = sp.perspective;
979
- out.fxKinds = enabledEffects(n);
1167
+ out.fxKinds = skyOf(n, 'space') === 'earth' ? [] : enabledEffects(n);
980
1168
  out.fxNoRepeat = n.effects.noRepeat;
981
1169
  Object.assign(out, fxCadence(n.effects));
982
1170
  out.neonSource = sp.neonSource; out.neonColour = sp.neonColour; out.neonBrightness = sp.neonBrightness;
@@ -1012,9 +1200,8 @@ export function enabledEffects(s, group = 'effects') {
1012
1200
  /** Blockout's switches, with the sky's make-up from the Sky group (as tetrustOptions does). */
1013
1201
  export function blockoutOptions(s) {
1014
1202
  const n = normalise(s);
1015
- const sky = spaceOptions(s);
1016
1203
  return {
1017
- 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,
1018
1205
  neon: n.blockout.neon,
1019
1206
  // the engine calls the data-coloured source "temperature"; here that is the brick's own row
1020
1207
  neonSource: n.blockout.neonSource === 'colour' ? 'colour' : 'temperature',
@@ -1023,43 +1210,45 @@ export function blockoutOptions(s) {
1023
1210
  // composed here rather than in the screen, so the court needs no new import and the renderer
1024
1211
  // keys stay in one place. 0.18 is the weight this court was hand-tuned at.
1025
1212
  gridOpts: courtGridColours(n.blockout.gridColour, n.blockout.gridBrightness, 0.18),
1026
- starDensity: sky.starDensity, starBrightness: sky.starBrightness,
1027
- nebulae: sky.nebulae, galaxies: sky.galaxies, dust: sky.dust, clusters: sky.clusters,
1028
- starColours: sky.starColours, starGlints: sky.starGlints,
1029
1213
  };
1030
1214
  }
1031
1215
 
1032
1216
  /** Blockanoid's switches. Blockout's shape, plus the two that are Arkanoid's own. */
1033
1217
  export function blockanoidOptions(s) {
1034
1218
  const n = normalise(s);
1035
- const sky = spaceOptions(s);
1036
1219
  return {
1037
- 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,
1038
1221
  capsules: n.blockanoid.capsules, enemies: n.blockanoid.enemies,
1039
1222
  neon: n.blockanoid.neon,
1040
1223
  neonSource: n.blockanoid.neonSource === 'colour' ? 'colour' : 'temperature',
1041
1224
  neonColour: n.blockanoid.neonColour, neonBrightness: n.blockanoid.neonBrightness,
1042
1225
  grid: n.blockanoid.grid, gridColour: n.blockanoid.gridColour, gridBrightness: n.blockanoid.gridBrightness,
1043
1226
  gridOpts: courtGridColours(n.blockanoid.gridColour, n.blockanoid.gridBrightness, 0.18),
1044
- starDensity: sky.starDensity, starBrightness: sky.starBrightness,
1045
- nebulae: sky.nebulae, galaxies: sky.galaxies, dust: sky.dust, clusters: sky.clusters,
1046
- starColours: sky.starColours, starGlints: sky.starGlints,
1047
1227
  };
1048
1228
  }
1049
1229
 
1050
1230
  export function tetrustOptions(s) {
1051
1231
  const n = normalise(s);
1052
- const sky = spaceOptions(s);
1053
1232
  return {
1054
- 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,
1055
1234
  ghostColour: n.tetrust.ghostColour, ghostWidth: n.tetrust.ghostWidth,
1056
1235
  neon: n.tetrust.neon, neonSource: n.tetrust.neonSource === 'colour' ? 'colour' : 'temperature', neonColour: n.tetrust.neonColour, neonBrightness: n.tetrust.neonBrightness,
1057
1236
  grid: n.tetrust.grid, gridColour: n.tetrust.gridColour, gridBrightness: n.tetrust.gridBrightness,
1058
1237
  // 0.06, not 0.18: the well draws its grid fainter than the brick courts do, because the stack
1059
1238
  // sits on top of it. That difference was hardcoded in tetrust.js; it lives here now.
1060
1239
  gridOpts: courtGridColours(n.tetrust.gridColour, n.tetrust.gridBrightness, 0.06),
1061
- starDensity: sky.starDensity, starBrightness: sky.starBrightness,
1062
- 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,
1063
1252
  };
1064
1253
  }
1065
1254
 
@@ -1072,13 +1261,8 @@ export function marketsOptions(s) {
1072
1261
  // as the sky, so driving it from the star switch restyled the whole board when the operator
1073
1262
  // only wanted the stars gone. The sky has its own option now.
1074
1263
  space: true,
1075
- stars: mk.stars,
1076
- starDensity: n.sky.density,
1077
- starBrightness: n.sky.brightness,
1078
- galaxy: n.sky.galaxy,
1079
- galaxyAt: n.sky.galaxyAt,
1080
- nebulae: n.sky.nebulae, galaxies: n.sky.galaxies, dust: n.sky.dust, clusters: n.sky.clusters,
1081
- 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'),
1082
1266
  // never, at any setting: the halo under the grid lines is not wanted on this board
1083
1267
  neonHalo: 'rgba(0,0,0,0)',
1084
1268
  gridGlow: 'rgba(0,0,0,0)',
@@ -1089,7 +1273,9 @@ export function marketsOptions(s) {
1089
1273
  ...(mk.effects ? {} : { idleFx: false, transition: MOTION.still }),
1090
1274
  // ITS OWN SWITCHES (operator, 2026-09-14: "settings specific to market panel"): the Market
1091
1275
  // effects group, not the block board's
1092
- 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'),
1093
1279
  fxNoRepeat: n.marketEffects.noRepeat,
1094
1280
  ...fxCadence(n.marketEffects),
1095
1281
  };