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
package/public/js/app.js CHANGED
@@ -11,8 +11,7 @@ import { renderMiningOverview, renderMining, renderBlockSpace, refreshLabel } fr
11
11
  import { viewerIdle } from './details3d.js';
12
12
  import {
13
13
  loadSettings, setSetting, resetSettings, seedSettings, setSettingsPush,
14
- SETTINGS_KEY, PANEL as SETTINGS_PANEL, formatRangeValue,
15
- } from './settings.js';
14
+ SETTINGS_KEY, PANEL as SETTINGS_PANEL, formatRangeValue, SKY_BOARDS, SKY_CHOICES } from './settings.js';
16
15
  // APPEARANCE (2026-09-16): the theme goes on <html> before the first paint, from the browser's copy
17
16
  // of the settings, and follows every change after (followTheme); the cards' swatches are painted
18
17
  // through the CSSOM because the CSP refuses a style attribute in markup
@@ -21,12 +20,32 @@ import { followTheme, presetFace, resolveScheme, resolveTheme, PRESETS, BASE_KEY
21
20
  import { renderExplorer } from './explorer.js';
22
21
  import { renderMarkets, summaryHtml as marketsSummaryHtml, REFRESH_MS as MARKETS_REFRESH_MS } from './markets.js';
23
22
  import { renderKiosk } from './kiosk.js';
24
- import { renderTetrust } from './tetrust.js';
25
- import { renderBlockout } from './blockout.js';
26
- import { renderBlockanoid } from './blockanoid.js';
27
- import { renderWolf3d } from './wolf3d.js';
28
- import { renderDoom } from './doom.js';
29
- import { renderQuake } from './quake.js';
23
+ // THE GAMES LOAD WHEN THEY ARE OPENED (the outside review of 2026-09-16: "Move the DOOM/Wolfenstein/
24
+ // Quake emulator and 3D games into an optional, separately loadable module ... This reduces the
25
+ // attack and bug surface of the core monitor"). They were static imports, so the Overview of a node
26
+ // monitor fetched and evaluated twenty game modules -- a quarter of the app's code -- before it drew
27
+ // a chart. Each game is now an import() made the first time its page is opened; a monitor that is
28
+ // only ever used as a monitor never runs a line of them.
29
+ const GAMES = Object.freeze({
30
+ tetrust: () => import('./tetrust.js').then((m) => m.renderTetrust),
31
+ blockout: () => import('./blockout.js').then((m) => m.renderBlockout),
32
+ blockanoid: () => import('./blockanoid.js').then((m) => m.renderBlockanoid),
33
+ scorched: () => import('./scorchedyard.js').then((m) => m.renderScorchedYard),
34
+ wolf3d: () => import('./wolf3d.js').then((m) => m.renderWolf3d),
35
+ doom: () => import('./doom.js').then((m) => m.renderDoom),
36
+ quake: () => import('./quake.js').then((m) => m.renderQuake),
37
+ });
38
+ const gameRenderers = new Map();
39
+ const gameLoads = new Map();
40
+ /** Draw a game's page, loading its module the first time; the page repaints once it has arrived. */
41
+ function renderGame(page, s) {
42
+ const draw = gameRenderers.get(page);
43
+ if (draw) { draw(s, state, helpers); return; }
44
+ if (gameLoads.has(page)) return;
45
+ gameLoads.set(page, GAMES[page]()
46
+ .then((fn) => { gameRenderers.set(page, fn); if (state.page === page) render(); })
47
+ .catch((e) => { gameLoads.delete(page); toast(`${page} could not be loaded: ${e.message}`, 'bad'); }));
48
+ }
30
49
  import { renderAbout } from './about.js';
31
50
  import { renderChain, renderMempool, renderPeers, renderNetwork, renderLogs, renderNode, renderAdmin, ensureLogsLoaded, init as initPanels, initChainDrill } from './panels.js';
32
51
 
@@ -528,12 +547,9 @@ export function render() {
528
547
  case 'explorer': renderExplorer(s, state, helpers); break;
529
548
  case 'markets': renderMarkets(s, state, helpers); break;
530
549
  case 'kiosk': renderKiosk(s, state, helpers); break;
531
- case 'tetrust': renderTetrust(s, state, helpers); break;
532
- case 'blockout': renderBlockout(s, state, helpers); break;
533
- case 'blockanoid': renderBlockanoid(s, state, helpers); break;
534
- case 'wolf3d': renderWolf3d(s, state, helpers); break;
535
- case 'doom': renderDoom(s, state, helpers); break;
536
- case 'quake': renderQuake(s, state, helpers); break;
550
+ case 'tetrust': case 'blockout': case 'blockanoid': case 'scorched': case 'wolf3d': case 'doom': case 'quake':
551
+ renderGame(state.page, s);
552
+ break;
537
553
  case 'about': renderAbout(s, state, helpers); break;
538
554
  }
539
555
  }
