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.
- package/CHANGELOG.md +108 -0
- package/README.md +13 -11
- package/SECURITY.md +2 -2
- package/docs/API.md +1 -1
- package/docs/ARCHITECTURE.md +36 -5
- package/docs/CONFIGURATION.md +6 -4
- package/docs/DEFECTS.md +4 -1
- package/docs/GETTING-STARTED.md +14 -7
- package/docs/INSTALL.md +7 -4
- package/docs/PLAN-SCORCHED-YARD.md +456 -0
- package/docs/PLAN-SKIES.md +142 -0
- package/docs/SECURITY-AUDIT-2026-09-16.md +647 -0
- package/docs/SECURITY.md +26 -7
- package/docs/TROUBLESHOOTING.md +10 -5
- package/docs/USER-GUIDE.md +239 -9
- package/package.json +4 -2
- package/public/css/app.css +87 -0
- package/public/index.html +58 -6
- package/public/js/app.js +60 -19
- package/public/js/blockanoid.js +15 -7
- package/public/js/blockout.js +15 -7
- package/public/js/blockscene3d.js +51 -11
- package/public/js/depthchart.js +1 -1
- package/public/js/details3d.js +25 -2
- package/public/js/explorer.js +7 -1
- package/public/js/livingsky.js +494 -0
- package/public/js/login.js +3 -2
- package/public/js/mining.js +4 -4
- package/public/js/panels.js +27 -18
- package/public/js/safenext.js +14 -0
- package/public/js/scorched.js +1051 -0
- package/public/js/scorchedai.js +227 -0
- package/public/js/scorchedair.js +286 -0
- package/public/js/scorchedfx.js +376 -0
- package/public/js/scorchedshop.js +105 -0
- package/public/js/scorchedwind.js +69 -0
- package/public/js/scorchedyard.js +1338 -0
- package/public/js/settings.js +266 -80
- package/public/js/tetrust.js +15 -6
- package/public/js/tetsound.js +35 -5
- package/scripts/check.js +46 -0
- package/scripts/index-build.js +9 -2
- package/scripts/pool-map.js +152 -36
- package/scripts/setup.js +108 -10
- package/scripts/shots.mjs +21 -0
- package/scripts/smoke.sh +6 -5
- package/scripts/ui.js +4 -2
- package/server/auth/sessions.js +33 -13
- package/server/chain/blockfile.js +64 -5
- package/server/chain/index/build.js +432 -56
- package/server/chain/index/heights.js +29 -3
- package/server/chain/index/live.js +13 -7
- package/server/chain/index/rows.js +6 -1
- package/server/chain/index/store.js +28 -5
- package/server/chain/index/worker.js +23 -11
- package/server/collect/logparse.js +65 -18
- package/server/collect/markets.js +76 -7
- package/server/collect/mining.js +32 -0
- package/server/collect/monitor.js +24 -11
- package/server/collect/network.js +19 -9
- package/server/config.js +7 -0
- package/server/http/api.js +70 -13
- package/server/http/server.js +22 -5
- package/server/http/sse.js +53 -7
- package/server/main.js +13 -3
- package/server/rpc/allowlist.js +26 -0
- package/server/rpc/client.js +30 -2
- package/server/store/audit.js +6 -1
- package/server/store/history.js +19 -3
- package/server/store/ledger.js +15 -4
- package/systemd/blockyard.service +34 -3
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
// THE LIVING SKY (operator, 2026-09-16: "suggest a new sky background type we can add to
|
|
2
|
+
// blockyard. I would like for it to be something realistic with blue skies, clouds, the sun,
|
|
3
|
+
// change of day, night with the moon coming out, just something fucking beautiful"; then "do it").
|
|
4
|
+
//
|
|
5
|
+
// A second sky type beside the star field: a real day, drawn from the machine's clock, over every
|
|
6
|
+
// board that has a sky. The sun climbs and sets, the dome goes through night, dawn, day and dusk
|
|
7
|
+
// by the sun's altitude, clouds drift lit from the sun's side, the moon rises at its real phase
|
|
8
|
+
// and the star field (the one details3d already draws) comes out as the sun goes down.
|
|
9
|
+
//
|
|
10
|
+
// It obeys the canvas rules the rest of the renderer lives by (test/viewer-canvas-rules.test.js):
|
|
11
|
+
// no gradients, no globalAlpha, no composite modes, no shadowBlur. The dome is ONE huge soft disc
|
|
12
|
+
// centred far below the horizon, so its rings become gently curved horizontal bands -- which is
|
|
13
|
+
// how a sky dome looks -- and it is cached on an offscreen canvas that is repainted only when the
|
|
14
|
+
// colours move. Clouds are clusters of soft discs, the way the supernova's gas is built. The sun
|
|
15
|
+
// and the moon are discs with halos. Rain is lines. Nothing here needs a dependency.
|
|
16
|
+
//
|
|
17
|
+
// `drawLivingSky` takes the two helpers it needs from details3d as arguments (softStops, and a
|
|
18
|
+
// drawStars bound to the canvas) rather than importing them, so the two files do not import each
|
|
19
|
+
// other. Everything else here is pure, and the tests hold the astronomy to known dates.
|
|
20
|
+
|
|
21
|
+
// ------------------------------------------------------------------- the clock
|
|
22
|
+
/**
|
|
23
|
+
* The hour the sky is drawn at, 0..24, by the clock setting: 'real' (this machine's local time),
|
|
24
|
+
* 'cycle' (a whole day every 24 minutes), 'fixed' (an hour to admire). The date is today's for
|
|
25
|
+
* the moon's phase whatever the clock says.
|
|
26
|
+
*/
|
|
27
|
+
export function skyTime(opts = {}, nowMs = Date.now()) {
|
|
28
|
+
const d = new Date(nowMs);
|
|
29
|
+
const mode = opts.skyClock ?? 'real';
|
|
30
|
+
let hour;
|
|
31
|
+
if (mode === 'cycle') hour = ((nowMs / 60000) % 24 + 24) % 24; // a minute an hour
|
|
32
|
+
else if (mode === 'fixed') hour = Math.max(0, Math.min(24, Number(opts.skyHour) || 0)) % 24;
|
|
33
|
+
else hour = d.getHours() + d.getMinutes() / 60 + d.getSeconds() / 3600;
|
|
34
|
+
const start = new Date(d.getFullYear(), 0, 0);
|
|
35
|
+
const dayOfYear = Math.floor((d - start) / 86400000);
|
|
36
|
+
// under the cycle a day is 24 minutes, and the moon keeps its calendar in THOSE days: a lunar
|
|
37
|
+
// month goes by in about twelve hours of the clock on the wall, so the phases can be watched
|
|
38
|
+
const cycleDays = mode === 'cycle' ? Math.floor(nowMs / (24 * 60000)) : 0;
|
|
39
|
+
return { hour, dayOfYear, date: d, mode, cycleDays };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ------------------------------------------------------------------- the sun
|
|
43
|
+
const RAD = Math.PI / 180;
|
|
44
|
+
/**
|
|
45
|
+
* Where the sun is: altitude in degrees and its progress across the sky, 0 at rising and 1 at
|
|
46
|
+
* setting. With no latitude, a symmetric day from six to six that reaches 62° at noon. With one,
|
|
47
|
+
* the standard formula: declination from the day of the year, the hour angle from the clock
|
|
48
|
+
* (the machine's local hour is taken as solar time), altitude from both.
|
|
49
|
+
*/
|
|
50
|
+
export function sunPosition(hour, dayOfYear = 172, lat = null) {
|
|
51
|
+
if (lat == null || !Number.isFinite(lat)) {
|
|
52
|
+
const alt = 62 * Math.sin(((hour - 6) / 12) * Math.PI);
|
|
53
|
+
return { alt, frac: (hour - 6) / 12, rise: 6, set: 18 };
|
|
54
|
+
}
|
|
55
|
+
const phi = Math.max(-89, Math.min(89, lat)) * RAD;
|
|
56
|
+
const decl = 23.44 * RAD * Math.sin(((284 + dayOfYear) / 365) * Math.PI * 2);
|
|
57
|
+
const H = (hour - 12) * 15 * RAD;
|
|
58
|
+
const alt = Math.asin(Math.sin(phi) * Math.sin(decl) + Math.cos(phi) * Math.cos(decl) * Math.cos(H)) / RAD;
|
|
59
|
+
const c = -Math.tan(phi) * Math.tan(decl);
|
|
60
|
+
const half = c <= -1 ? 12 : c >= 1 ? 0 : Math.acos(c) / RAD / 15; // hours from noon to the horizon
|
|
61
|
+
const rise = 12 - half, set = 12 + half;
|
|
62
|
+
const frac = half > 0 ? (hour - rise) / (2 * half) : (hour - 12) / 12 + 0.5;
|
|
63
|
+
return { alt, frac, rise, set };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The sun's place on a canvas: left to right over the day, up by altitude; below the horizon at
|
|
68
|
+
* night. `horizon` is where the horizon IS, as a share of the panel's height from the top -- 1 is
|
|
69
|
+
* the bottom edge, which is right for a board with nothing in front of the sky; a board whose land
|
|
70
|
+
* fills the lower third passes about 0.6, so the sun sets behind the hills and not under them
|
|
71
|
+
* (operator, 2026-09-16: "so sun and moon any more?" -- at dusk the disc had dropped behind the
|
|
72
|
+
* terrain long before the sky called it set).
|
|
73
|
+
*/
|
|
74
|
+
export function sunScreen(sun, pw, ph, maxAlt = 62, horizon = 1) {
|
|
75
|
+
const H = ph * Math.max(0.2, Math.min(1, horizon));
|
|
76
|
+
const x = pw * (0.06 + 0.88 * Math.max(-0.25, Math.min(1.25, sun.frac)));
|
|
77
|
+
const k = Math.max(-0.6, Math.min(1, sun.alt / maxAlt));
|
|
78
|
+
const y = H - (0.10 + 0.80 * k) * H;
|
|
79
|
+
return { x, y };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ------------------------------------------------------------------- the moon
|
|
83
|
+
const SYNODIC = 29.530588853; // days
|
|
84
|
+
const NEW_MOON = Date.UTC(2000, 0, 6, 18, 14); // a known new moon
|
|
85
|
+
/** The moon's phase for a date: 0 new, 0.5 full, 1 new again; the lit fraction; waxing or not. */
|
|
86
|
+
export function moonPhase(date = new Date()) {
|
|
87
|
+
const days = (date.getTime() - NEW_MOON) / 86400000;
|
|
88
|
+
const p = ((days / SYNODIC) % 1 + 1) % 1;
|
|
89
|
+
return { p, lit: (1 - Math.cos(p * Math.PI * 2)) / 2, waxing: p < 0.5 };
|
|
90
|
+
}
|
|
91
|
+
/** The moon rides the sun's track a share of a day behind: opposite the sun when full, beside it when new. */
|
|
92
|
+
export function moonPosition(hour, phase, dayOfYear = 172, lat = null) {
|
|
93
|
+
const h = ((hour - 24 * phase.p) % 24 + 24) % 24;
|
|
94
|
+
return sunPosition(h, dayOfYear, lat);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ------------------------------------------------------------------- the dome's colours
|
|
98
|
+
// keyframes by the sun's altitude: [alt, zenith, horizon, glow, glow strength]
|
|
99
|
+
export const KEYFRAMES = Object.freeze([
|
|
100
|
+
[-90, [3, 5, 16], [8, 11, 28], [20, 24, 60], 0],
|
|
101
|
+
[-18, [3, 5, 16], [8, 11, 28], [20, 24, 60], 0],
|
|
102
|
+
[-12, [6, 9, 26], [16, 20, 48], [50, 40, 80], 0.15],
|
|
103
|
+
[-6, [12, 20, 54], [56, 46, 84], [150, 80, 90], 0.45],
|
|
104
|
+
[-3, [26, 46, 108], [150, 90, 90], [240, 130, 70], 0.8],
|
|
105
|
+
[0, [52, 92, 168], [232, 140, 84], [255, 160, 70], 1],
|
|
106
|
+
[4, [82, 132, 206], [246, 186, 128], [255, 200, 120], 0.7],
|
|
107
|
+
[12, [66, 134, 224], [196, 220, 242], [255, 235, 200], 0.25],
|
|
108
|
+
[30, [44, 112, 222], [200, 224, 244], [255, 250, 235], 0.08],
|
|
109
|
+
[90, [30, 92, 210], [206, 228, 246], [255, 255, 245], 0],
|
|
110
|
+
]);
|
|
111
|
+
const lerp = (a, b, k) => a + (b - a) * k;
|
|
112
|
+
const mix3 = (a, b, k) => [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)];
|
|
113
|
+
const rgb = (c, a = 1) => `rgba(${Math.round(c[0])},${Math.round(c[1])},${Math.round(c[2])},${a})`;
|
|
114
|
+
|
|
115
|
+
/** The dome's colours at a sun altitude: zenith, horizon, the glow at the sun's side, and how strong. */
|
|
116
|
+
export function skyColours(alt) {
|
|
117
|
+
const a = Math.max(-90, Math.min(90, alt));
|
|
118
|
+
let i = 0;
|
|
119
|
+
while (i < KEYFRAMES.length - 2 && KEYFRAMES[i + 1][0] <= a) i++;
|
|
120
|
+
const [a0, z0, h0, g0, s0] = KEYFRAMES[i], [a1, z1, h1, g1, s1] = KEYFRAMES[i + 1];
|
|
121
|
+
const k = a1 > a0 ? Math.max(0, Math.min(1, (a - a0) / (a1 - a0))) : 0;
|
|
122
|
+
return { zenith: mix3(z0, z1, k), horizon: mix3(h0, h1, k), glow: mix3(g0, g1, k), glowStrength: lerp(s0, s1, k), dayness: Math.max(0, Math.min(1, (a + 6) / 18)) };
|
|
123
|
+
}
|
|
124
|
+
/** How much of the star field shows: all of it below -12°, none above -1°. */
|
|
125
|
+
export const starVisibility = (alt) => Math.max(0, Math.min(1, (-1 - alt) / 11));
|
|
126
|
+
|
|
127
|
+
// ------------------------------------------------------------------- clouds
|
|
128
|
+
const hash = (n) => { const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); };
|
|
129
|
+
/**
|
|
130
|
+
* A seeded field of cumulus: each a cluster of puffs at a height band, with its own drift. The
|
|
131
|
+
* same seed always gives the same sky, so a screenshot and a test agree.
|
|
132
|
+
*/
|
|
133
|
+
export function cloudField(seed = 3, count = 8) {
|
|
134
|
+
const out = [];
|
|
135
|
+
for (let i = 0; i < count; i++) {
|
|
136
|
+
const h = (k) => hash(seed * 97 + i * 13 + k);
|
|
137
|
+
const puffs = [];
|
|
138
|
+
const n = 4 + Math.floor(h(1) * 5);
|
|
139
|
+
for (let j = 0; j < n; j++) puffs.push({ dx: (j / (n - 1) - 0.5) * 1.6 + (h(10 + j) - 0.5) * 0.3, dy: -(0.35 * Math.sin((j / (n - 1)) * Math.PI)) - h(20 + j) * 0.15, r: 0.32 + h(30 + j) * 0.3 });
|
|
140
|
+
out.push({ u: h(2), v: 0.10 + h(3) * 0.42, s: 0.55 + h(4) * 0.9, speed: 0.004 + h(5) * 0.006, puffs });
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
/** Long thin cirrus streaks, high up. */
|
|
145
|
+
export function cirrusField(seed = 5, count = 4) {
|
|
146
|
+
const out = [];
|
|
147
|
+
for (let i = 0; i < count; i++) {
|
|
148
|
+
const h = (k) => hash(seed * 131 + i * 17 + k);
|
|
149
|
+
out.push({ u: h(1), v: 0.05 + h(2) * 0.2, len: 0.18 + h(3) * 0.25, tilt: (h(4) - 0.5) * 0.12, speed: 0.0015 + h(5) * 0.002, thick: 0.006 + h(6) * 0.008 });
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export const WEATHER = Object.freeze({
|
|
155
|
+
clear: { cover: 0.12, clouds: 3, grey: 0, rain: false, storm: false },
|
|
156
|
+
scattered: { cover: 0.4, clouds: 8, grey: 0.08, rain: false, storm: false },
|
|
157
|
+
overcast: { cover: 0.85, clouds: 16, grey: 0.35, rain: false, storm: false },
|
|
158
|
+
storm: { cover: 1, clouds: 18, grey: 0.6, rain: true, storm: true },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ------------------------------------------------------------------- drawing
|
|
162
|
+
const CACHE = new WeakMap(); // canvas -> { dome, key, w, h }
|
|
163
|
+
|
|
164
|
+
function domeLayer(ctx, pw, ph, key, paint) {
|
|
165
|
+
const cv = ctx.canvas;
|
|
166
|
+
if (!cv || typeof ctx.drawImage !== 'function' || typeof document === 'undefined' || !document.createElement) return false;
|
|
167
|
+
let c = CACHE.get(cv);
|
|
168
|
+
if (!c || c.w !== pw || c.h !== ph) {
|
|
169
|
+
const canvas = document.createElement('canvas'); canvas.width = pw; canvas.height = ph;
|
|
170
|
+
const c2 = canvas.getContext?.('2d');
|
|
171
|
+
if (!c2) return false;
|
|
172
|
+
c = { canvas, ctx: c2, key: null, w: pw, h: ph };
|
|
173
|
+
CACHE.set(cv, c);
|
|
174
|
+
}
|
|
175
|
+
if (c.key !== key) { c.ctx.setTransform(1, 0, 0, 1, 0, 0); c.ctx.clearRect(0, 0, pw, ph); paint(c.ctx); c.key = key; }
|
|
176
|
+
ctx.drawImage(c.canvas, 0, 0);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function paintDome(ctx, pw, ph, cols) {
|
|
181
|
+
// OPAQUE DISCS FROM THE OUTSIDE IN. The dome is one circle centred well below the bottom edge,
|
|
182
|
+
// so its rings curve gently like a sky's; each disc is painted whole in the colour of its own
|
|
183
|
+
// height, largest (the zenith) first, so what is left showing between two radii is the colour
|
|
184
|
+
// of the smaller -- the horizon's at the bottom, the zenith's at the top. Not softStops: that
|
|
185
|
+
// helper composes translucent discs for a bright centre fading OUTWARD, and a sky is the other
|
|
186
|
+
// way round (the first cut came out one flat zenith blue for exactly that reason).
|
|
187
|
+
const cy = ph * 2.4, R = cy;
|
|
188
|
+
const tH = (cy - ph) / R; // the horizon's radius, as a share of R
|
|
189
|
+
// the zenith colour under everything: the corners above the dome's crown are the top of the
|
|
190
|
+
// sky, and a first cut that left them unpainted showed the board's black as a dark arc
|
|
191
|
+
ctx.fillStyle = rgb(cols.zenith);
|
|
192
|
+
ctx.fillRect(0, 0, pw, ph);
|
|
193
|
+
const N = 140;
|
|
194
|
+
for (let i = 0; i <= N; i++) {
|
|
195
|
+
const t = 1 - (i / N) * (1 - tH) - (i === N ? 0.002 : 0);
|
|
196
|
+
const k = Math.pow(Math.max(0, (t - tH) / (1 - tH)), 0.75); // 1 at the zenith, 0 at the horizon; the haze holds the low sky
|
|
197
|
+
ctx.fillStyle = rgb(mix3(cols.horizon, cols.zenith, k));
|
|
198
|
+
ctx.beginPath(); ctx.arc(pw / 2, cy, Math.max(1, R * t), 0, Math.PI * 2); ctx.fill();
|
|
199
|
+
}
|
|
200
|
+
// the ground line: the horizon colour fills what the smallest disc leaves at the very bottom
|
|
201
|
+
ctx.fillStyle = rgb(cols.horizon);
|
|
202
|
+
ctx.fillRect(0, ph - 2, pw, 2);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function paintTwilight(ctx, pw, ph, sunX, cols, softStops) {
|
|
206
|
+
if (cols.glowStrength <= 0.02) return;
|
|
207
|
+
const R = pw * 0.62;
|
|
208
|
+
softStops(ctx, sunX, ph + ph * 0.12, R, [
|
|
209
|
+
[0, rgb(cols.glow, 0.62 * cols.glowStrength)], [0.35, rgb(cols.glow, 0.32 * cols.glowStrength)], [1, rgb(cols.glow, 0)],
|
|
210
|
+
], 90);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The sun in two passes: its GLOW is painted before the clouds, so they are lit from behind, and
|
|
214
|
+
// its DISC after them, so it is never lost behind one (operator, 2026-09-16: at noon it was a small
|
|
215
|
+
// pale spot under a cloud). And it is half again as big as the first cut: a sun a fiftieth of the
|
|
216
|
+
// panel high is a star, not a sun.
|
|
217
|
+
function paintSun(ctx, pw, ph, s, sun, cols, softStops, pass = 'both') {
|
|
218
|
+
if (sun.alt < -8) return;
|
|
219
|
+
const k = Math.max(0, Math.min(1, sun.alt / 30));
|
|
220
|
+
// THE SUN IS NOT WHITE (operator, 2026-09-16: "The Sun is white?!"). A white disc is what a
|
|
221
|
+
// camera makes of it; the eye, and every painting of a sky, sees gold. Deep orange on the
|
|
222
|
+
// horizon, warm gold overhead -- never past a warm cream, even at noon.
|
|
223
|
+
const c = mix3([255, 120, 60], [255, 212, 116], k);
|
|
224
|
+
const r = ph * 0.032 * (1 + 0.7 * (1 - k));
|
|
225
|
+
const glow = 0.3 + 0.35 * (1 - k);
|
|
226
|
+
const vis = sun.alt < 0 ? Math.max(0, 1 + sun.alt / 8) : 1;
|
|
227
|
+
if (pass !== 'disc') softStops(ctx, s.x, s.y, ph * 0.5, [[0, rgb(c, glow * 0.7 * vis)], [0.08, rgb(c, glow * 0.45 * vis)], [0.3, rgb(c, glow * 0.12 * vis)], [1, rgb(c, 0)]], 90);
|
|
228
|
+
if (pass === 'glow') return;
|
|
229
|
+
// THE HALO AND THE EDGE (operator, 2026-09-16, with a screenshot: "still seeing some banding
|
|
230
|
+
// here. Can we add a glow effect around the sun?"). The disc's soft edge was twelve rings, which
|
|
231
|
+
// on a big panel is a step of several pixels each -- the rings in the picture. Ring counts are
|
|
232
|
+
// left to softStops now, which lays one per pixel or so. And a halo in front of the clouds: a
|
|
233
|
+
// wide, faint corona and a tighter bright one, so the sun sits in its own light rather than
|
|
234
|
+
// being a coin on the sky.
|
|
235
|
+
softStops(ctx, s.x, s.y, r * 4.2, [[0, rgb(c, 0.5 * vis)], [0.22, rgb(c, 0.28 * vis)], [0.55, rgb(c, 0.09 * vis)], [1, rgb(c, 0)]]);
|
|
236
|
+
softStops(ctx, s.x, s.y, r * 2.1, [[0, rgb([255, 236, 190], 0.6 * vis)], [0.5, rgb(c, 0.3 * vis)], [1, rgb(c, 0)]]);
|
|
237
|
+
// the disc, with a soft edge
|
|
238
|
+
softStops(ctx, s.x, s.y, r * 1.35, [[0, rgb(c, vis)], [0.7, rgb(c, vis)], [1, rgb(c, 0)]]);
|
|
239
|
+
softStops(ctx, s.x, s.y, r * 0.9, [[0, rgb([255, 246, 214], 0.92 * k * vis)], [1, rgb([255, 246, 214], 0)]]);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function paintMoon(ctx, pw, ph, m, moon, phase, night, cols, softStops) {
|
|
243
|
+
if (moon.alt < -3 || phase.lit < 0.03) return;
|
|
244
|
+
const r = ph * 0.046;
|
|
245
|
+
// faint by day (a daytime moon is there, but it is not a lamp), and a thin crescent by day is invisible
|
|
246
|
+
const show = Math.max(0, Math.min(1, (moon.alt + 3) / 6)) * (0.12 + 0.88 * night);
|
|
247
|
+
if (show <= 0.02 || (night < 0.3 && phase.lit < 0.25)) return;
|
|
248
|
+
// THE HALO (operator, 2026-09-16: "Can we give the moon a glow effect as well?"): the same two
|
|
249
|
+
// layers the sun has, in moonlight silver, and stronger the fuller the moon -- a full moon lights
|
|
250
|
+
// the sky round it; a crescent barely does
|
|
251
|
+
if (night > 0.3) {
|
|
252
|
+
const g = night * show * (0.35 + 0.65 * Math.max(0, Math.min(1, phase.lit)));
|
|
253
|
+
const silver = [222, 230, 248];
|
|
254
|
+
softStops(ctx, m.x, m.y, r * 7, [[0, rgb(silver, 0.42 * g)], [0.2, rgb(silver, 0.22 * g)], [0.5, rgb(silver, 0.08 * g)], [1, rgb(silver, 0)]]);
|
|
255
|
+
softStops(ctx, m.x, m.y, r * 2.6, [[0, rgb([236, 240, 255], 0.6 * g)], [0.5, rgb(silver, 0.28 * g)], [1, rgb(silver, 0)]]);
|
|
256
|
+
}
|
|
257
|
+
// ONLY THE LIT PART IS PAINTED. The first cut drew a full disc and then a disc of sky colour over
|
|
258
|
+
// the dark side; once the moon moved in front of the clouds and the stars, that sky-coloured
|
|
259
|
+
// disc was a hole punched in them. The lit shape is a polygon: the outer half-circle on the lit
|
|
260
|
+
// side, and the terminator -- a half-ellipse whose width is the phase -- back along the other.
|
|
261
|
+
const L = Math.max(0.03, Math.min(1, phase.lit));
|
|
262
|
+
const side = phase.waxing ? 1 : -1; // waxing: lit on the right
|
|
263
|
+
const a = r * (2 * L - 1); // the terminator's half-width: negative bulges into the lit side
|
|
264
|
+
const N = 28;
|
|
265
|
+
ctx.fillStyle = rgb([236, 238, 244], show);
|
|
266
|
+
ctx.beginPath();
|
|
267
|
+
for (let i = 0; i <= N; i++) { // the limb, top to bottom on the lit side
|
|
268
|
+
const th = -Math.PI / 2 + (i / N) * Math.PI;
|
|
269
|
+
const px = m.x + side * Math.cos(th) * r, py = m.y + Math.sin(th) * r;
|
|
270
|
+
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
|
|
271
|
+
}
|
|
272
|
+
for (let i = N; i >= 0; i--) { // the terminator, bottom to top
|
|
273
|
+
const th = -Math.PI / 2 + (i / N) * Math.PI;
|
|
274
|
+
ctx.lineTo(m.x + side * Math.cos(th) * a, m.y + Math.sin(th) * r);
|
|
275
|
+
}
|
|
276
|
+
ctx.closePath();
|
|
277
|
+
ctx.fill();
|
|
278
|
+
// the seas, faint, only when enough of the face is lit for them to sit inside it
|
|
279
|
+
if (L > 0.6) {
|
|
280
|
+
ctx.fillStyle = rgb([196, 200, 212], show * 0.6);
|
|
281
|
+
for (const [dx, dy, rr] of [[0.3 * side, -0.2, 0.28], [0.45 * side, 0.2, 0.16], [0.2 * side, 0.45, 0.14]]) { ctx.beginPath(); ctx.arc(m.x + dx * r, m.y + dy * r, rr * r, 0, Math.PI * 2); ctx.fill(); }
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function paintClouds(ctx, pw, ph, now, opts, sun, s, cols, weather, softStops) {
|
|
286
|
+
const cover = Number.isFinite(opts.skyCover) ? Math.max(0, Math.min(1, opts.skyCover)) : weather.cover;
|
|
287
|
+
if (cover <= 0.01) return;
|
|
288
|
+
const count = Math.max(1, Math.round(2 + cover * 16));
|
|
289
|
+
const clouds = cloudField(3, count);
|
|
290
|
+
const grey = weather.grey + Math.max(0, cover - 0.6) * 0.5;
|
|
291
|
+
const day = cols.dayness;
|
|
292
|
+
const sunk = Math.max(0, Math.min(1, sun.alt / 25));
|
|
293
|
+
// lit from the sun's side by day, warm at the horizon, silver by moonlight at night
|
|
294
|
+
const litBase = mix3(mix3([70, 80, 110], [250, 252, 255], day), [255, 205, 150], (1 - sunk) * day * 0.7);
|
|
295
|
+
const lit = mix3(litBase, [120, 125, 140], grey);
|
|
296
|
+
const shade = mix3(lit, mix3([20, 26, 48], [110, 120, 140], day), 0.45 + grey * 0.3);
|
|
297
|
+
const t = now / 1000;
|
|
298
|
+
const wind = (opts.skyWind ?? 1);
|
|
299
|
+
for (let i = 0; i < clouds.length; i++) {
|
|
300
|
+
const c = clouds[i];
|
|
301
|
+
const u = ((c.u + t * c.speed * wind * 0.5) % 1.3 + 1.3) % 1.3 - 0.15;
|
|
302
|
+
const cx = u * pw, cy = c.v * ph;
|
|
303
|
+
const S = ph * 0.11 * c.s;
|
|
304
|
+
const a = 0.55 + 0.4 * cover;
|
|
305
|
+
const toSun = s.x > cx ? 1 : -1;
|
|
306
|
+
for (const p of c.puffs) {
|
|
307
|
+
const px = cx + p.dx * S, py = cy + p.dy * S, r = p.r * S;
|
|
308
|
+
softStops(ctx, px, py + r * 0.35, r * 1.15, [[0, rgb(shade, a * 0.9)], [0.6, rgb(shade, a * 0.6)], [1, rgb(shade, 0)]], 24);
|
|
309
|
+
softStops(ctx, px, py, r, [[0, rgb(lit, a)], [0.55, rgb(lit, a * 0.75)], [1, rgb(lit, 0)]], 24);
|
|
310
|
+
// the bright rim on the sun's side
|
|
311
|
+
if (day > 0.2) softStops(ctx, px + toSun * r * 0.25, py - r * 0.25, r * 0.55, [[0, rgb([255, 255, 255], a * 0.35 * day)], [1, rgb([255, 255, 255], 0)]], 16);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// cirrus, high and slow, by day -- AS GAS, NOT AS A CHAIN OF DISCS (operator, 2026-09-16: "The
|
|
315
|
+
// banding on that faint cloud strip looks awful. Can we replace with plasma gasses instead?").
|
|
316
|
+
// The first cut laid hard-edged flat discs fourteen pixels apart along each wisp, and where
|
|
317
|
+
// they overlapped the outlines stacked into a row of rings. Each wisp is now a scatter of
|
|
318
|
+
// soft-edged puffs -- softStops, the same way the cumulus is built -- jittered off the line,
|
|
319
|
+
// of varying size, each drawn twice with a small offset along the wisp so it reads stretched
|
|
320
|
+
// in the direction it drifts. No puff has an edge, so nothing bands.
|
|
321
|
+
if (day > 0.3 && !weather.storm) {
|
|
322
|
+
const wisps = cirrusField(5, 3 + Math.round(cover * 3));
|
|
323
|
+
const white = [255, 255, 255];
|
|
324
|
+
for (const w of wisps) {
|
|
325
|
+
const u = ((w.u + t * w.speed * wind) % 1.4 + 1.4) % 1.4 - 0.2;
|
|
326
|
+
const x0 = u * pw, y0 = w.v * ph;
|
|
327
|
+
const n = Math.max(6, Math.round(w.len * pw / 34));
|
|
328
|
+
const dx = w.len * pw / n, dy = w.tilt * ph / n;
|
|
329
|
+
for (let k = 0; k <= n; k++) {
|
|
330
|
+
const f = k / n;
|
|
331
|
+
const j = (q) => hash(w.u * 977 + k * 31 + q);
|
|
332
|
+
const x = x0 + f * w.len * pw + (j(1) - 0.5) * dx * 0.9;
|
|
333
|
+
const y = y0 + f * w.tilt * ph + Math.sin(f * Math.PI * 2 + w.u * 9) * ph * 0.006 + (j(2) - 0.5) * w.thick * ph * 2.5;
|
|
334
|
+
const r = w.thick * ph * (0.45 + Math.sin(f * Math.PI)) * (2.6 + 1.6 * j(3));
|
|
335
|
+
const a = 0.028 * day * (0.6 + 0.8 * j(4));
|
|
336
|
+
const stops = [[0, rgb(white, a)], [0.4, rgb(white, a * 0.55)], [1, rgb(white, 0)]];
|
|
337
|
+
softStops(ctx, x, y, r, stops, 12);
|
|
338
|
+
softStops(ctx, x + dx * 0.45, y + dy * 0.45, r * 0.85, stops, 12);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function paintRays(ctx, pw, ph, s, sun, cols) {
|
|
345
|
+
if (sun.alt < 1 || sun.alt > 28) return;
|
|
346
|
+
const a = 0.045 * (1 - sun.alt / 28) * cols.dayness;
|
|
347
|
+
if (a <= 0.004) return;
|
|
348
|
+
ctx.fillStyle = rgb(cols.glow, a);
|
|
349
|
+
for (let i = 0; i < 7; i++) {
|
|
350
|
+
const ang = -Math.PI / 2 + (i - 3) * 0.22 + Math.sin(i * 1.7) * 0.05;
|
|
351
|
+
const w = 0.05 + (i % 3) * 0.02;
|
|
352
|
+
const L = ph * 1.6;
|
|
353
|
+
ctx.beginPath();
|
|
354
|
+
ctx.moveTo(s.x, s.y);
|
|
355
|
+
ctx.lineTo(s.x + Math.cos(ang - w) * L, s.y - Math.sin(ang - w) * L);
|
|
356
|
+
ctx.lineTo(s.x + Math.cos(ang + w) * L, s.y - Math.sin(ang + w) * L);
|
|
357
|
+
ctx.closePath(); ctx.fill();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function paintRain(ctx, pw, ph, now, dpr) {
|
|
362
|
+
const n = Math.round((pw * ph) / 3500);
|
|
363
|
+
const fall = (now / 1000) * ph * 1.4;
|
|
364
|
+
ctx.strokeStyle = 'rgba(200,212,232,0.28)';
|
|
365
|
+
ctx.lineWidth = Math.max(1, dpr * 0.8);
|
|
366
|
+
ctx.beginPath();
|
|
367
|
+
for (let i = 0; i < n; i++) {
|
|
368
|
+
const x = ((hash(i * 7 + 1) * pw + (now / 1000) * 30 * (0.5 + hash(i * 3 + 9))) % (pw + 40)) - 20;
|
|
369
|
+
const y = ((hash(i * 11 + 2) * ph + fall * (0.7 + hash(i * 5 + 4) * 0.6)) % (ph + 30)) - 15;
|
|
370
|
+
ctx.moveTo(x, y); ctx.lineTo(x - 3, y + 14 * dpr);
|
|
371
|
+
}
|
|
372
|
+
ctx.stroke();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** A bolt: midpoint displacement from a cloud base to the ground, seeded, with a branch. */
|
|
376
|
+
export function boltPoints(seed, x0, y0, x1, y1, depth = 5) {
|
|
377
|
+
let pts = [{ x: x0, y: y0 }, { x: x1, y: y1 }];
|
|
378
|
+
let k = 0;
|
|
379
|
+
for (let d = 0; d < depth; d++) {
|
|
380
|
+
const next = [pts[0]];
|
|
381
|
+
for (let i = 1; i < pts.length; i++) {
|
|
382
|
+
const a = pts[i - 1], b = pts[i];
|
|
383
|
+
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
|
|
384
|
+
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
|
385
|
+
const off = (hash(seed * 31 + k++) - 0.5) * len * 0.5;
|
|
386
|
+
next.push({ x: mx + off, y: my + (hash(seed * 37 + k++) - 0.5) * len * 0.12 }, b);
|
|
387
|
+
}
|
|
388
|
+
pts = next;
|
|
389
|
+
}
|
|
390
|
+
return pts;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function paintLightning(ctx, pw, ph, now, dpr) {
|
|
394
|
+
const slot = Math.floor(now / 5000);
|
|
395
|
+
if (hash(slot * 3 + 0.5) > 0.35) return; // most slots are quiet
|
|
396
|
+
const at = slot * 5000 + hash(slot * 5 + 0.25) * 4000;
|
|
397
|
+
const age = now - at;
|
|
398
|
+
if (age < 0 || age > 220) return;
|
|
399
|
+
const k = 1 - age / 220;
|
|
400
|
+
ctx.fillStyle = `rgba(220,228,255,${(0.28 * k).toFixed(3)})`;
|
|
401
|
+
ctx.fillRect(0, 0, pw, ph);
|
|
402
|
+
const x0 = pw * (0.15 + hash(slot * 7 + 0.75) * 0.7);
|
|
403
|
+
const pts = boltPoints(slot, x0, ph * 0.2, x0 + (hash(slot * 9 + 0.1) - 0.5) * pw * 0.2, ph * 0.98);
|
|
404
|
+
for (const [w, a] of [[9, 0.18], [4, 0.45], [1.6, 0.95]]) {
|
|
405
|
+
ctx.strokeStyle = `rgba(236,240,255,${(a * k).toFixed(3)})`;
|
|
406
|
+
ctx.lineWidth = w * dpr;
|
|
407
|
+
ctx.beginPath();
|
|
408
|
+
pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
|
|
409
|
+
ctx.stroke();
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function paintRainbow(ctx, pw, ph, s, sun, dpr) {
|
|
414
|
+
if (sun.alt < 4 || sun.alt > 38) return;
|
|
415
|
+
const k = 1 - Math.abs(sun.alt - 15) / 25;
|
|
416
|
+
const a = 0.16 * Math.max(0, k);
|
|
417
|
+
if (a <= 0.01) return;
|
|
418
|
+
const cx = pw - s.x, cy = ph + ph * 0.55;
|
|
419
|
+
const R = ph * 1.05;
|
|
420
|
+
const bands = [[255, 70, 60], [255, 150, 50], [250, 230, 70], [90, 210, 90], [70, 150, 255], [90, 80, 220], [150, 70, 200]];
|
|
421
|
+
ctx.lineWidth = Math.max(2, ph * 0.011);
|
|
422
|
+
bands.forEach((c, i) => {
|
|
423
|
+
ctx.strokeStyle = rgb(c, a);
|
|
424
|
+
ctx.beginPath(); ctx.arc(cx, cy, R - i * ph * 0.011, Math.PI, Math.PI * 2); ctx.stroke();
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function paintShootingStar(ctx, pw, ph, now, night, dpr) {
|
|
429
|
+
if (night < 0.5) return;
|
|
430
|
+
const slot = Math.floor(now / 14000);
|
|
431
|
+
if (hash(slot * 13 + 0.35) > 0.5) return;
|
|
432
|
+
const at = slot * 14000 + hash(slot * 17 + 0.65) * 12000;
|
|
433
|
+
const age = now - at;
|
|
434
|
+
if (age < 0 || age > 800) return;
|
|
435
|
+
const f = age / 800;
|
|
436
|
+
const x0 = pw * (0.1 + hash(slot * 19 + 0.2) * 0.8), y0 = ph * (0.05 + hash(slot * 23 + 0.4) * 0.35);
|
|
437
|
+
const dx = pw * 0.18 * (hash(slot * 29 + 0.8) > 0.5 ? 1 : -1), dy = ph * 0.12;
|
|
438
|
+
const hx = x0 + dx * f, hy = y0 + dy * f;
|
|
439
|
+
const tail = Math.min(1, f * 3) * 0.7;
|
|
440
|
+
const a = (1 - f) * night;
|
|
441
|
+
ctx.strokeStyle = `rgba(255,255,255,${(a * 0.8).toFixed(3)})`;
|
|
442
|
+
ctx.lineWidth = 1.2 * dpr;
|
|
443
|
+
ctx.beginPath(); ctx.moveTo(hx - dx * tail, hy - dy * tail); ctx.lineTo(hx, hy); ctx.stroke();
|
|
444
|
+
ctx.fillStyle = `rgba(255,255,255,${a.toFixed(3)})`;
|
|
445
|
+
ctx.beginPath(); ctx.arc(hx, hy, 1.4 * dpr, 0, Math.PI * 2); ctx.fill();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The whole sky at `now` (an animation clock, ms) onto `ctx`, `pw` x `ph` device pixels.
|
|
450
|
+
* `helpers.softStops(ctx, x, y, r, stops, N)` and `helpers.drawStars(opts)` come from details3d.
|
|
451
|
+
* Returns what it drew, for a test: the sun's altitude, whether the sun, the moon and the stars
|
|
452
|
+
* showed, the weather.
|
|
453
|
+
*/
|
|
454
|
+
export function drawLivingSky(ctx, pw, ph, dpr, now, opts, helpers) {
|
|
455
|
+
const { softStops, drawStars } = helpers;
|
|
456
|
+
const t = skyTime(opts, opts.skyNowMs ?? Date.now());
|
|
457
|
+
const lat = Number.isFinite(opts.skyLat) ? opts.skyLat : null;
|
|
458
|
+
const sun = sunPosition(t.hour, t.dayOfYear, lat);
|
|
459
|
+
const cols = skyColours(sun.alt);
|
|
460
|
+
const night = starVisibility(sun.alt);
|
|
461
|
+
const weather = WEATHER[opts.skyWeather] ?? WEATHER.scattered;
|
|
462
|
+
const horizon = Number.isFinite(opts.skyHorizon) ? opts.skyHorizon : 1;
|
|
463
|
+
const s = sunScreen(sun, pw, ph, lat == null ? 62 : 90, horizon);
|
|
464
|
+
// the dome, cached until the sun has moved half a degree
|
|
465
|
+
const key = `${pw}x${ph}|${Math.round(sun.alt * 2) / 2}`;
|
|
466
|
+
const painted = domeLayer(ctx, pw, ph, key, (c2) => paintDome(c2, pw, ph, cols));
|
|
467
|
+
if (!painted) paintDome(ctx, pw, ph, cols);
|
|
468
|
+
// the stars, fading in as the sun goes down (the star field details3d already keeps)
|
|
469
|
+
if (night > 0.01 && drawStars) drawStars({ ...opts, stars: true, starBrightness: (Number.isFinite(opts.starBrightness) ? opts.starBrightness : 1) * night });
|
|
470
|
+
paintTwilight(ctx, pw, ph, s.x, cols, softStops);
|
|
471
|
+
if (opts.skyShooting !== false) paintShootingStar(ctx, pw, ph, now, night, dpr);
|
|
472
|
+
// the real calendar's phase (operator: "Is it ever a full moon? Does it go through phases?" --
|
|
473
|
+
// yes, on the real dates: new, crescent, quarter, gibbous, full and back over 29.5 days); under
|
|
474
|
+
// the cycle clock the calendar runs a day per 24-minute day, so the month passes in twelve hours
|
|
475
|
+
const phase = moonPhase(t.mode === 'cycle' ? new Date(t.date.getTime() + t.cycleDays * 86400000) : t.date);
|
|
476
|
+
// THE MOON EVERY NIGHT (opts.skyMoon 'night', the shipped choice): on the real track a young
|
|
477
|
+
// crescent sets an hour or two after the sun and a night has no moon in it at all, which is
|
|
478
|
+
// astronomy and not what anyone looking at a night sky for pleasure expects. 'night' puts it on
|
|
479
|
+
// the sun's own track twelve hours behind -- highest at midnight -- and never thinner than a
|
|
480
|
+
// fat crescent; 'real' is the real thing, for the astronomer.
|
|
481
|
+
const nightly = opts.skyMoon !== 'real';
|
|
482
|
+
const moon = nightly ? sunPosition(((t.hour + 12) % 24 + 24) % 24, t.dayOfYear, lat) : moonPosition(t.hour, phase, t.dayOfYear, lat);
|
|
483
|
+
const shown = nightly ? { ...phase, lit: Math.max(phase.lit, 0.4) } : phase;
|
|
484
|
+
const m = sunScreen(moon, pw, ph, lat == null ? 62 : 90, horizon);
|
|
485
|
+
paintSun(ctx, pw, ph, s, sun, cols, softStops, 'glow');
|
|
486
|
+
if (opts.skyRays !== false) paintRays(ctx, pw, ph, s, sun, cols);
|
|
487
|
+
paintClouds(ctx, pw, ph, now, opts, sun, s, cols, weather, softStops);
|
|
488
|
+
// the two discs over the clouds: a sun or a moon lost behind one is a sky with neither
|
|
489
|
+
paintMoon(ctx, pw, ph, m, moon, shown, night, cols, softStops);
|
|
490
|
+
paintSun(ctx, pw, ph, s, sun, cols, softStops, 'disc');
|
|
491
|
+
if (opts.skyRainbow && opts.skyWeather === 'scattered') paintRainbow(ctx, pw, ph, s, sun, dpr);
|
|
492
|
+
if (weather.rain) { paintRain(ctx, pw, ph, now, dpr); if (weather.storm) paintLightning(ctx, pw, ph, now, dpr); }
|
|
493
|
+
return { hour: t.hour, alt: sun.alt, sun: sun.alt >= -8, moon: moon.alt >= -3 && shown.lit >= 0.03, stars: night, weather: opts.skyWeather ?? 'scattered', phase: phase.p, horizon };
|
|
494
|
+
}
|
package/public/js/login.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// the theme the browser last saw (settings.js keeps a copy under blockyard.settings), so a light
|
|
5
5
|
// theme's sign-in page is light too; a first visit gets the shipped look
|
|
6
6
|
import { followTheme } from './theme.js';
|
|
7
|
+
import { safeNext } from './safenext.js';
|
|
7
8
|
followTheme();
|
|
8
9
|
|
|
9
10
|
const err = document.getElementById('err');
|
|
@@ -76,8 +77,7 @@ form.addEventListener('submit', async (ev) => {
|
|
|
76
77
|
// X-CSRF-Token header -- reading it is the thing a cross-origin page cannot
|
|
77
78
|
// do, which is what makes the check mean anything.
|
|
78
79
|
void /(?:^|;\s*)blockyard_csrf=/.test(document.cookie);
|
|
79
|
-
|
|
80
|
-
window.location.replace(back && back.startsWith('/') ? back : '/');
|
|
80
|
+
window.location.replace(safeNext(new URLSearchParams(location.search).get('next'), location.origin));
|
|
81
81
|
} catch (e) {
|
|
82
82
|
show(`cannot reach the server: ${e.message}`);
|
|
83
83
|
btn.disabled = false;
|
|
@@ -86,3 +86,4 @@ form.addEventListener('submit', async (ev) => {
|
|
|
86
86
|
});
|
|
87
87
|
|
|
88
88
|
document.getElementById('u').focus();
|
|
89
|
+
|
package/public/js/mining.js
CHANGED
|
@@ -959,10 +959,10 @@ export function nextHud(nb, { meter = '', fresh = null, mempool = null } = {}, f
|
|
|
959
959
|
const mins = fresh && Number.isFinite(fresh.seconds) ? Math.floor(fresh.seconds / 60) : null;
|
|
960
960
|
const ageSec = nb.at ? Math.max(0, Math.round((Date.now() - nb.at) / 1000)) : null;
|
|
961
961
|
const ec = nb.economy ?? null;
|
|
962
|
-
const kv = (k, v) => `<dt>${k}</dt><dd>${v}</dd>`;
|
|
963
|
-
return `${head(`<span class="hudk">#${nb.height ?? '?'}</span><span class="hudclock ${lvl}" title="time since the last block: how long this block has been accumulating${fresh?.basis === 'block time' ? ' (from the block timestamp)' : ''}">${mins != null ? `${mins}m` : '–'}</span>`)}
|
|
962
|
+
const kv = (k, v) => `<dt>${fmt.esc(k)}</dt><dd>${fmt.esc(v)}</dd>`; // text only (audit 2026-09-16, L2)
|
|
963
|
+
return `${head(`<span class="hudk">#${fmt.esc(nb.height ?? '?')}</span><span class="hudclock ${lvl}" title="time since the last block: how long this block has been accumulating${fresh?.basis === 'block time' ? ' (from the block timestamp)' : ''}">${mins != null ? `${mins}m` : '–'}</span>`)}
|
|
964
964
|
${meter}
|
|
965
|
-
<div class="hudbig">${pct.toFixed(1)}<small>% full</small>${ec?.marginal?.rate != null ? `<span class="chip hot">marginal ~${ec.marginal.rate}/vB</span>` : '<span class="chip">not full</span>'}</div>
|
|
965
|
+
<div class="hudbig">${pct.toFixed(1)}<small>% full</small>${ec?.marginal?.rate != null ? `<span class="chip hot">marginal ~${fmt.esc(ec.marginal.rate)}/vB</span>` : '<span class="chip">not full</span>'}</div>
|
|
966
966
|
<dl class="hudkv">
|
|
967
967
|
${kv('transactions', nb.txCount != null ? fmt.num(nb.txCount) : '–')}
|
|
968
968
|
${kv('fees', btc(nb.totalFeesSat))}
|
|
@@ -983,7 +983,7 @@ export function tipHud(tipHeight, row, prev, { avgGapSec = null, now = Date.now(
|
|
|
983
983
|
const w = who(row);
|
|
984
984
|
const f = blockFacts(row);
|
|
985
985
|
const gap = prev?.time && row.time ? row.time - prev.time : null;
|
|
986
|
-
const kv = (k, v) => `<dt>${k}</dt><dd>${v}</dd>`;
|
|
986
|
+
const kv = (k, v) => `<dt>${fmt.esc(k)}</dt><dd>${fmt.esc(v)}</dd>`; // text only (audit 2026-09-16, L2)
|
|
987
987
|
return `${head}
|
|
988
988
|
<div class="hudpool"><span class="bdot" data-pool="${w.idx}"></span><b data-pool-fg="${w.idx}">${fmt.esc(trunc(w.name, 22))}</b><span class="faint">${agoText(row.at ?? row.seenAt, now) ? `mined ${agoText(row.at ?? row.seenAt, now)}` : ''}</span></div>
|
|
989
989
|
<div class="hudbar" title="${f.capPct != null ? `${f.capPct.toFixed(1)}% of the 4,000,000 WU cap` : 'weight unknown'}"><span data-w="${f.capPct == null ? 0 : f.capPct.toFixed(1)}" data-pool="${w.idx}"></span></div>
|
package/public/js/panels.js
CHANGED
|
@@ -111,8 +111,8 @@ export function renderChain(s, state, h) {
|
|
|
111
111
|
// hundreds of blocks a second is not a hashrate), and the reason travels with it
|
|
112
112
|
// so the dash is an explanation rather than a mystery.
|
|
113
113
|
['network hash', s.hashrateEstEh != null ? fmt.eh(s.hashrateEstEh)
|
|
114
|
-
: `<span title="${fmt.esc(s.hashrateNote ?? '')}">–</span>`],
|
|
115
|
-
...(s.hashrateEstEh == null && s.hashrateNote ? [['hash rate note', `<span class="tiny faint">${fmt.esc(s.hashrateNote)}</span>`]] : []),
|
|
114
|
+
: raw(`<span title="${fmt.esc(s.hashrateNote ?? '')}">–</span>`)],
|
|
115
|
+
...(s.hashrateEstEh == null && s.hashrateNote ? [['hash rate note', raw(`<span class="tiny faint">${fmt.esc(s.hashrateNote)}</span>`)]] : []),
|
|
116
116
|
['avg interval', s.avgBlockGapSec != null ? fmt.ageSec(s.avgBlockGapSec) : '–'],
|
|
117
117
|
['reorgs seen', String(s.blocks?.reorgs ?? 0)],
|
|
118
118
|
]));
|
|
@@ -521,7 +521,7 @@ export function renderNode(s, state, h) {
|
|
|
521
521
|
const lh = t.health ?? {};
|
|
522
522
|
h.setText('ndTail', kv([
|
|
523
523
|
['file', t.source === 'disabled'
|
|
524
|
-
? '<span class="faint">disabled — running on RPC only</span>'
|
|
524
|
+
? raw('<span class="faint">disabled — running on RPC only</span>')
|
|
525
525
|
: (t.file ? fmt.hash(t.file, 16) : 'not configured')],
|
|
526
526
|
['exists', t.exists == null ? '–' : String(t.exists)],
|
|
527
527
|
['size', t.size != null ? fmt.bytes(t.size, 1) : '–'],
|
|
@@ -536,10 +536,10 @@ export function renderNode(s, state, h) {
|
|
|
536
536
|
// The two figures that separate "this node is quiet" from "we are tailing the
|
|
537
537
|
// wrong file". A tail that stops moving is invisible in every other panel.
|
|
538
538
|
['lines matched', lh.ratio != null
|
|
539
|
-
? `${Math.round(lh.ratio * 100)}%${lh.lines ? ` <span class="faint tiny">(${fmt.num(lh.parsed)}/${fmt.num(lh.lines)} last window)</span>` : ''}`
|
|
540
|
-
: '<span class="faint">not checked yet</span>'],
|
|
539
|
+
? raw(`${Math.round(lh.ratio * 100)}%${lh.lines ? ` <span class="faint tiny">(${fmt.num(lh.parsed)}/${fmt.num(lh.lines)} last window)</span>` : ''}`)
|
|
540
|
+
: raw('<span class="faint">not checked yet</span>')],
|
|
541
541
|
['last new bytes', lh.lastGrowthAt
|
|
542
|
-
? `${fmt.ago(lh.lastGrowthAt)} <span class="faint tiny">(warns after ${lh.staleAfterMs ? Math.round(lh.staleAfterMs / 60000) : '?'} min)</span>`
|
|
542
|
+
? raw(`${fmt.esc(fmt.ago(lh.lastGrowthAt))} <span class="faint tiny">(warns after ${lh.staleAfterMs ? Math.round(lh.staleAfterMs / 60000) : '?'} min)</span>`)
|
|
543
543
|
: '–'],
|
|
544
544
|
]));
|
|
545
545
|
|
|
@@ -826,8 +826,17 @@ function drawFromSeries(h, id, points, color, fmtY, extra = {}) {
|
|
|
826
826
|
});
|
|
827
827
|
}
|
|
828
828
|
|
|
829
|
-
|
|
830
|
-
|
|
829
|
+
// ESCAPED BY DEFAULT (audit 2026-09-16, L2/L3). kv() used to write every value as markup, and some
|
|
830
|
+
// values are the node's own strings passed straight through -- `chain`, `pruned`, the RPC endpoint,
|
|
831
|
+
// the cookie path. A hostile or misconfigured node returned `chain` with an <img> appended and it
|
|
832
|
+
// became an element on the Chain page (the CSP stopped the script; the element was still there).
|
|
833
|
+
// Now a value is text unless it is wrapped in raw(), which says at the call site "this is markup
|
|
834
|
+
// this file built, with its own dynamic parts already escaped".
|
|
835
|
+
const RAW = Symbol('raw');
|
|
836
|
+
export const raw = (html) => ({ [RAW]: String(html) });
|
|
837
|
+
export function kv(pairs) {
|
|
838
|
+
const cell = (v) => (v && typeof v === 'object' && RAW in v ? v[RAW] : F().esc(v));
|
|
839
|
+
return pairs.map(([k, v]) => `<dt>${F().esc(k)}</dt><dd>${cell(v)}</dd>`).join('');
|
|
831
840
|
}
|
|
832
841
|
|
|
833
842
|
function median(a) {
|
|
@@ -888,8 +897,8 @@ export function initChainDrill(h) {
|
|
|
888
897
|
return;
|
|
889
898
|
}
|
|
890
899
|
const rows = [
|
|
891
|
-
['txid', `<span class="mono tiny select-all">${fmt.esc(d.txid ?? txid)}</span> <a class="xlink" href="#explorer/tx/${encodeURIComponent(d.txid ?? txid)}">open in the explorer ›</a>`],
|
|
892
|
-
['in', d.inMempool ? '<span class="warn">mempool (unconfirmed)</span>' : `block ${
|
|
900
|
+
['txid', raw(`<span class="mono tiny select-all">${fmt.esc(d.txid ?? txid)}</span> <a class="xlink" href="#explorer/tx/${encodeURIComponent(d.txid ?? txid)}">open in the explorer ›</a>`)],
|
|
901
|
+
['in', d.inMempool ? raw('<span class="warn">mempool (unconfirmed)</span>') : `block ${d.blockHash ?? '?'}`],
|
|
893
902
|
['confirmations', d.confirmations == null ? '– (in the pool)' : fmt.num(d.confirmations)],
|
|
894
903
|
['size / vsize / weight', [d.size, d.vsize, d.weight].map((v) => (v == null ? '–' : fmt.num(v))).join(' / ')],
|
|
895
904
|
['locktime', d.locktime ?? 0],
|
|
@@ -898,8 +907,8 @@ export function initChainDrill(h) {
|
|
|
898
907
|
['total out', d.totalOutSat != null ? `${fmt.num(d.totalOutSat)} sat` : '–'],
|
|
899
908
|
];
|
|
900
909
|
const io = (rows2, label) => `<div class="note tiny mt-6"><b>${label}</b></div><table class="t"><tbody>${rows2}</tbody></table>`;
|
|
901
|
-
const inRows = (d.inputs ?? []).map((v) => `<tr><td class=\"mono tiny w\">${fmt.esc(String(v.txid ?? 'coinbase').slice(0, 16))}…:${v.vout ?? '–'}</td><td class=\"tiny faint\">${fmt.esc(v.scriptSigType ?? '')} ${fmt.esc(v.scriptSigAsm ?? '')}</td></tr>`).join('');
|
|
902
|
-
const outRows = (d.outputs ?? []).map((v) => `<tr><td class=\"r faint\">${v.n ?? '–'}</td><td class=\"mono tiny\">${fmt.esc(String(v.address ?? v.scriptPubKeyType ?? '–'))}</td><td class=\"r\">${v.value == null ? '–' : fmt.num(v.value)}</td></tr>`).join('');
|
|
910
|
+
const inRows = (d.inputs ?? []).map((v) => `<tr><td class=\"mono tiny w\">${fmt.esc(String(v.txid ?? 'coinbase').slice(0, 16))}…:${fmt.esc(v.vout ?? '–')}</td><td class=\"tiny faint\">${fmt.esc(v.scriptSigType ?? '')} ${fmt.esc(v.scriptSigAsm ?? '')}</td></tr>`).join('');
|
|
911
|
+
const outRows = (d.outputs ?? []).map((v) => `<tr><td class=\"r faint\">${fmt.esc(v.n ?? '–')}</td><td class=\"mono tiny\">${fmt.esc(String(v.address ?? v.scriptPubKeyType ?? '–'))}</td><td class=\"r\">${v.value == null ? '–' : fmt.num(v.value)}</td></tr>`).join('');
|
|
903
912
|
box.innerHTML = `<div class=\"drill\">
|
|
904
913
|
<dl class=\"kv\">${kv(rows)}</dl>
|
|
905
914
|
${inRows ? io(inRows, `inputs (${d.inputsTotal})`) : ''}
|
|
@@ -936,20 +945,20 @@ export function initChainDrill(h) {
|
|
|
936
945
|
const b = d.header ?? {};
|
|
937
946
|
const st = d.stats ?? {};
|
|
938
947
|
if (hdr()) hdr().innerHTML = kv([
|
|
939
|
-
['height', b.height == null ? '–' : `<a class="xlink" href="#explorer/block/${b.height}">${fmt.num(b.height)}</a> <span class="tiny faint">open in the explorer</span>`],
|
|
940
|
-
['hash', `<span class=\"mono tiny select-all\">${fmt.esc(String(b.hash ?? '').slice(0, 24))}…</span>`],
|
|
941
|
-
['confirmations', b.confirmations == null ? '–' : `${fmt.num(b.confirmations)}${b.confirmations === 0 ? ' <span class=\"bad\">(not on the best chain)</span>' : ''}`],
|
|
948
|
+
['height', b.height == null ? '–' : raw(`<a class="xlink" href="#explorer/block/${encodeURIComponent(Number(b.height))}">${fmt.esc(fmt.num(b.height))}</a> <span class="tiny faint">open in the explorer</span>`)],
|
|
949
|
+
['hash', raw(`<span class=\"mono tiny select-all\">${fmt.esc(String(b.hash ?? '').slice(0, 24))}…</span>`)],
|
|
950
|
+
['confirmations', b.confirmations == null ? '–' : raw(`${fmt.esc(fmt.num(b.confirmations))}${b.confirmations === 0 ? ' <span class=\"bad\">(not on the best chain)</span>' : ''}`)],
|
|
942
951
|
['time', b.time ? `${new Date(b.time * 1000).toISOString().replace('T', ' ').slice(0, 19)}Z` : '–'],
|
|
943
952
|
['age', b.time ? fmt.ago(b.time * 1000) : '–'],
|
|
944
953
|
['size / weight', `${b.size == null ? '–' : fmt.bytes(b.size, 0)} / ${b.weight == null ? '–' : fmt.num(b.weight)} WU`],
|
|
945
954
|
['transactions', fmt.num(b.nTx ?? b.txCount ?? 0)],
|
|
946
|
-
['fees', st.totalfee == null ? (d.statsError ? '<span class=\"warn\">getblockstats failed</span>' : '–') : `${fmt.num(st.totalfee)} sat`],
|
|
955
|
+
['fees', st.totalfee == null ? (d.statsError ? raw('<span class=\"warn\">getblockstats failed</span>') : '–') : `${fmt.num(st.totalfee)} sat`],
|
|
947
956
|
['median fee', st.medianfee == null ? '–' : `${fmt.num(st.medianfee)} sat`],
|
|
948
957
|
['fee rate p10/50/90', (st.feerate_percentiles ?? []).length ? st.feerate_percentiles.map((v) => Number(v).toFixed(1)).join(' / ') : '–'],
|
|
949
958
|
['subsidy', st.subsidy == null ? '–' : `${fmt.num(st.subsidy)} sat`],
|
|
950
959
|
['utxo delta', st.utxo_increase == null ? '–' : fmt.num(st.utxo_increase)],
|
|
951
|
-
['prev / next', `<span class=\"mono tiny\">${fmt.esc(String(b.previousblockhash ?? '–').slice(0, 10))}… / ${fmt.esc(String(b.nextblockhash ?? 'none').slice(0, 10))}…</span>`],
|
|
952
|
-
['merkle root', `<span class=\"mono tiny\">${fmt.esc(String(b.merkleRoot ?? '–').slice(0, 16))}…</span>`],
|
|
960
|
+
['prev / next', raw(`<span class=\"mono tiny\">${fmt.esc(String(b.previousblockhash ?? '–').slice(0, 10))}… / ${fmt.esc(String(b.nextblockhash ?? 'none').slice(0, 10))}…</span>`)],
|
|
961
|
+
['merkle root', raw(`<span class=\"mono tiny\">${fmt.esc(String(b.merkleRoot ?? '–').slice(0, 16))}…</span>`)],
|
|
953
962
|
]);
|
|
954
963
|
const ids = d.txids ?? [];
|
|
955
964
|
if (list()) {
|