@@ -923,7 +939,7 @@ function shortBuild(build) {
923
939
  // subroutes; they stay on the URL so every explorer page is a link.
924
940
  // The games live behind the Diversions pop-down at the end of the nav; the menu shows as the
925
941
  // active tab while one of them is open, since its own button is out of sight inside the popup.
926
- const DIVERSION_PAGES = ['tetrust', 'blockout', 'blockanoid', 'wolf3d', 'doom', 'quake'];
942
+ const DIVERSION_PAGES = ['tetrust', 'blockout', 'blockanoid', 'scorched', 'wolf3d', 'doom', 'quake'];
927
943
 
928
944
  function setPage(route) {
929
945
  const [page, ...rest] = String(route).split('/');
@@ -1165,6 +1181,23 @@ async function boot() {
1165
1181
  `<button type="button" class="cfgsegbtn${val === v ? ' on' : ''}" role="radio" aria-checked="${val === v}" data-cfgset="${g.group}.${r.key}" data-value="${val}">${label}</button>`).join('')}</span>`;
1166
1182
  return `<div class="cfgrow"><b><label>${r.label}</label></b><span>${seg}</span><i>${r.hint}</i></div>`;
1167
1183
  }
1184
+ // WHICH SKY, WHERE (docs/PLAN-SKIES.md): the table at the top of the Sky tab. One row per board
1185
+ // that has a sky, each a select on that board's own `sky` key -- the same setting the board's
1186
+ // tab shows, through the same data-cfg path, so a change in either place is one change.
1187
+ if (r.kind === 'skymap') {
1188
+ const rows = SKY_BOARDS.map((b) => {
1189
+ const cur = s[b.group]?.sky ?? 'galaxy';
1190
+ const sel = `<select data-cfg="${b.group}.sky" aria-label="${b.label}: sky">${SKY_CHOICES.map(([val, label]) => `<option value="${val}"${val === cur ? ' selected' : ''}>${label}</option>`).join('')}</select>`;
1191
+ return `<tr><th scope="row">${b.label}${b.note ? `<small>${b.note}</small>` : ''}</th><td>${sel}</td></tr>`;
1192
+ }).join('');
1193
+ return `<div class="cfgrow cfgskymap"><b><label>${r.label}</label></b><span><table class="skymap">${rows}</table></span><i>${r.hint}</i></div>`;
1194
+ }
1195
+ // a heading inside a group: the two skies of the Sky tab, each introducing its own rows
1196
+ if (r.kind === 'heading') {
1197
+ const close = open ? '</div>' : '';
1198
+ open = false;
1199
+ return `${close}<div class="cfghead"><b>${r.label}</b><i>${r.hint}</i></div>`;
1200
+ }
1168
1201
  if (r.kind === 'cards') {
1169
1202
  const scheme = resolveScheme(s.appearance);
1170
1203
  const cards = r.options.map(([val, label]) => {
@@ -1186,17 +1219,25 @@ async function boot() {
1186
1219
  : `<span class="cfgrange"><input type="range" id="${id}" data-cfg="${g.group}.${r.key}" min="${r.min}" max="${r.max}" step="${r.step}" value="${v}"><span class="val" data-val-for="${g.group}.${r.key}">${formatRangeValue(r.step, v)}</span></span>`;
1187
1220
  // (and the nine custom colours of the Appearance tab the same way: two across, hint as tooltip,
1188
1221
  // dimmed while another theme is chosen, so the tab is a screen)
1222
+ // A CONTROL THAT DOES NOTHING RIGHT NOW SAYS SO (operator, 2026-09-16: "It's counter
1223
+ // intuitive in the preferences to display a spiral galaxy for a night/day scene"). A row
1224
+ // whose `dimWhen` is true of the current settings is dimmed and says which sky it belongs
1225
+ // to, rather than being hidden: the setting is still there, and still remembered.
1226
+ const off = (r.custom && s.appearance.theme !== 'custom') || (typeof r.dimWhen === 'function' && r.dimWhen(s));
1227
+ const idle = off ? ' idle' : '';
1228
+ const note = off && r.dimNote ? ` <em>${r.dimNote}</em>` : '';
1189
1229
  if ((compact && r.kind === 'toggle') || r.custom) {
1190
1230
  const start = open ? '' : '<div class="cfgrows2">';
1191
1231
  open = true;
1192
- const idle = r.custom && s.appearance.theme !== 'custom' ? ' idle' : '';
1193
- return `${start}<div class="cfgrow compact${idle}" title="${String(r.hint).replace(/"/g, '&quot;')}"><b><label for="${id}">${r.label}</label></b><span>${ctl}</span></div>`;
1232
+ return `${start}<div class="cfgrow compact${idle}" title="${String(off && r.dimNote ? `${r.dimNote} ${r.hint}` : r.hint).replace(/"/g, '&quot;')}"><b><label for="${id}">${r.label}</label></b><span>${ctl}</span></div>`;
1194
1233
  }
1195
1234
  const close = open ? '</div>' : '';
1196
1235
  open = false;
1197
- return `${close}<div class="cfgrow"><b><label for="${id}">${r.label}</label></b><span>${ctl}</span><i>${r.hint}</i></div>`;
1236
+ return `${close}<div class="cfgrow${idle}"><b><label for="${id}">${r.label}</label></b><span>${ctl}</span><i>${r.hint}${note}</i></div>`;
1198
1237
  }).join('') + (open ? '</div>' : '');
1199
- return `<div class="cfggroup"><h3>${g.title}</h3><p>${g.note}</p>${bulk}${rowsHtml}</div>`;
1238
+ // the Space effects tab says so while the Living sky has them off (settings.js spaceOptions)
1239
+ const held = g.group === 'effects' && s.sky.type === 'living' ? '<p class="cfgheld">Off while the sky is the Living sky (Sky tab): these play over space. Your switches are kept.</p>' : '';
1240
+ return `<div class="cfggroup"><h3>${g.title}</h3><p>${g.note}</p>${held}${bulk}${rowsHtml}</div>`;
1200
1241
  }).join('');
1201
1242
  for (const el of cfgBody.querySelectorAll('[data-swatch]')) el.style.setProperty('background', el.dataset.swatch);
1202
1243
  for (const el of cfgBody.querySelectorAll('.thcard')) {
@@ -10,7 +10,7 @@ import {
10
10
  newGame, advance, step, movePaddle, nudge, launch, fire, tiles, remaining, powers, setOptions,
11
11
  COLS, ROWS, LIVES, CAPSULE_LETTER, layoutFor,
12
12
  } from './arkanoid.js';
13
- import { loadSettings, setSetting, blockanoidOptions } from './settings.js';
13
+ import { loadSettings, setSetting, blockanoidOptions, nextSky, SKY_LABELS } from './settings.js';
14
14
  import * as sound from './tetsound.js';
15
15
 
16
16
  const SCORES_KEY = 'blockyard.blockanoid.scores';
@@ -130,9 +130,20 @@ function drawScores(highlightAt = null) {
130
130
  if (t.innerHTML !== html) t.innerHTML = html;
131
131
  }
132
132
 
133
- const SWITCHES = [['baStars', 'stars'], ['baGalaxy', 'galaxy'], ['baNeon', 'neon'], ['baSfx', 'sfx']];
133
+ const SWITCHES = [['baNeon', 'neon'], ['baSfx', 'sfx']];
134
+ /** The sky button says which sky it is on, and lights up while there is one. */
135
+ function skyButton(id, sky) {
136
+ const b = el(id);
137
+ if (!b) return;
138
+ b.textContent = `\u2600 ${SKY_LABELS[sky] ?? sky}`;
139
+ b.classList.toggle('on', sky !== 'none');
140
+ b.setAttribute('aria-pressed', sky !== 'none' ? 'true' : 'false');
141
+ }
134
142
  function drawSwitches() {
135
143
  const b = blockanoidOptions(loadSettings());
144
+ // ONE SKY BUTTON (docs/PLAN-SKIES.md): Galaxy, Earth or none, round the ring, in place of the old
145
+ // star field and galaxy pair -- the same setting the Sky tab's table shows for this board
146
+ skyButton('baSkySw', b.sky.sky);
136
147
  for (const [id, key] of SWITCHES) {
137
148
  const btn = el(id);
138
149
  if (!btn) continue;
@@ -146,7 +157,6 @@ function flip(key) {
146
157
  setSetting(loadSettings(), `blockanoid.${key}`, !cur);
147
158
  sound.unlock();
148
159
  drawSwitches();
149
- if (key === 'stars' || key === 'galaxy') drawSky();
150
160
  // capsules and minions change the RULES, so a flip must reach the game IN PLAY, not just the
151
161
  // next one -- otherwise the switch does nothing until you lose, which reads as a broken control
152
162
  if (G.game) { const b = blockanoidOptions(loadSettings()); setOptions(G.game, { capsules: b.capsules, enemies: b.enemies }); }
@@ -159,10 +169,7 @@ function drawSky() {
159
169
  const b = blockanoidOptions(loadSettings());
160
170
  board3d(sky, [], {
161
171
  ...SKY,
162
- stars: b.stars, galaxy: b.stars && b.galaxy, galaxyAt: b.galaxyAt,
163
- starDensity: b.starDensity, starBrightness: b.starBrightness,
164
- nebulae: b.nebulae, galaxies: b.galaxies, dust: b.dust, clusters: b.clusters,
165
- starColours: b.starColours, starGlints: b.starGlints,
172
+ ...b.sky, // which sky this board chose, and what it is made of (settings.js skyFor)
166
173
  });
167
174
  }
168
175
 
@@ -327,6 +334,7 @@ function bind() {
327
334
  });
328
335
  court?.addEventListener('contextmenu', (e) => { if (G.running) e.preventDefault(); });
329
336
  for (const [id, key] of SWITCHES) el(id)?.addEventListener('click', () => flip(key));
337
+ el('baSkySw')?.addEventListener('click', () => { const s = loadSettings(); setSetting(s, 'blockanoid.sky', nextSky(blockanoidOptions(s).sky.sky)); sound.unlock(); drawSwitches(); drawSky(); draw(); });
330
338
  }
331
339
 
332
340
  export function renderBlockanoid(s, state, h) {
@@ -12,7 +12,7 @@ import {
12
12
  newGame, advance, step, movePaddle, nudge, launch, tiles, remaining,
13
13
  COLS, ROWS, LIVES,
14
14
  } from './breakout.js';
15
- import { loadSettings, setSetting, blockoutOptions } from './settings.js';
15
+ import { loadSettings, setSetting, blockoutOptions, nextSky, SKY_LABELS } from './settings.js';
16
16
  import * as sound from './tetsound.js';
17
17
 
18
18
  const SCORES_KEY = 'blockyard.blockout.scores';
@@ -145,9 +145,20 @@ function drawScores(highlightAt = null) {
145
145
  if (t.innerHTML !== html) t.innerHTML = html;
146
146
  }
147
147
 
148
- const SWITCHES = [['boStars', 'stars'], ['boGalaxy', 'galaxy'], ['boNeon', 'neon'], ['boSfx', 'sfx']];
148
+ const SWITCHES = [['boNeon', 'neon'], ['boSfx', 'sfx']];
149
+ /** The sky button says which sky it is on, and lights up while there is one. */
150
+ function skyButton(id, sky) {
151
+ const b = el(id);
152
+ if (!b) return;
153
+ b.textContent = `\u2600 ${SKY_LABELS[sky] ?? sky}`;
154
+ b.classList.toggle('on', sky !== 'none');
155
+ b.setAttribute('aria-pressed', sky !== 'none' ? 'true' : 'false');
156
+ }
149
157
  function drawSwitches() {
150
158
  const b = blockoutOptions(loadSettings());
159
+ // ONE SKY BUTTON (docs/PLAN-SKIES.md): Galaxy, Earth or none, round the ring, in place of the old
160
+ // star field and galaxy pair -- the same setting the Sky tab's table shows for this board
161
+ skyButton('boSkySw', b.sky.sky);
151
162
  for (const [id, key] of SWITCHES) {
152
163
  const btn = el(id);
153
164
  if (!btn) continue;
@@ -161,7 +172,6 @@ function flip(key) {
161
172
  setSetting(loadSettings(), `blockout.${key}`, !cur);
162
173
  sound.unlock();
163
174
  drawSwitches();
164
- if (key === 'stars' || key === 'galaxy') drawSky();
165
175
  G.dirty = true;
166
176
  draw();
167
177
  }
@@ -172,10 +182,7 @@ function drawSky() {
172
182
  const b = blockoutOptions(loadSettings());
173
183
  board3d(sky, [], {
174
184
  ...SKY,
175
- stars: b.stars, galaxy: b.stars && b.galaxy, galaxyAt: b.galaxyAt,
176
- starDensity: b.starDensity, starBrightness: b.starBrightness,
177
- nebulae: b.nebulae, galaxies: b.galaxies, dust: b.dust, clusters: b.clusters,
178
- starColours: b.starColours, starGlints: b.starGlints,
185
+ ...b.sky, // which sky this board chose, and what it is made of (settings.js skyFor)
179
186
  });
180
187
  }
181
188
 
@@ -329,6 +336,7 @@ function bind() {
329
336
  court?.addEventListener('pointermove', onPointer);
330
337
  court?.addEventListener('pointerdown', (e) => { onPointer(e); serve(); });
331
338
  for (const [id, key] of SWITCHES) el(id)?.addEventListener('click', () => flip(key));
339
+ el('boSkySw')?.addEventListener('click', () => { const s = loadSettings(); setSetting(s, 'blockout.sky', nextSky(blockoutOptions(s).sky.sky)); sound.unlock(); drawSwitches(); drawSky(); draw(); });
332
340
  }
333
341
 
334
342
  export function renderBlockout(s, state, h) {
@@ -852,21 +852,50 @@ const flipOf = (o) => (o.flipY === false ? -1 : 1);
852
852
  // settings"). Each lamp is a direction over the board for the dome's slope shading (grid x,
853
853
  // screen-up rows, up) and a direction across the screen for the side faces (x right, y down);
854
854
  // `overhead` is straight above, so no slope is in shade and every side takes the same light.
855
+ // SIX PLACEMENTS AT THREE HEIGHTS (operator, 2026-09-16: "add more camera light angles ... Low
856
+ // Middle and High light placement locations, top left, top right, bottom left, bottom right,
857
+ // viewer, direct overhead"). A placement is a direction across the board (x right, y screen-up);
858
+ // the height is the lamp's elevation over it: low 25°, middle 45°, high 65°. `overhead` ignores
859
+ // the height. The old names (upper-left, upper-right) still answer, as top-left and top-right at
860
+ // the middle height; `front` is the lamp at the viewer. (Not 'viewer': `light: 'viewer'` is the
861
+ // Markets board's own flat, viewer-lit finish -- viewerLit below -- and a placement of that name
862
+ // turned the whole block board flat-shaded, which the operator saw at once on 2026-09-16.)
863
+ export const LIGHT_PLACES = Object.freeze({
864
+ 'overhead': [0, 0], 'top-left': [-1, 1], 'top-right': [1, 1], 'bottom-left': [-1, -1], 'bottom-right': [1, -1], 'front': [0, -1],
865
+ });
866
+ export const LIGHT_HEIGHTS = Object.freeze({ low: 25, middle: 45, high: 65 });
867
+ const LIGHT_ALIASES = Object.freeze({ 'upper-left': 'top-left', 'upper-right': 'top-right' });
868
+ export function lampFor(place, height = 'middle') {
869
+ const p = LIGHT_PLACES[place] ?? LIGHT_PLACES['top-left'];
870
+ if (!p[0] && !p[1]) return { L: [0, 0, 1], side: [0, 0] };
871
+ const el = ((LIGHT_HEIGHTS[height] ?? 45) * Math.PI) / 180;
872
+ const n = Math.hypot(p[0], p[1]) || 1;
873
+ const ax = p[0] / n, ay = p[1] / n;
874
+ // the side faces take the lamp's direction across the screen (y down), weaker the higher it hangs
875
+ const k = 0.9 * Math.cos(el) / Math.cos((45 * Math.PI) / 180);
876
+ return { L: [ax * Math.cos(el), ay * Math.cos(el), Math.sin(el)], side: [ax * k, -ay * k] };
877
+ }
855
878
  export const LIGHTS = Object.freeze({
856
- 'overhead': { L: [0, 0, 1], side: [0, 0] },
857
- 'upper-left': { L: [-0.55, 0.55, 0.63], side: [-0.7071, -0.7071] },
858
- 'upper-right': { L: [0.55, 0.55, 0.63], side: [0.7071, -0.7071] },
859
- 'front': { L: [0, -0.55, 0.63], side: [0, 0.9] },
879
+ 'overhead': lampFor('overhead'),
880
+ 'top-left': lampFor('top-left'), 'top-right': lampFor('top-right'), 'bottom-left': lampFor('bottom-left'), 'bottom-right': lampFor('bottom-right'), 'front': lampFor('front'),
881
+ 'upper-left': lampFor('top-left'), 'upper-right': lampFor('top-right'),
860
882
  });
861
- export const LIGHT_DEFAULT = 'upper-left';
883
+ export const LIGHT_DEFAULT = 'top-left';
884
+ // the middle of the shipped brightness range: `lightGain` stretches the faces about this point
885
+ const LIT_MID = 0.58;
862
886
  const NEON_HEX = /^#[0-9a-f]{6}$/i;
863
887
  export function lightOf(o = {}) {
864
- return LIGHTS[o.light] ? o.light : o.overheadLight === true ? 'overhead' : LIGHT_DEFAULT;
888
+ const name = LIGHT_ALIASES[o.light] ?? o.light;
889
+ return LIGHT_PLACES[name] ? name : o.overheadLight === true ? 'overhead' : LIGHT_DEFAULT;
890
+ }
891
+ /** The lamp a board asks for: its placement at its height. */
892
+ export function lampOf(o = {}) {
893
+ return lampFor(lightOf(o), LIGHT_HEIGHTS[o.lightHeight] ? o.lightHeight : 'middle');
865
894
  }
866
895
  export function domeLight(t, o = {}) {
867
896
  const { dome = 0, gridW = 0, gridH = 0 } = o;
868
897
  if (!dome || !gridW || !gridH) return 1;
869
- const lamp = LIGHTS[lightOf(o)];
898
+ const lamp = lampOf(o);
870
899
  // straight above: the slope shades nothing -- the bottom rows, which lean away from a corner
871
900
  // lamp and sat at the 0.6 floor, read as bright as the middle
872
901
  if (lamp.L[0] === 0 && lamp.L[1] === 0) return 1;
@@ -1271,7 +1300,7 @@ export function buildScene(tiles, o = {}) {
1271
1300
  // among equals the one further from the vanishing point first (its sides
1272
1301
  // lean outward, away from its nearer neighbours).
1273
1302
  const vx0 = o.vanishX ?? 0, vy0 = o.vanishY ?? 0;
1274
- const ordered = o.oblique && o.order === 'diagonal' ? diagonalOrder(tiles) : o.oblique ? obliqueOrder(tiles, o) : tiles.map((t) => (growth.has(t) ? { ...t, boost: growth.get(t) } : t))
1303
+ const ordered = o.order === 'given' ? tiles.slice() : o.oblique && o.order === 'diagonal' ? diagonalOrder(tiles) : o.oblique ? obliqueOrder(tiles, o) : tiles.map((t) => (growth.has(t) ? { ...t, boost: growth.get(t) } : t))
1275
1304
  .map((t) => { const c = project(t.x + t.s / 2, t.y + t.s / 2, 0, o); return { t, top: (t.z ?? 0) + (t.floor ?? 0) + cubeHeight(t), d: Math.hypot(c.x - vx0, c.y - vy0) }; })
1276
1305
  // Under the oblique camera a cube reaches STRAIGHT UP from its footprint (measured: height
1277
1306
  // moves screen y only, grid x moves screen x only), so it can only cover blocks at smaller
@@ -1326,7 +1355,10 @@ export function buildScene(tiles, o = {}) {
1326
1355
  })).filter((e) => e.k > 0.01) : [];
1327
1356
  // the lamp's direction across the screen, for the side faces (LIGHTS); under the overhead
1328
1357
  // lamp every side takes the same light, a shade under the top
1329
- const lampSide = LIGHTS[lightOf(o)].side;
1358
+ const lampSide = lampOf(o).side;
1359
+ // the lamp's hardness and how bright the tops sit under it (see the side loop below)
1360
+ const gain = Number.isFinite(o.lightGain) ? Math.max(0.2, Math.min(3, o.lightGain)) : 1;
1361
+ const topLit = Number.isFinite(o.topLight) ? Math.max(0.1, Math.min(1.4, o.topLight)) : 0.8;
1330
1362
  for (const t of ordered) {
1331
1363
  const fxv = (t.z ?? 0) > 0.02 ? FX_NONE : fxAt(t, o.fx);
1332
1364
  // GONE FOR THE DURATION, not deleted: a hidden cube is skipped this frame and drawn again the
@@ -1517,11 +1549,19 @@ export function buildScene(tiles, o = {}) {
1517
1549
  // brighter, one turned away falls into shadow
1518
1550
  const d = side.nx * lampSide[0] + side.ny * lampSide[1];
1519
1551
  // from the viewer: a face pointing down the screen (toward the camera) takes the most light
1520
- const k = viewerLit ? 0.6 + 0.42 * Math.max(0, side.ny) + 0.3 * Math.max(0, side.nx) - 0.16 * Math.max(0, -side.nx) : 0.34 + 0.26 * (d + 1);
1552
+ const k0 = viewerLit ? 0.6 + 0.42 * Math.max(0, side.ny) + 0.3 * Math.max(0, side.nx) - 0.16 * Math.max(0, -side.nx) : 0.34 + 0.26 * (d + 1);
1553
+ // HOW HARD THE LAMP IS (operator, 2026-09-16: "we need better lighting on the front of the
1554
+ // blocks. Still looks too washed out and not illuminated well enough"). The shipped range put
1555
+ // every face between 0.34 and 0.86 of its colour with the top at 0.8: a narrow band, and a
1556
+ // board of it reads as tinted rather than lit. `lightGain` stretches that range about its
1557
+ // middle -- above 1 the face turned to the lamp goes brighter than its own colour and the one
1558
+ // turned away falls properly dark. 1 is exactly what every board drew before this.
1559
+ const k = Math.max(0.06, Math.min(1.4, LIT_MID + (k0 - LIT_MID) * gain));
1521
1560
  out.push({ txid: t.txid, face: 'side', key: side.key, points: side.points, fill: shade(c, k * lit, a), ...(viewerLit ? { stroke: lift(c, 0.45, round3(0.5 * a)) } : {}) });
1522
1561
  side.points.forEach(note);
1523
1562
  }
1524
- out.push({ txid: t.txid, face: 'top', points: f.top, fill: shade(c, (viewerLit ? 1.02 : 0.8) * lit, a), stroke: viewerLit ? lift(c, 0.55, round3(0.6 * a)) : `rgba(0,0,0,${round3(seam * a)})` });
1563
+ const topK = viewerLit ? 1.02 : Math.max(0.06, Math.min(1.4, LIT_MID + (topLit - LIT_MID) * gain));
1564
+ out.push({ txid: t.txid, face: 'top', points: f.top, fill: shade(c, topK * lit, a), stroke: viewerLit ? lift(c, 0.55, round3(0.6 * a)) : `rgba(0,0,0,${round3(seam * a)})` });
1525
1565
  f.top.forEach(note);
1526
1566
 
1527
1567
  if (f.innerG && !viewerLit) {
@@ -242,7 +242,7 @@ const offText = (d) => (d?.enabled === false ? (d.polling === false ? 'market po
242
242
 
243
243
  export function depthNote(d, fmt) {
244
244
  if (!d) return 'asking the exchanges for their order books…';
245
- if (d.enabled === false) return d.note ?? 'market data is off on this monitor';
245
+ if (d.enabled === false) return d.note != null ? fmt.esc(d.note) : 'market data is off on this monitor'; // markup sink: escaped (audit 2026-09-16, I2)
246
246
  if (d.warming) return 'reading the order books for the first time…';
247
247
  const agoL = AGOS.find(([s]) => s === d.ago)?.[1] ?? `${d.ago} s`;
248
248
  const parts = [`books read at ${clock(d.at)} UTC, every ${Math.round((d.bookMs ?? DEPTH_MS) / 1000)} s while this tab is open`];
@@ -19,6 +19,7 @@ import { planTransition, frameAt, fitToBox, project, fxFront, TRANSITION, SLAB_H
19
19
  // This module keeps three seams and nothing else -- build here in startFx, frame in fxNow,
20
20
  // draw in paintFrame -- so fifty agents do not become fifty `if`s in the renderer.
21
21
  import { AGENTS, isAgent, rng, lensFlare, saucerAbove } from './agents.js';
22
+ import { drawLivingSky } from './livingsky.js';
22
23
 
23
24
  const STATE = new WeakMap();
24
25
 
@@ -3132,7 +3133,14 @@ const NO_CANVAS = {};
3132
3133
  // line, so turning the stars off through it RESTYLED THE WHOLE BOARD -- which is not what a switch
3133
3134
  // labelled "Star field" should do. `stars` gates the sky alone; where it is absent the old
3134
3135
  // meaning stands, so every caller that never heard of it behaves exactly as before.
3135
- const starsOn = (o) => !!(o.stars ?? o.space);
3136
+ // THE LIVING SKY IS NOT THE STAR FIELD (operator, 2026-09-16: "The sky is not drawing for me ...
3137
+ // The background is all black for me"). With the living sky chosen and the Star field switch off,
3138
+ // this gate turned off the day as well: a blue afternoon and a moonrise were being hidden by a
3139
+ // switch about stars. A board that asks for the living sky draws it whatever that switch says;
3140
+ // `stars` still decides the star field, which the living sky brings out at night by itself.
3141
+ // the Earth sky (settings.js calls it 'earth'; 'living' was its name for a day and still answers)
3142
+ const earthSky = (o) => o.skyType === 'earth' || o.skyType === 'living';
3143
+ const starsOn = (o) => earthSky(o) || !!(o.stars ?? o.space);
3136
3144
  // THE GALAXY (operator, 2026-09-12: "I want all the starts slowly rotating to form a spiral
3137
3145
  // galaxy in the background ... Make it a toggle").
3138
3146
  //
@@ -3688,7 +3696,12 @@ function paintFrame(ctx, geom, frame, opts, view, gridN, blockRows, gridH = grid
3688
3696
  ctx.clearRect(0, 0, pw, ph);
3689
3697
  ctx.fillStyle = opts.background;
3690
3698
  ctx.fillRect(0, 0, pw, ph);
3691
- if (starsOn(opts)) drawStars(ctx, pw, ph, dpr || 1, view.now ?? 0, opts);
3699
+ // THE SKY: the star field, or the living sky (livingsky.js: a real day from the clock, with the
3700
+ // star field coming out at night through the same drawStars)
3701
+ if (starsOn(opts)) {
3702
+ if (earthSky(opts)) drawLivingSky(ctx, pw, ph, dpr || 1, view.now ?? 0, opts, { softStops, drawStars: (o) => drawStars(ctx, pw, ph, dpr || 1, view.now ?? 0, o) });
3703
+ else drawStars(ctx, pw, ph, dpr || 1, view.now ?? 0, opts);
3704
+ }
3692
3705
  // with nothing on the board -- every block in the air between two layouts (a viewer-mode switch)
3693
3706
  // -- the oblique board still draws itself: its transform is a constant, not fitted to the blocks
3694
3707
  if (!frame.bounds && !opts.oblique) return;
@@ -3757,6 +3770,13 @@ function paintFrame(ctx, geom, frame, opts, view, gridN, blockRows, gridH = grid
3757
3770
  const agentDraw = view.fx?.kind ? AGENTS[view.fx.kind]?.draw : null;
3758
3771
  if (agentDraw) agentDraw(ctx, view, ctx.lineWidth, { drawCycles, drawBall, project });
3759
3772
  else { drawCycles(ctx, view, ctx.lineWidth); drawBall(ctx, view, ctx.lineWidth); drawFireworks(ctx, view, ctx.lineWidth); drawSupernova(ctx, view, ctx.lineWidth); drawBlackHole(ctx, view, ctx.lineWidth); drawScanCurtain(ctx, view, ctx.lineWidth); }
3773
+ // A CALLER'S OWN LAYER, over the board and in the board's own projection (operator, 2026-09-16:
3774
+ // "We have fucking firework and nebula effects and you're doing shitty block and sprite
3775
+ // explosions?"). The effects above are the renderer's, chosen by cadence and seed; a game needs
3776
+ // to draw ITS events -- a shell landing here, at this instant -- with the same primitives. The
3777
+ // hook hands over the projector and softStops, so an explosion is built the way the fireworks
3778
+ // are rather than out of tiles, and lands exactly where the cell it destroyed was.
3779
+ if (typeof opts.overlay === 'function') opts.overlay(ctx, view, { project, softStops, lw: ctx.lineWidth });
3760
3780
  ctx.setTransform(1, 0, 0, 1, 0, 0);
3761
3781
  }
3762
3782
 
@@ -4104,6 +4124,7 @@ export function render3d(canvas, cells, options = {}) {
4104
4124
  opts.starDensity, opts.starBrightness, opts.galaxy === true, opts.galaxyAt,
4105
4125
  opts.nebulae !== false, opts.galaxies !== false, opts.dust !== false, opts.clusters !== false,
4106
4126
  opts.starColours !== false, opts.starGlints !== false,
4127
+ opts.skyType, opts.skyClock, opts.skyHour, opts.skyWeather, opts.skyCover, opts.skyLat, opts.skyRays !== false, opts.skyRainbow === true, opts.skyShooting !== false, opts.skyHorizon, opts.skyMoon,
4107
4128
  opts.neon === true, opts.sheen === true, opts.sheenStyle, opts.overheadLight === true, opts.light,
4108
4129
  opts.neonSource, opts.neonColour, opts.neonBrightness, opts.wireWidth,
4109
4130
  opts.transition ? `${opts.transition.rise}/${opts.transition.travel}/${opts.transition.drop}` : 'default'].join('|');
@@ -4212,6 +4233,8 @@ export function render3d(canvas, cells, options = {}) {
4212
4233
  neon: opts.neon === true, sheen: opts.sheen === true, sheenStyle: opts.sheenStyle,
4213
4234
  overheadLight: opts.overheadLight === true, // the lamp straight above (Tetrust)
4214
4235
  light: opts.light, // or wherever settings.js space.light puts it
4236
+ lightHeight: opts.lightHeight,
4237
+ lightGain: opts.lightGain, topLight: opts.topLight, // how hard the lamp is, and how bright the tops sit under it
4215
4238
  neonSource: opts.neonSource, neonColour: opts.neonColour, neonBrightness: opts.neonBrightness,
4216
4239
  wireWidth: opts.wireWidth,
4217
4240
  // the paint order's memory across frames (blockscene3d obliqueOrder): a tangle keeps the
@@ -19,8 +19,14 @@
19
19
 
20
20
  const X = { key: null, data: null, error: null, loading: false, shown: null, bound: false };
21
21
 
22
+ // A malformed escape in a pasted link (`#explorer/tx/%E0`) made decodeURIComponent throw inside the
23
+ // render, and the explorer stopped drawing for that tab (audit 2026-09-16, I1). Such a link is a
24
+ // route to the explorer's home, not an exception.
25
+ const decodePart = (x) => { try { return decodeURIComponent(x); } catch { return null; } };
22
26
  export function parseRoute(r) {
23
- const [kind, id, page] = String(r ?? '').split('/').filter((x) => x !== '').map((x) => decodeURIComponent(x));
27
+ const parts = String(r ?? '').split('/').filter((x) => x !== '').map(decodePart);
28
+ if (parts.includes(null)) return { kind: 'home' };
29
+ const [kind, id, page] = parts;
24
30
  if ((kind === 'block' || kind === 'address') && id) return { kind, id, page: Math.max(0, Math.floor(Number(page) || 0)) };
25
31
  if (kind === 'tx' && id) return { kind, id };
26
32
  return { kind: 'home' };