Merge ultra/prelaunch-review-and-graphics: pre-launch review fixes, renderer lighting pass, node failover

This commit is contained in:
jwinterm
2026-07-26 08:00:06 -04:00
36 changed files with 2799 additions and 289 deletions
+35
View File
@@ -275,6 +275,41 @@ To run multiple instances (e.g., Wownero on port 3000 and Monero stagenet on por
| `PRIMARY_WALLET_ENDPOINT` | http://127.0.0.1:34570 | http://127.0.0.1:38083 |
| `PRIMARY_RPC_ENDPOINT` | http://127.0.0.1:34568 | http://127.0.0.1:38081 |
### Daemon redundancy and graceful degradation
`RPC_ENDPOINTS` takes any number of daemons, comma or whitespace separated, in **preference order**:
```
RPC_ENDPOINTS=http://127.0.0.1:34568,http://10.42.1.40:34568,http://10.42.1.41:34568
```
`PRIMARY_RPC_ENDPOINT` / `FALLBACK_RPC_ENDPOINT` still work and are appended to the list. The list
is deduplicated — note that the stock configuration sets primary and fallback to the *same* host, so
by default there is exactly one node and no redundancy at all. Add at least one genuinely separate
daemon before relying on failover.
Behaviour:
- one logical RPC call tries **every** configured node before it fails;
- each node must pass the same chain-identity check before it may answer, so failover can never
silently serve a different chain's data;
- after failing over, the preferred node is re-tested every `RPC_PREFERRED_RETRY_MS` (default 60s,
minimum 1s), so recovery does not need a restart;
- when **no** node answers, the service reports unhealthy and throws. `getBlockCountStrict()` —
used by fairness and match seeding — never substitutes a cached height.
**Entries are refused while no node is reachable.** A run's lifetime is counted in blocks and its
entry block comes from the last successful poll; admitting a game during an outage anchors it to an
already-past block and it is killed as a timeout on the first recovered poll. The player is told
`Can't reach a <CHAIN> node right now…` and nothing is charged. Credits are not consumed, because
the gate runs before any spend.
**The wallet is deliberately NOT failed over.** Two `wallet-rpc` processes serving one wallet file
can build transactions from the same outputs and corrupt the wallet cache — a standby wallet is a
custody decision for the operator, not something the app may do on its own. Daemons are read-only
and interchangeable; wallets are neither. When the wallet is unreachable the server declines paid
intake with `Payments are temporarily unavailable…`, and credits and free play keep working.
### Separate systemd Service
Install the hash-pinned fleet copy of `monerogue.service`; it is intentionally absent from the
+44 -1
View File
@@ -66,6 +66,48 @@ draws only the tile's floor **base** in the static pass and lets `RK.fx` paint t
the live loop. The tavern has wall braziers + a hearth; the dungeon hazard kinds are wired and render
the moment the generator emits their chars (`L`/`P`/`^`) — pending the generator + gameplay stakes.
## Lighting model (iso & 3D)
Both premium projections light the dungeon rather than just tinting it, and both derive their
lighting from the same `scene.lightGrid` the 2D modes use — so switching projection changes the
technique, never what you are allowed to see.
**Iso** composites in one pass per frame:
- tiles are **shaded opaque** (`_shadedImage`, quantized to 6 cached levels) instead of alpha-faded.
Alpha-fading made remembered walls translucent, so the dungeon read as a haze of overlapping
ghosts; multiplying a sprite's own pixels toward a cold near-black keeps distant stone *solid and
unlit*, which is what memory should look like;
- **light pools** (`_lightPool`) are additive, squashed to the ground plane, and emitted by torches
(flickering), the player's lantern, and objective beacons on the stairs/treasure;
- **crevice AO** (`_wallAO`) darkens floor cells by how many walls they touch;
- **embers** rise off each flame, seeded per cell so they don't reshuffle every frame;
- a **cold blue wash** (`_memoryWash`) separates remembered rooms from torchlit ones by colour
temperature. The wash colour must stay *darker* than the tiles it covers — a mid-tone blue lifts
memory brighter than the lit centre and inverts the depth hierarchy.
**3D** (`threeRenderer.js`) draws the level as **three InstancedMeshes** (walls / ground / props),
so a 70×35 dungeon costs three draw calls and a fog-of-war update is a few thousand matrix writes
rather than ~2500 mesh allocations. Wall and floor materials are generated on a canvas at startup
(coursed stone, irregular flagstones, both doubling as bump maps) — no new downloads, nothing for
the CSP to block. On top: a low warm key light with a player-tracking shadow frustum, a pool of six
point lights reassigned each frame to the nearest braziers, a hand-held lantern, drifting dust motes,
and a **wall cutaway** that squashes the walls between the isometric camera and the player.
Two things that are easy to get wrong here:
- **Fog is measured from the CAMERA**, which sits ~17 world units back along the isometric offset —
not from the player. A fog band tuned as if it started at the player puts the player a quarter of
the way into fog and crushes the scene to near-black.
- **Do not apply `lightGrid` to albedo at full strength in 3D.** The point lights already provide
falloff; multiplying by the grid as well double-darkens every surface. 3D compresses it
(`0.38 + 0.62·lb`) and lets the lamps shape the scene.
Features (`entrance` / `exit` / `treasure` / items) are **set dressing, not avatars** in both
projections: stairs with a beacon shaft, a gold chest, a spinning pickup. Before that distinction
existed, every one of them was drawn as a humanoid, so the entrance looked like a second player
standing at the map origin.
## Other pieces
| File | Role |
@@ -75,7 +117,8 @@ the moment the generator emits their chars (`L`/`P`/`^`) — pending the generat
| `zoomControl.js` | `RK.attachZoom(host)` — wheel/pinch zoom (0.4–4.0), dblclick reset, pixelated scaling. |
| `catSprites.js` | Animated tavern cat (Pet Cats Pack idle strips). |
| `packRegistry.js`| Multi-pack-per-projection registry: active-pack selection persisted per projection, entitlement-gated, graceful fallback. Node-testable. |
| `threeRenderer.js` | Same-origin Three.js scene, fog-aware entity retention, animated GLB cloning, fitted model scale, camera follow, and complete renderer/resource cleanup. |
| `threeRenderer.js` | Same-origin Three.js scene. Instanced level geometry, procedural stone/flagstone materials, torch light pool + shadows, dedicated feature/monster meshes, dust motes, wall cutaway, fog-aware entity retention, animated GLB cloning, camera follow, and complete renderer/resource cleanup. |
| `chainAmbience.js` | `RK.mountChainAmbience(host)` — drifting top-block-hash fragments, a block-height readout, and a ripple when a block lands. Consumes the public `blockheight` broadcast; decoration only, and it pauses/prunes itself while the tab is hidden. |
| `spGameRenderer.js` | Single-player render bridge, player-centred camera, local 3D lazy-load, and explicit gameplay-focus restoration. |
Tests: `test/renderPackResolver.test.js`, `test/packRegistry_ultra.test.js`,
+27
View File
@@ -157,6 +157,33 @@ render modes, and later multiplayer rooms.
The existing chat backend is retained and wrapped behind a `ChatProvider` interface so the backend
can be swapped later without changing callers. The current backend remains the default.
**Tavern chat is global chat.** When a `globalChatProvider` is injected, `tavern_chat` reaches every
connected client and the persisted history, so it must clear the same bar as the lobby path:
`TavernManager._moderateChat` applies the chat ban list, the reconnect-proof rate limiter
(`stableId` + IP), and `user_id` attribution before publishing. Without those guards `tavern_chat` is
simply an unauthenticated bypass of lobby moderation under a client-chosen display name. The
per-occupant cooldown alone still governs the ephemeral tavern-scoped fallback room, which nobody
outside the room ever sees.
Speech bubbles resolve the speaker from the occupant list **by display name**. The chat provider
publishes a short `publicId`, never the raw socket id, so anything keying bubbles off `socketId`
silently never fires.
## Chain ambience
The Tavern stage carries a decorative chain layer (`RK.mountChainAmbience`, `chainAmbience.js`):
fragments of the real top block hash fade in around the border and drift away, a corner readout
tracks the height, and a landing block sends a ripple around the frame plus a brighter burst.
It consumes the public `blockheight` broadcast, which now carries an optional cosmetic tip
(`hash`, `difficulty`, `txPoolSize`). The tip is refreshed from `get_info` **only when the height
actually advances** — it is decoration, not worth an extra RPC every poll — and
`RpcService.getChainTipInfo()` never throws and never feeds fairness, payouts, or match seeding.
`DebugManager.getChainTip()` returns null under simulated blocks, since there is no chain to
describe. The layer is `pointer-events:none`, honours `prefers-reduced-motion`, and stops spawning
(and drops its backlog) while the tab is hidden — a hidden tab pauses CSS animations, so
`animationend` never fires and motes would otherwise accumulate indefinitely.
## Rendering (art pass)
Rendering goes through a shared, renderer-agnostic **scene model**: game/tavern state is adapted
+31 -2
View File
@@ -1,6 +1,34 @@
/**
* Input handlers for the Wowngeon game
*/
// Is a blocking dialog on screen?
//
// The document-level Enter shortcut submits a RANKED (paid-intent) entry, and it used to fire
// regardless of what was on top — including while the entry-choice modal or the payment modal was
// open. Since those modals advertise "ENTER ▸" on their own cards, one Enter silently submitted an
// entry the player had not chosen, and the click that followed submitted a second one. Anything
// that takes over the screen must swallow the shortcut.
function modalIsOpen() {
if (document.getElementById('entryChoiceOverlay')) return true;
var overlays = document.querySelectorAll('.modal-overlay');
for (var i = 0; i < overlays.length; i++) {
if (!overlays[i].classList.contains('hidden')) return true;
}
var paymentUi = document.getElementById('payment-ui');
if (paymentUi && paymentUi.offsetParent !== null) return true;
return false;
}
// Enter inside any text field belongs to that field, not to the game.
function typingInAField() {
var el = document.activeElement;
if (!el) return false;
if (el.isContentEditable) return true;
var tag = (el.tagName || '').toLowerCase();
return tag === 'input' || tag === 'textarea' || tag === 'select';
}
const InputHandler = {
_lastMoveTime: 0,
_moveCooldown: 100, // Minimum 100ms between moves
@@ -151,12 +179,13 @@ const InputHandler = {
}
}
}
} else if (e.key === 'Enter' && document.activeElement !== $('#chatInput')[0]) {
} else if (e.key === 'Enter' && document.activeElement !== $('#chatInput')[0]
&& !modalIsOpen() && !typingInAField()) {
// If Enter is pressed and chat is not focused
if (typeof Game !== 'undefined' && !Game._gameActive) {
// On welcome screen - start the game
e.preventDefault();
const isDebugMode = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.protocol === 'file:';
+30 -7
View File
@@ -494,6 +494,29 @@ const SocketHandlers = {
}
},
// Chat text arrives ALREADY HTML-escaped by the server (that is the delivery contract — the
// tavern renders it as HTML directly). Escaping it a second time on the way in is what made
// every apostrophe show up as &#39; and every ampersand as &amp;. Decode the exact five
// entities the server writes, then render through a TEXT node: no double encoding, and strictly
// safer than the old innerHTML path because the message can never be parsed as markup at all —
// so the client keeps its "never trust the server escaped it" stance without the artefacts.
_chatText: function(value) {
return String(value == null ? '' : value)
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&'); // last — mirrors the server escaping & first
},
// <strong>sender:</strong> followed by the message as a text node.
_chatLine: function(msgElement, sender, message) {
const strong = document.createElement('strong');
strong.textContent = SocketHandlers._chatText(sender) + ':';
msgElement.append(strong);
msgElement.append(document.createTextNode(' ' + SocketHandlers._chatText(message)));
},
onChatBroadcast: function(data) {
const msgElement = $('<li style="color: #aaa;">');
// Prefer non-sensitive attribution fields; the server no longer sends the raw full
@@ -501,11 +524,9 @@ const SocketHandlers = {
const sender = data.username || data.playerId || data.publicId ||
(data.socketId ? String(data.socketId).substring(0, 6) : null);
if (sender) {
// Escape both fields — chat content is attacker-controlled. Defense in depth:
// the server also escapes, but the client must never trust that.
msgElement.html('<strong>' + escapeHtml(String(sender)) + ':</strong> ' + escapeHtml(data.message));
SocketHandlers._chatLine(msgElement, String(sender), data.message);
} else {
msgElement.text(data.message);
msgElement.text(SocketHandlers._chatText(data.message));
}
$('#messages').append(msgElement);
UI.scrollChat();
@@ -531,11 +552,13 @@ const SocketHandlers = {
msgElement.addClass('status');
}
// Add timestamp if available
let timeStr = '';
if (msg.timestamp) {
timeStr = '<span style="color:#555;font-size:10px;">[' + SocketHandlers._formatTimeAgo(new Date(msg.timestamp)) + ']</span> ';
const stamp = document.createElement('span');
stamp.style.cssText = 'color:#555;font-size:10px;';
stamp.textContent = '[' + SocketHandlers._formatTimeAgo(new Date(msg.timestamp)) + '] ';
msgElement.append(stamp);
}
msgElement.html(timeStr + '<strong>' + escapeHtml(username) + ':</strong> ' + escapeHtml(msg.message));
SocketHandlers._chatLine(msgElement, username, msg.message);
$('#messages').append(msgElement);
});
+217
View File
@@ -0,0 +1,217 @@
// ChainAmbience — the live chain, drifting around the edges of a stage.
//
// The Tavern sits on top of a real blockchain, and the game's whole clock is "when does the next
// block land". This makes that visible without putting a dashboard on screen: fragments of the top
// block hash fade in around the border and drift away, a corner readout tracks the height, and a
// new block sends a ripple around the frame.
//
// Purely decorative. It reads the public `blockheight` broadcast and never influences gameplay, so
// a missing or stale tip just means fewer motes.
(function (root) {
'use strict';
var RK = root.RK = root.RK || {};
var STYLE_ID = 'rk-chain-ambience-style';
var CSS = [
'.rk-chain-amb{position:absolute;inset:0;pointer-events:none;overflow:hidden;z-index:9;',
' font-family:ui-monospace,SFMono-Regular,Menlo,monospace;}',
'.rk-chain-amb .rk-cm{position:absolute;white-space:nowrap;font-size:11px;letter-spacing:.09em;',
' color:#7fe3c0;opacity:0;text-shadow:0 0 8px rgba(80,220,180,.45);will-change:transform,opacity;}',
'.rk-chain-amb .rk-cm.rk-drift{animation:rk-cm-drift var(--rk-cm-life,9s) linear forwards;}',
'@keyframes rk-cm-drift{',
' 0%{opacity:0;transform:translate3d(0,0,0) scale(.94);}',
' 16%{opacity:var(--rk-cm-peak,.42);}',
' 76%{opacity:var(--rk-cm-peak,.42);}',
' 100%{opacity:0;transform:translate3d(var(--rk-cm-dx,0px),var(--rk-cm-dy,-26px),0) scale(1);}}',
'.rk-chain-amb .rk-chain-readout{position:absolute;left:10px;bottom:8px;font-size:10px;',
' line-height:1.5;color:#5f7f96;letter-spacing:.1em;opacity:.72;transition:color .5s ease;}',
'.rk-chain-amb .rk-chain-readout b{display:block;color:#8fd8b6;font-weight:400;font-size:11px;}',
'.rk-chain-amb .rk-chain-readout .rk-chain-hash{display:block;color:#4d6a80;word-break:break-all;max-width:34ch;}',
'.rk-chain-amb .rk-chain-readout.rk-fresh b{color:#b6f7d6;text-shadow:0 0 10px rgba(120,240,190,.55);}',
'.rk-chain-amb .rk-chain-ripple{position:absolute;inset:0;border:1px solid rgba(127,227,192,0);',
' border-radius:inherit;}',
'.rk-chain-amb .rk-chain-ripple.rk-go{animation:rk-chain-ripple 1.5s ease-out forwards;}',
'@keyframes rk-chain-ripple{',
' 0%{border-color:rgba(127,227,192,.55);box-shadow:inset 0 0 0 rgba(127,227,192,.4);}',
' 100%{border-color:rgba(127,227,192,0);box-shadow:inset 0 0 46px rgba(127,227,192,0);}}',
'@media (prefers-reduced-motion:reduce){',
' .rk-chain-amb .rk-cm{display:none}',
' .rk-chain-amb .rk-chain-ripple.rk-go{animation:none}}'
].join('\n');
function ensureStyle(doc) {
if (doc.getElementById(STYLE_ID)) return;
var el = doc.createElement('style');
el.id = STYLE_ID;
el.textContent = CSS;
doc.head.appendChild(el);
}
function reducedMotion() {
try { return root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches; }
catch (_) { return false; }
}
function ChainAmbience(host, opts) {
opts = opts || {};
var doc = root.document;
ensureStyle(doc);
this.host = host;
this.doc = doc;
this.tip = { height: null, hash: null, difficulty: null, txPoolSize: null };
this.motes = 0;
this.maxMotes = opts.maxMotes || 9;
this.spawnMs = opts.spawnMs || 1200;
this.el = doc.createElement('div');
this.el.className = 'rk-chain-amb';
this.el.setAttribute('aria-hidden', 'true'); // decoration; the readout below is the a11y copy
this.ripple = doc.createElement('div');
this.ripple.className = 'rk-chain-ripple';
this.el.appendChild(this.ripple);
this.readout = doc.createElement('div');
this.readout.className = 'rk-chain-readout';
this.heightEl = doc.createElement('b');
this.hashEl = doc.createElement('span');
this.hashEl.className = 'rk-chain-hash';
this.readout.appendChild(this.heightEl);
this.readout.appendChild(this.hashEl);
this.el.appendChild(this.readout);
host.appendChild(this.el);
var self = this;
this.timer = root.setInterval(function () { self._spawn(); }, this.spawnMs);
// A hidden tab pauses CSS animations, so `animationend` never fires and nothing is ever
// removed — while the spawn interval keeps ticking. Left alone, a tavern tab parked in the
// background accrues motes indefinitely and then plays a pile of stale ones all at once when
// the player comes back. Spawning is skipped while hidden (below) and the backlog is dropped
// here; these are decorations, so discarding them costs nothing.
this._onVisibility = function () {
if (!self.el || !self.doc.hidden) return;
self._clearMotes();
};
doc.addEventListener('visibilitychange', this._onVisibility);
}
ChainAmbience.prototype._clearMotes = function () {
if (!this.el) return;
var existing = this.el.querySelectorAll('.rk-cm');
for (var i = 0; i < existing.length; i++) existing[i].remove();
this.motes = 0;
};
// Fragments of the real top-block hash, plus the occasional height/difficulty token, so the
// drifting text is actual chain data rather than decorative gibberish.
ChainAmbience.prototype._fragment = function () {
var t = this.tip;
var roll = Math.random();
if (t.hash && roll < 0.68) {
var start = Math.floor(Math.random() * (t.hash.length - 8));
return t.hash.substr(start, 4 + Math.floor(Math.random() * 5));
}
if (t.height != null && roll < 0.86) return '#' + t.height;
if (t.difficulty != null && roll < 0.94) return 'diff ' + Math.round(t.difficulty / 1e6) + 'M';
if (t.txPoolSize != null) return 'mempool ' + t.txPoolSize;
return t.height != null ? '#' + t.height : '';
};
ChainAmbience.prototype._spawn = function (burst) {
if (!this.el || reducedMotion()) return;
if (this.doc.hidden) return; // paused animations would never clean themselves up
// Count the DOM, not a counter: a missed animationend must not permanently inflate the tally
// and silently stop all further spawning.
if (!burst && this.el.querySelectorAll('.rk-cm').length >= this.maxMotes) return;
var text = this._fragment();
if (!text) return;
var doc = this.doc;
var m = doc.createElement('span');
m.className = 'rk-cm rk-drift';
m.textContent = text;
// Hug the border: pick an edge, then a position along it. The middle of the stage is where
// the game is — the chain lives in the margins.
var edge = Math.floor(Math.random() * 4);
var along = 6 + Math.random() * 84;
var dx = 0, dy = 0;
if (edge === 0) { m.style.left = along + '%'; m.style.top = '3%'; dy = -18; }
else if (edge === 1) { m.style.left = along + '%'; m.style.bottom = '4%'; dy = 20; }
else if (edge === 2) { m.style.left = '2%'; m.style.top = along + '%'; dx = -22; }
else { m.style.right = '2%'; m.style.top = along + '%'; dx = 22; }
dx += (Math.random() - 0.5) * 14;
dy += (Math.random() - 0.5) * 10;
m.style.setProperty('--rk-cm-dx', dx.toFixed(1) + 'px');
m.style.setProperty('--rk-cm-dy', dy.toFixed(1) + 'px');
m.style.setProperty('--rk-cm-life', (7 + Math.random() * 5).toFixed(1) + 's');
m.style.setProperty('--rk-cm-peak', (burst ? 0.62 : 0.30 + Math.random() * 0.22).toFixed(2));
if (burst) m.style.color = '#b6f7d6';
var self = this;
m.addEventListener('animationend', function () {
if (m.parentNode) m.parentNode.removeChild(m);
self.motes--;
});
this.motes++;
this.el.appendChild(m);
};
// Feed it the `blockheight` payload. A height increase is treated as "a block landed" and gets
// the ripple + a burst of fragments; a repeat of the same height just refreshes the readout.
ChainAmbience.prototype.setTip = function (data) {
if (!this.el || !data) return;
var height = Number(data.blockHeight);
if (!isFinite(height)) return;
var advanced = this.tip.height != null && height > this.tip.height;
this.tip.height = height;
if (typeof data.hash === 'string') this.tip.hash = data.hash;
if (data.difficulty != null) this.tip.difficulty = Number(data.difficulty);
if (data.txPoolSize != null) this.tip.txPoolSize = Number(data.txPoolSize);
this.heightEl.textContent = 'BLOCK ' + height;
this.hashEl.textContent = this.tip.hash
? this.tip.hash.slice(0, 16) + '…' + this.tip.hash.slice(-8)
: '';
if (!advanced) return;
var self = this;
this.readout.classList.add('rk-fresh');
this.ripple.classList.remove('rk-go');
// Force a reflow so the animation restarts on consecutive blocks.
void this.ripple.offsetWidth;
this.ripple.classList.add('rk-go');
for (var i = 0; i < 5; i++) {
(function (n) { root.setTimeout(function () { self._spawn(true); }, n * 110); })(i);
}
if (this._freshTimer) root.clearTimeout(this._freshTimer);
this._freshTimer = root.setTimeout(function () {
if (self.readout) self.readout.classList.remove('rk-fresh');
}, 2600);
};
ChainAmbience.prototype.destroy = function () {
if (this.timer) root.clearInterval(this.timer);
this.timer = null;
if (this._onVisibility) this.doc.removeEventListener('visibilitychange', this._onVisibility);
this._onVisibility = null;
if (this._freshTimer) root.clearTimeout(this._freshTimer);
this._freshTimer = null;
if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
this.el = null;
this.readout = null;
this.ripple = null;
};
RK.ChainAmbience = ChainAmbience;
RK.mountChainAmbience = function (host, opts) {
if (!host || !root.document) return null;
try { return new ChainAmbience(host, opts); }
catch (e) {
if (root.console) console.warn('chain ambience unavailable:', e && e.message);
return null;
}
};
})(typeof window !== 'undefined' ? window : this);
+202 -8
View File
@@ -17,6 +17,7 @@
this.ctx = this.canvas.getContext('2d');
this.cache = {};
this.tintCache = {};
this.shadeCache = {};
this.boundsCache = {};
this.last = {};
this.lastScene = null;
@@ -131,15 +132,78 @@
ctx.restore();
};
// Warm pool of light cast onto the scene by a torch / the player's lantern / a beacon. Drawn
// additively after the tiles so it lights the floor, the props AND the characters standing in
// it, which is what separates a lit room from a flat sprite collage.
IsoRenderer.prototype._lightPool = function (cx, cy, radius, inner, outer) {
var ctx = this.ctx;
// A non-finite emitter would throw out of createRadialGradient and abandon the frame
// half-drawn. Lighting is decoration — skip the bad pool, keep the dungeon.
if (!isFinite(cx) || !isFinite(cy) || !isFinite(radius) || radius <= 0) return;
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
g.addColorStop(0, inner);
g.addColorStop(0.45, outer);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = g;
// The pool is squashed to the iso ground plane so it reads as light ON the floor rather
// than a sphere floating in front of the camera.
ctx.translate(cx, cy);
ctx.scale(1, this.tileH / this.tileW);
ctx.translate(-cx, -cy);
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
};
// Embers rising off a flame. Deterministic per torch (seeded by cell) so they don't reshuffle
// every frame; only the phase advances.
IsoRenderer.prototype._embers = function (cx, cy, seed, now) {
var ctx = this.ctx;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < 4; i++) {
var phase = ((now / 1400) + (seed + i * 37) / 97) % 1;
var driftX = Math.sin((seed + i * 11) * 1.7 + phase * 6.28) * 7;
var y = cy - phase * 34;
var a = (1 - phase) * 0.5;
ctx.fillStyle = 'rgba(255,' + (150 + Math.floor(phase * 70)) + ',90,' + a.toFixed(3) + ')';
ctx.beginPath();
ctx.arc(cx + driftX, y, 1.5 * (1 - phase * 0.5), 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
};
// Crevice darkening on floor cells that touch a wall — a cheap ambient-occlusion cue. The
// gradient is clear in the middle of the cell and dark at its rim, so tiles tucked into a corner
// sit in shadow instead of looking like stickers laid on top of the ground.
IsoRenderer.prototype._wallAO = function (cx, cy, strength) {
var ctx = this.ctx, hw = this.tileW / 2, hh = this.tileH / 2;
var g = ctx.createRadialGradient(cx, cy, hw * 0.15, cx, cy, hw);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, 'rgba(0,0,0,' + strength.toFixed(3) + ')');
ctx.save();
ctx.beginPath();
ctx.moveTo(cx, cy - hh); ctx.lineTo(cx + hw, cy); ctx.lineTo(cx, cy + hh); ctx.lineTo(cx - hw, cy);
ctx.closePath();
ctx.clip();
ctx.fillStyle = g;
ctx.fillRect(cx - hw, cy - hh, this.tileW, this.tileH);
ctx.restore();
};
// Warm radial vignette — an ambient tavern glow that darkens the edges and lifts the centre,
// the "juice" that makes the flat canvas feel lit rather than pasted.
IsoRenderer.prototype._vignette = function () {
var ctx = this.ctx, w = this.canvas.width, h = this.canvas.height;
var g = ctx.createRadialGradient(w / 2, h * 0.46, Math.min(w, h) * 0.18,
w / 2, h * 0.46, Math.max(w, h) * 0.62);
g.addColorStop(0, 'rgba(255, 214, 150, 0.10)');
g.addColorStop(0.55, 'rgba(0, 0, 0, 0)');
g.addColorStop(1, 'rgba(0, 0, 0, 0.42)');
g.addColorStop(0, 'rgba(255, 214, 150, 0.06)');
g.addColorStop(0.5, 'rgba(0, 0, 0, 0)');
g.addColorStop(1, 'rgba(0, 0, 0, 0.58)');
ctx.save();
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = g;
@@ -201,6 +265,34 @@
return cv;
};
// Darkened copy of a tile image, cached at quantized levels.
//
// Fog-of-war used to dim tiles with globalAlpha, which made remembered walls TRANSLUCENT — you
// could see the tiles behind them, so the dungeon read as a haze of overlapping ghosts instead
// of solid stone in the dark. Multiplying the sprite's own pixels toward black keeps every tile
// opaque and just unlit, which is what "you remember this room, it isn't lit" should look like.
IsoRenderer.prototype._shadedImage = function (img, level) {
if (!img) return img;
var step = Math.max(0, Math.min(5, Math.round(level * 5)));
if (step >= 5) return img;
var key = (img.src || img._rkBoundsKey || '') + '|s' + step;
if (this.shadeCache[key]) return this.shadeCache[key];
var cv = document.createElement('canvas');
cv.width = img.naturalWidth || img.width;
cv.height = img.naturalHeight || img.height;
if (!cv.width || !cv.height) return img;
var ctx = cv.getContext('2d');
ctx.drawImage(img, 0, 0);
ctx.globalCompositeOperation = 'source-atop';
// A touch of blue in the shadow rather than flat black — unlit stone reads cold.
ctx.fillStyle = 'rgba(6,10,20,' + (1 - step / 5).toFixed(3) + ')';
ctx.fillRect(0, 0, cv.width, cv.height);
ctx.globalCompositeOperation = 'source-over';
cv._rkBoundsKey = key;
this.shadeCache[key] = cv;
return cv;
};
IsoRenderer.prototype._skinTintFor = function (visual) {
var ap = visual && visual.appearance;
var skin = ap && ap.colors && ap.colors.skin;
@@ -292,8 +384,16 @@
var margin = 28;
var originX = margin + scene.rows * this.tileW / 2 + this.tileW;
var originY = margin + this.imageH;
this.canvas.width = Math.ceil((scene.cols + scene.rows) * this.tileW / 2 + margin * 2 + this.tileW * 2);
this.canvas.height = Math.ceil((scene.cols + scene.rows) * this.tileH / 2 + this.imageH + margin * 2);
var wantW = Math.ceil((scene.cols + scene.rows) * this.tileW / 2 + margin * 2 + this.tileW * 2);
var wantH = Math.ceil((scene.cols + scene.rows) * this.tileH / 2 + this.imageH + margin * 2);
// Only ASSIGN width/height when they actually change. Assigning either one reallocates and
// clears the whole backing buffer, and this renderer re-runs every animation frame (the
// walkable pulse and the flames keep `_animating` set) — so the old unconditional assignment
// was throwing away and re-allocating a multi-megapixel canvas 60 times a second.
if (this.canvas.width !== wantW || this.canvas.height !== wantH) {
this.canvas.width = wantW;
this.canvas.height = wantH;
}
var ctx = this.ctx;
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
@@ -333,6 +433,9 @@
items.sort(function (a, b) { return a.depth === b.depth ? (a.y || 0) - (b.y || 0) : a.depth - b.depth; });
var now = Date.now();
// Light emitters collected while drawing, applied additively in one pass afterwards so the
// pools land on the floor, the props AND the characters standing in them.
var lights = [];
for (i = 0; i < items.length; i++) {
var it = items[i];
if (it.type === 'tile') {
@@ -353,13 +456,28 @@
// Fog fade: dim explored-but-distant tiles toward the (near-black) background so the
// remembered area melts into the dark with distance — parity with the 2D modes.
var lb = (scene.lightGrid && scene.lightGrid[it.y] && scene.lightGrid[it.y][it.x] != null) ? scene.lightGrid[it.y][it.x] : 1;
ctx.globalAlpha = cut ? 0.28 : Math.max(0.16, lb);
if (rec && rec.ready) this._drawImage(rec.img, it.sx, it.sy + this.tileH, this.imageW, this.imageH);
else this._diamond(it.sx, it.sy + this.tileH * 0.5, (legend[it.kind] && legend[it.kind].color) || '#3a4048');
// Cutaway walls stay ALPHA-faded (you need to see the floor through them). Everything
// else is shaded opaque, so distant stone is dark stone rather than a ghost.
ctx.globalAlpha = cut ? 0.28 : 1;
if (rec && rec.ready) {
var img = cut ? rec.img : this._shadedImage(rec.img, Math.max(0.16, lb));
this._drawImage(img, it.sx, it.sy + this.tileH, this.imageW, this.imageH);
} else {
ctx.globalAlpha = cut ? 0.28 : Math.max(0.16, lb);
this._diamond(it.sx, it.sy + this.tileH * 0.5, (legend[it.kind] && legend[it.kind].color) || '#3a4048');
}
// Walkable-floor pip: mark every visible floor cell with a soft dot so the WALKABLE
// ground is explicit everywhere. A dark gap with no pip is unambiguously NOT floor —
// that's what stops iso wall-gaps/voids from reading as holes you can walk into.
if (it.kind === 'floor' || it.kind === 'floor2') {
// Crevice shadow where this floor cell abuts stone. Cheap AO — the more walls
// around it, the deeper the cell sits in shadow.
var adj = (this._isWall(scene, it.x - 1, it.y) ? 1 : 0) + (this._isWall(scene, it.x + 1, it.y) ? 1 : 0) +
(this._isWall(scene, it.x, it.y - 1) ? 1 : 0) + (this._isWall(scene, it.x, it.y + 1) ? 1 : 0);
if (adj) {
ctx.globalAlpha = Math.max(0.14, lb);
this._wallAO(it.sx, it.sy + this.tileH * 0.5, Math.min(0.42, 0.14 * adj));
}
ctx.globalAlpha = Math.max(0.14, lb) * 0.5;
ctx.fillStyle = 'rgba(150,215,255,1)';
ctx.beginPath();
@@ -384,6 +502,15 @@
} else {
var fscale = this.tileW * (it.def.fxScale || 0.34) * 0.7;
RK.fx.fire(ctx, fcx, fgy, fscale, now, (it.x * 7 + it.y * 13) % 97);
var seed = (it.x * 7 + it.y * 13) % 97;
this._embers(fcx, fgy - this.tileH * 0.2, seed, now);
// Torch flames flicker, so the pool they cast has to flicker with them.
var flick = 0.82 + Math.sin(now / 95 + seed) * 0.12 + Math.sin(now / 41 + seed * 2) * 0.06;
lights.push({
x: fcx, y: it.sy + this.tileH, r: this.tileW * 1.75 * flick,
inner: 'rgba(255,168,74,' + (0.15 * flick).toFixed(3) + ')',
outer: 'rgba(196,96,26,' + (0.045 * flick).toFixed(3) + ')'
});
}
} else if (it.type === 'entity') {
// Dungeon FEATURES/ITEMS (entrance/exit/treasure/items) are tiles/glyphs, NOT avatars.
@@ -395,6 +522,23 @@
var frec = furl && this._load(furl);
if (frec && frec.ready) this._drawImage(frec.img, it.sx, it.sy + this.tileH, this.imageW, this.imageH);
else this._glyph(it.sx, it.sy + this.tileH * 0.4, it.e.char || '?', it.e.color || '#fff');
// Objectives beacon. Stairs and treasure are the only two things in the dungeon
// worth crossing a room for, so they get a slow breathing glow in their own
// colour — findable from across a dark hall without leaking the layout.
var beaconRGB = it.e.char === '<' ? '63,185,80'
: it.e.char === '>' ? '210,153,34'
: it.e.char && it.e.char.charAt(0) === '$' ? '251,191,36' : null;
if (beaconRGB) {
this._animating = true;
// NB: entity items carry the cell on `it.e`, not `it.x` (that is only set on
// tile items) — reading it.x here produced a NaN radius and threw.
var breathe = 0.55 + Math.sin(now / 620 + it.e.x) * 0.45;
lights.push({
x: it.sx, y: it.sy + this.tileH, r: this.tileW * (1.1 + breathe * 0.35),
inner: 'rgba(' + beaconRGB + ',' + (0.10 + breathe * 0.12).toFixed(3) + ')',
outer: 'rgba(' + beaconRGB + ',' + (0.03 + breathe * 0.04).toFixed(3) + ')'
});
}
continue;
}
this._contactShadow(it.sx, it.sy + this.tileH * 1.15, 18, 7);
@@ -446,10 +590,59 @@
this._animating = true; // keep the pulse animating between game updates
}
// The player carries a lantern. Its pool is drawn last of the emitters so it always wins in
// the cell you actually occupy, and it breathes slightly so a still player isn't a static image.
if (this.focusPoint) {
var lantern = 0.9 + Math.sin(now / 170) * 0.07 + Math.sin(now / 73) * 0.03;
lights.push({
x: this.focusPoint.x, y: this.focusPoint.y, r: this.tileW * 2.7 * lantern,
inner: 'rgba(255,214,158,' + (0.115 * lantern).toFixed(3) + ')',
outer: 'rgba(210,140,70,' + (0.04 * lantern).toFixed(3) + ')'
});
this._animating = true;
}
for (var lg = 0; lg < lights.length; lg++) {
var L = lights[lg];
this._lightPool(L.x, L.y, L.r, L.inner, L.outer);
}
// Colour-temperature separation: everything the player can currently SEE is warm torchlight;
// the explored-but-remembered parts get a cold blue wash. Alpha alone made memory read as
// "far away"; the temperature shift makes it read as "remembered", which is what it is.
this._memoryWash(scene, originX, originY);
this._vignette();
if (this._animating) this._invalidate();
};
// Cold blue overlay on explored-but-not-currently-visible cells.
IsoRenderer.prototype._memoryWash = function (scene, originX, originY) {
var light = scene.lightGrid;
if (!light) return;
var ctx = this.ctx, hw = this.tileW / 2, hh = this.tileH / 2;
ctx.save();
ctx.globalCompositeOperation = 'source-atop';
// A DARK blue. A mid-tone blue at 20% actually lifted the already-darkened memory tiles
// brighter than the torchlit centre, inverting the whole depth hierarchy — remembered rooms
// glowed and the room you were standing in receded.
ctx.fillStyle = 'rgba(16,28,52,1)';
for (var y = 0; y < scene.rows; y++) {
for (var x = 0; x < scene.cols; x++) {
var kind = scene.grid[y] && scene.grid[y][x];
if (!kind || kind === 'dark') continue;
var lb = (light[y] && light[y][x] != null) ? light[y][x] : 1;
if (lb >= 0.3) continue; // currently lit — leave it warm
var p = this._project(x, y, originX, originY), cy = p.y + hh;
ctx.globalAlpha = 0.45 * (1 - lb / 0.3);
ctx.beginPath();
ctx.moveTo(p.x, cy - hh); ctx.lineTo(p.x + hw, cy);
ctx.lineTo(p.x, cy + hh); ctx.lineTo(p.x - hw, cy);
ctx.closePath();
ctx.fill();
}
}
ctx.restore();
};
IsoRenderer.prototype.destroy = function () {
if (this._raf) cancelAnimationFrame(this._raf);
if (this.canvas && this.canvas.parentNode) this.canvas.parentNode.removeChild(this.canvas);
@@ -457,6 +650,7 @@
this.ctx = null;
this.cache = {};
this.tintCache = {};
this.shadeCache = {};
this.boundsCache = {};
this.last = {};
this.lastScene = null;
+593 -54
View File
@@ -1,5 +1,10 @@
// ThreeRenderer — lightweight 3D projection for the shared Scene. It uses generated GLB avatars
// ThreeRenderer — a lit, textured 3D projection of the shared Scene. It uses generated GLB avatars
// when available and falls back to low-poly pieces so the mode remains usable during asset work.
//
// The level is drawn with three InstancedMeshes (walls / ground / props) so a 70x35 dungeon costs
// three draw calls instead of ~2500 individual meshes, and per-instance colour carries the same
// fog-of-war brightness the 2D modes use. Textures are generated on a canvas at startup: no new
// asset downloads, nothing for the CSP to block, and the walls read as stone instead of flat boxes.
(function (root) {
'use strict';
@@ -11,6 +16,115 @@
return fallback || null;
}
// Deterministic PRNG — generated textures are byte-identical every session, so the dungeon
// doesn't subtly re-skin itself on reload.
function lcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function makeCanvas(size, paint) {
var cv = root.document.createElement('canvas');
cv.width = size; cv.height = size;
paint(cv.getContext('2d'), size);
return cv;
}
// Coursed stone blocks: mortar gaps, per-block value variation, a lit top edge and a shaded
// bottom edge. The bevel is baked in so the walls have relief even at grazing light angles.
function stoneCanvas(size) {
return makeCanvas(size, function (c, s) {
var rnd = lcg(0x5713);
c.fillStyle = '#241f1a'; c.fillRect(0, 0, s, s);
var rows = 4, h = s / rows, bw = s / 2.5;
for (var r = 0; r < rows; r++) {
var offset = (r % 2) * (bw / 2);
for (var bx = -1; bx < 4; bx++) {
var x = bx * bw + offset, y = r * h;
var v = 116 + Math.floor(rnd() * 40);
c.fillStyle = 'rgb(' + v + ',' + (v - 7) + ',' + (v - 20) + ')';
c.fillRect(x + 2, y + 2, bw - 4, h - 4);
c.fillStyle = 'rgba(255,246,225,0.11)'; c.fillRect(x + 2, y + 2, bw - 4, 2);
c.fillStyle = 'rgba(0,0,0,0.26)'; c.fillRect(x + 2, y + h - 4, bw - 4, 2);
}
}
for (var i = 0; i < s * 10; i++) {
c.fillStyle = 'rgba(0,0,0,' + (rnd() * 0.18).toFixed(3) + ')';
c.fillRect(Math.floor(rnd() * s), Math.floor(rnd() * s), 1, 1);
}
});
}
// Irregular flagstones — a broken grout grid plus grain, so a large floor never shows an
// obvious repeat.
function groundCanvas(size) {
return makeCanvas(size, function (c, s) {
var rnd = lcg(0x2f19);
c.fillStyle = '#1d1a16'; c.fillRect(0, 0, s, s);
var n = 4, cell = s / n;
for (var gy = 0; gy < n; gy++) {
for (var gx = 0; gx < n; gx++) {
var pad = 1.5 + rnd() * 1.5;
var v = 96 + Math.floor(rnd() * 34);
c.fillStyle = 'rgb(' + v + ',' + (v - 4) + ',' + (v - 14) + ')';
c.fillRect(gx * cell + pad, gy * cell + pad, cell - pad * 2, cell - pad * 2);
c.fillStyle = 'rgba(255,244,220,0.06)';
c.fillRect(gx * cell + pad, gy * cell + pad, cell - pad * 2, 1.5);
}
}
for (var i = 0; i < s * 14; i++) {
c.fillStyle = 'rgba(0,0,0,' + (rnd() * 0.2).toFixed(3) + ')';
c.fillRect(Math.floor(rnd() * s), Math.floor(rnd() * s), 1, 1);
}
});
}
// Vertical fade for the objective beacons: bright at the floor, gone by the top. Without it the
// beam cylinder renders as a flat uniform column that looks like a solid prop.
function beamCanvas(h) {
var cv = root.document.createElement('canvas');
cv.width = 4; cv.height = h;
var c = cv.getContext('2d');
var g = c.createLinearGradient(0, h, 0, 0);
g.addColorStop(0, 'rgba(255,255,255,0.85)');
g.addColorStop(0.35, 'rgba(255,255,255,0.30)');
g.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g; c.fillRect(0, 0, 4, h);
return cv;
}
// Soft round sprite for the dust motes drifting in the torchlight.
function moteCanvas(size) {
return makeCanvas(size, function (c, s) {
var g = c.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
g.addColorStop(0, 'rgba(255,236,196,1)');
g.addColorStop(0.45, 'rgba(255,206,140,0.5)');
g.addColorStop(1, 'rgba(255,190,120,0)');
c.fillStyle = g; c.fillRect(0, 0, s, s);
});
}
// Wall-family kinds occupy the full cell height; ground kinds are a thin slab; everything else
// is a prop box sized per kind. `emissive` marks hazards/fire that glow on their own.
var WALLISH = { wall: 1, window: 1, door: 1, archway: 1, torch: 1 };
var GROUND = { floor: 1, floor2: 1, rug: 1, dirt: 1, entrance: 1, exit: 1, treasure: 1 };
var PROP_HEIGHT = {
bar: 0.52, table: 0.40, chair: 0.30, keg: 0.50, barrel: 0.50, shelf: 0.92,
crate: 0.44, chest: 0.38, hearth: 0.30, column: 1.30,
lava: 0.05, poison: 0.05, spikes: 0.09
};
var HAZARD_GLOW = { lava: 0.85, poison: 0.5, spikes: 0.12 };
// Ground tints. The scene legend colours are tuned for flat 2D fills and read almost black in a
// lit 3D scene, so wall/floor use material-appropriate values and everything else falls back to
// the legend.
var KIND_TINT = {
wall: '#a49a8b', window: '#7d94a8', door: '#9c7a4a', archway: '#a49a8b',
torch: '#a49a8b',
floor: '#7a6a53', floor2: '#6a5d4b', rug: '#8a4a45', dirt: '#645540',
entrance: '#4d7f57', exit: '#8a743c', treasure: '#7c7062',
lava: '#ff6a2a', poison: '#4fd07a', spikes: '#9aa3ad'
};
function ThreeRenderer(host, opts) {
opts = opts || {};
if (!root.RK || !RK.THREE || !RK.THREE.THREE) throw new Error('THREE not loaded');
@@ -26,7 +140,8 @@
this.zoom = 1;
this.panX = 0;
this.panY = 0;
this._sizeKey = null;
this.buckets = {};
this.torchCells = [];
this._destroyed = false;
this._init();
}
@@ -38,10 +153,12 @@
// Fog-of-war parity with the 2D modes: the camera follows the player, so distance fog fades
// explored tiles to black away from you, and _buildTiles skips unexplored ('dark') tiles so
// they never render. Together: unexplored = hidden, explored fades with distance, no map leak.
// (camera sits ~14.5 units from the player; fade from there out to ~15 tiles.)
this.scene.fog = new T.Fog(0x000000, 14, 32);
this.camera = new T.OrthographicCamera(-10, 10, 10, -10, 0.1, 100);
this.camera.position.set(8, 9, 8);
// Fog is measured from the CAMERA, which sits ~17 units back along the isometric offset —
// not from the player. A band tuned as if it started at the player put the player themselves
// a quarter of the way into fog and crushed the whole scene to near-black.
this.scene.fog = new T.Fog(0x05060a, 21, 46);
this.camera = new T.OrthographicCamera(-10, 10, 10, -10, 0.1, 120);
this.camera.position.set(9, 11, 9);
this.camera.lookAt(0, 0, 0);
this.renderer = new T.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
this.renderer.domElement.className = 'rk-canvas';
@@ -50,22 +167,57 @@
if (T.SRGBColorSpace) this.renderer.outputColorSpace = T.SRGBColorSpace;
if (T.ACESFilmicToneMapping != null) {
this.renderer.toneMapping = T.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.08;
this.renderer.toneMappingExposure = 1.22;
}
this.renderer.shadowMap.enabled = true;
if (T.PCFSoftShadowMap != null) this.renderer.shadowMap.type = T.PCFSoftShadowMap;
this.host.appendChild(this.renderer.domElement);
var hemi = new T.HemisphereLight(0xfff3d2, 0x283044, 2.1);
this._buildMaterials();
// A dim cool ambient keeps unlit corners from going pure black; the warm key light and the
// player's torch do the actual shaping.
var hemi = new T.HemisphereLight(0x9fb4d8, 0x161a24, 0.5);
this.scene.add(hemi);
var key = new T.DirectionalLight(0xffffff, 1.6);
key.position.set(6, 10, 5);
this.scene.add(key);
// A subtle player-carried torch adds depth and keeps the followed avatar readable without
// washing out the dungeon's black fog-of-war background.
this.playerLight = new T.PointLight(0xffb35c, 2.2, 8, 2);
this.playerLight.position.set(0, 3.2, 0);
this.keyLight = new T.DirectionalLight(0xffd9a8, 2.3);
this.keyLight.position.set(10, 8.5, 4); // low sun — long, readable wall shadows
this.keyLight.castShadow = true;
this.keyLight.shadow.mapSize.set(1024, 1024);
this.keyLight.shadow.camera.near = 1;
this.keyLight.shadow.camera.far = 46;
this.keyLight.shadow.camera.left = -13;
this.keyLight.shadow.camera.right = 13;
this.keyLight.shadow.camera.top = 13;
this.keyLight.shadow.camera.bottom = -13;
this.keyLight.shadow.bias = -0.0015;
this.keyLight.shadow.normalBias = 0.04;
this.scene.add(this.keyLight);
this.scene.add(this.keyLight.target);
// A player-carried torch adds depth and keeps the followed avatar readable without washing
// out the dungeon's black fog-of-war background.
this.playerLight = new T.PointLight(0xffb35c, 4.4, 14, 2);
this.playerLight.position.set(0, 2.6, 0);
this.scene.add(this.playerLight);
// A small pool of lights reassigned to whichever torches are nearest the player each frame:
// a level can hold dozens of torches, but only a handful are ever on screen.
this.torchLights = [];
for (var i = 0; i < 6; i++) {
var pl = new T.PointLight(0xff9a3c, 0, 11, 2);
pl.visible = false;
this.scene.add(pl);
this.torchLights.push(pl);
}
this.world = new T.Group();
this.scene.add(this.world);
this.tileLayer = new T.Group();
this.fixtureLayer = new T.Group();
this.entityLayer = new T.Group();
this.world.add(this.tileLayer);
this.world.add(this.fixtureLayer);
this.world.add(this.entityLayer);
this._buildMotes();
this.clock = new T.Clock();
var self = this;
this._raf = requestAnimationFrame(function tick() {
@@ -75,6 +227,61 @@
});
};
ThreeRenderer.prototype._buildMaterials = function () {
var T = this.THREE;
function tex(canvas, repeat) {
var t = new T.CanvasTexture(canvas);
t.wrapS = t.wrapT = T.RepeatWrapping;
t.repeat.set(repeat, repeat);
t.anisotropy = 4;
return t;
}
var stone = stoneCanvas(128), ground = groundCanvas(128);
this._textures = [];
var stoneMap = tex(stone, 1), groundMap = tex(ground, 1);
var stoneBump = tex(stone, 1), groundBump = tex(ground, 1);
if (T.SRGBColorSpace) { stoneMap.colorSpace = T.SRGBColorSpace; groundMap.colorSpace = T.SRGBColorSpace; }
this._textures.push(stoneMap, groundMap, stoneBump, groundBump);
this.wallMat = new T.MeshStandardMaterial({
map: stoneMap, bumpMap: stoneBump, bumpScale: 0.55, roughness: 0.94, metalness: 0.02
});
this.groundMat = new T.MeshStandardMaterial({
map: groundMap, bumpMap: groundBump, bumpScale: 0.35, roughness: 0.96, metalness: 0.0
});
this.propMat = new T.MeshStandardMaterial({ roughness: 0.8, metalness: 0.05 });
// Hazards/fire glow on their own so they stay legible in an unlit corridor.
this.glowMat = new T.MeshStandardMaterial({
roughness: 0.55, metalness: 0.0, emissive: new T.Color(0xffffff), emissiveIntensity: 0.9
});
};
// Dust motes drifting through the torchlight. Purely atmospheric, one draw call, and they are
// re-centred on the player so the same 160 particles cover the whole level.
ThreeRenderer.prototype._buildMotes = function () {
var T = this.THREE, n = 160;
var pos = new Float32Array(n * 3);
this._moteSeed = new Float32Array(n);
var rnd = lcg(0x9e37);
for (var i = 0; i < n; i++) {
pos[i * 3] = (rnd() - 0.5) * 26;
pos[i * 3 + 1] = rnd() * 5.5;
pos[i * 3 + 2] = (rnd() - 0.5) * 26;
this._moteSeed[i] = rnd() * 6.28;
}
var geom = new T.BufferGeometry();
geom.setAttribute('position', new T.BufferAttribute(pos, 3));
var map = new T.CanvasTexture(moteCanvas(32));
this._textures.push(map);
this._moteMat = new T.PointsMaterial({
size: 0.11, map: map, transparent: true, depthWrite: false,
blending: T.AdditiveBlending, opacity: 0.55, sizeAttenuation: true
});
this.motes = new T.Points(geom, this._moteMat);
this.motes.frustumCulled = false;
this.scene.add(this.motes);
};
// Fill the host and frame a fixed "screenful" around the player. The camera FOLLOWS the player
// (see _animate) instead of framing the whole level, so 3D behaves like the other modes. Only
// does work when the host size actually changed (cheap to call every frame).
@@ -85,7 +292,7 @@
if (w === this._cw && h === this._ch) return;
this._cw = w; this._ch = h;
this.renderer.setSize(w, h, true); // updateStyle:true → the canvas CSS-fills the host
var aspect = w / h, span = 8 / (this.zoom || 1); // fixed screenful, user-adjustable zoom
var aspect = w / h, span = 6.6 / (this.zoom || 1); // fixed screenful, user-adjustable zoom
this.camera.left = -span * aspect;
this.camera.right = span * aspect;
this.camera.top = span;
@@ -118,57 +325,305 @@
ThreeRenderer.prototype._disposeObject = function (object) {
if (!object || !object.traverse) return;
var keep = [this.wallMat, this.groundMat, this.propMat, this.glowMat];
object.traverse(function (obj) {
if (obj.geometry && obj.geometry.dispose) obj.geometry.dispose();
if (!obj.material) return;
var materials = Array.isArray(obj.material) ? obj.material : [obj.material];
materials.forEach(function (mat) { if (mat && mat.dispose) mat.dispose(); });
materials.forEach(function (mat) {
if (mat && mat.dispose && keep.indexOf(mat) === -1) mat.dispose();
});
});
};
// One InstancedMesh per bucket, grown geometrically. Reusing the mesh across rebuilds is what
// keeps a fog-of-war update from re-allocating the whole level every time the player steps.
ThreeRenderer.prototype._bucket = function (name, geom, mat, needed) {
var T = this.THREE;
var b = this.buckets[name];
if (!b || b.capacity < needed) {
if (b) { this.tileLayer.remove(b.mesh); b.mesh.dispose(); }
var capacity = Math.max(64, Math.ceil(needed * 1.35));
var mesh = new T.InstancedMesh(geom, mat, capacity);
mesh.instanceMatrix.setUsage(T.DynamicDrawUsage);
mesh.castShadow = name !== 'ground';
mesh.receiveShadow = true;
mesh.frustumCulled = false;
this.tileLayer.add(mesh);
b = this.buckets[name] = { mesh: mesh, capacity: capacity, geom: geom };
}
return b;
};
ThreeRenderer.prototype._buildTiles = function (scene) {
var T = this.THREE;
// Visibility changes rebuild the floor/walls, but live entities must stay attached to their
// stable layer. Clearing the whole world here made every avatar disappear after the first
// fog-of-war update because this.entities still pointed at detached objects.
if (this.tileLayer) {
this.world.remove(this.tileLayer);
this._disposeObject(this.tileLayer);
}
this.tileLayer = new T.Group();
this.world.add(this.tileLayer);
if (!this.entityLayer) {
this.entityLayer = new T.Group();
this.world.add(this.entityLayer);
if (!this._geom) {
this._geom = {
wall: new T.BoxGeometry(1, 1, 1),
ground: new T.BoxGeometry(1, 0.12, 1),
prop: new T.BoxGeometry(1, 1, 1)
};
}
var legend = scene.legend || {};
var light = scene.lightGrid || null;
var cx = (scene.cols - 1) / 2, cz = (scene.rows - 1) / 2;
for (var y = 0; y < scene.rows; y++) {
for (var x = 0; x < scene.cols; x++) {
var kind = scene.grid[y][x];
if (kind === 'dark') continue; // unexplored — leave it void (fog-of-war: never render it)
var def = scene.legend[kind] || {};
var h = kind === 'wall' ? 0.9 : (kind === 'bar' ? 0.42 : (kind === 'table' ? 0.28 : 0.08));
var geom = new T.BoxGeometry(kind === 'wall' ? 1 : 0.96, h, kind === 'wall' ? 1 : 0.96);
var mesh = new T.Mesh(geom, this._mat(kind === 'floor' ? '#4f4a3e' : (def.color || '#333333')));
mesh.position.set(x - cx, h / 2 - 0.05, y - cz);
this.tileLayer.add(mesh);
var walls = [], grounds = [], props = [];
this.torchCells = [];
var y, x, kind;
for (y = 0; y < scene.rows; y++) {
for (x = 0; x < scene.cols; x++) {
kind = scene.grid[y] && scene.grid[y][x];
if (!kind || kind === 'dark') continue; // unexplored — never render it (fog-of-war)
var def = legend[kind] || {};
var lb = (light && light[y] && light[y][x] != null) ? light[y][x] : 1;
// Unlike the 2D modes — where lightGrid IS the lighting — 3D has real point lights
// doing the falloff, so applying the grid at full strength to the albedo as well
// double-darkened every surface into near-black. Compress it into a gentle range and
// let the lamps shape the scene.
var b = 0.38 + 0.62 * Math.max(0, Math.min(1, lb));
var tint = KIND_TINT[kind] || def.color || '#6d6558';
var cell = { x: x - cx, z: y - cz, gx: x, gy: y, b: b, tint: tint, kind: kind };
if (WALLISH[kind]) walls.push(cell);
else if (GROUND[kind]) grounds.push(cell);
else props.push(cell);
// Wall-mounted torches: a brazier fixture + a light source candidate.
if (def.fx === 'fire') this.torchCells.push(cell);
if (def.hazard) cell.hazard = def.hazard;
}
}
this.world.position.set(0, 0, 0);
this._wallCells = walls;
this._writeGround(grounds);
this._writeProps(props);
this._writeWalls();
this._syncFixtures();
};
ThreeRenderer.prototype._writeGround = function (cells) {
var T = this.THREE;
var b = this._bucket('ground', this._geom.ground, this.groundMat, cells.length);
var m = new T.Matrix4(), c = new T.Color();
for (var i = 0; i < cells.length; i++) {
var cell = cells[i];
m.makeTranslation(cell.x, 0, cell.z);
b.mesh.setMatrixAt(i, m);
c.set(colorNum(cell.tint)).multiplyScalar(cell.b);
b.mesh.setColorAt(i, c);
}
b.mesh.count = cells.length;
b.mesh.instanceMatrix.needsUpdate = true;
if (b.mesh.instanceColor) b.mesh.instanceColor.needsUpdate = true;
};
ThreeRenderer.prototype._writeProps = function (cells) {
var T = this.THREE;
var b = this._bucket('prop', this._geom.prop, this.propMat, cells.length);
var m = new T.Matrix4(), q = new T.Quaternion(), s = new T.Vector3(), p = new T.Vector3(), c = new T.Color();
for (var i = 0; i < cells.length; i++) {
var cell = cells[i];
var h = PROP_HEIGHT[cell.kind] != null ? PROP_HEIGHT[cell.kind] : 0.3;
s.set(0.88, h, 0.88);
p.set(cell.x, h / 2, cell.z);
m.compose(p, q, s);
b.mesh.setMatrixAt(i, m);
// Hazards keep near-full brightness — a lava pool you can barely see is a trap, not a
// difficulty curve.
var hb = cell.hazard ? Math.max(0.7, cell.b) : cell.b;
c.set(colorNum(cell.tint)).multiplyScalar(hb);
b.mesh.setColorAt(i, c);
}
b.mesh.count = cells.length;
b.mesh.instanceMatrix.needsUpdate = true;
if (b.mesh.instanceColor) b.mesh.instanceColor.needsUpdate = true;
};
// Walls are rewritten whenever the player's cell changes so the cutaway can follow them: walls
// sitting between the isometric camera (+x/+z) and the player are squashed and darkened, the 3D
// equivalent of the iso renderer's fade. Without it the near wall of every corridor hides you.
ThreeRenderer.prototype._writeWalls = function () {
var cells = this._wallCells;
if (!cells) return;
var T = this.THREE;
var b = this._bucket('wall', this._geom.wall, this.wallMat, cells.length);
var m = new T.Matrix4(), q = new T.Quaternion(), s = new T.Vector3(), p = new T.Vector3(), c = new T.Color();
var px = this._plx, py = this._ply;
for (var i = 0; i < cells.length; i++) {
var cell = cells[i];
var h = 1.45, dim = 1;
if (px != null && cell.gx >= px && cell.gy >= py) {
var d = (cell.gx - px) + (cell.gy - py);
if (d > 0 && d <= 3) { h = 0.40; dim = 0.62; }
}
s.set(1, h, 1);
p.set(cell.x, h / 2, cell.z);
m.compose(p, q, s);
b.mesh.setMatrixAt(i, m);
c.set(colorNum(cell.tint)).multiplyScalar(cell.b * dim);
b.mesh.setColorAt(i, c);
}
b.mesh.count = cells.length;
b.mesh.instanceMatrix.needsUpdate = true;
if (b.mesh.instanceColor) b.mesh.instanceColor.needsUpdate = true;
};
// Braziers on the torch walls. Only the cells that newly appeared get geometry — the tile
// buckets are rewritten on every move (that is how remembered tiles keep dimming), and building
// a fresh mesh per torch per step would churn the GPU for no visual gain.
ThreeRenderer.prototype._syncFixtures = function () {
var T = this.THREE;
this._flames = this._flames || {};
var want = {}, i, cell, k;
for (i = 0; i < this.torchCells.length; i++) {
cell = this.torchCells[i];
k = cell.gx + ',' + cell.gy;
want[k] = true;
if (this._flames[k]) { cell.flame = this._flames[k].flame; continue; }
var bowl = new T.Mesh(
new T.CylinderGeometry(0.17, 0.10, 0.16, 8),
new T.MeshStandardMaterial({ color: 0x2a2320, roughness: 0.9 })
);
bowl.position.set(cell.x, 1.30, cell.z);
this.fixtureLayer.add(bowl);
var flame = new T.Mesh(
new T.ConeGeometry(0.13, 0.34, 7),
new T.MeshBasicMaterial({ color: 0xffb14a, transparent: true, opacity: 0.92 })
);
flame.position.set(cell.x, 1.55, cell.z);
this.fixtureLayer.add(flame);
cell.flame = flame;
this._flames[k] = { bowl: bowl, flame: flame };
}
for (k in this._flames) {
if (want[k]) continue;
var rec = this._flames[k];
this.fixtureLayer.remove(rec.bowl);
this.fixtureLayer.remove(rec.flame);
this._disposeObject(rec.bowl);
this._disposeObject(rec.flame);
delete this._flames[k];
}
};
ThreeRenderer.prototype._fallbackAvatar = function (e, visual) {
var T = this.THREE;
var g = new T.Group();
var body = new T.Mesh(new T.CapsuleGeometry(0.44, 1.18, 4, 12), this._mat(tintForVisual(visual, e.color || '#9aa4b2')));
body.position.y = 1.0;
var body = new T.Mesh(new T.CapsuleGeometry(0.40, 1.05, 4, 12), this._mat(tintForVisual(visual, e.color || '#9aa4b2')));
body.position.y = 0.95;
body.castShadow = true;
g.add(body);
var face = new T.Mesh(new T.BoxGeometry(0.22, 0.12, 0.06), this._mat('#0a0c0f'));
face.position.set(0, 1.32, 0.44);
face.position.set(0, 1.30, 0.40);
g.add(face);
return g;
};
// Monsters share the dungeon with the player, so they must never read as a second hero: a
// hunched dark-red body with glowing eyes and its own red spill light.
ThreeRenderer.prototype._makeMonster = function (e) {
var T = this.THREE;
var g = new T.Group();
var body = new T.Mesh(
new T.CapsuleGeometry(0.42, 0.62, 4, 12),
new T.MeshStandardMaterial({ color: 0x7a1d1d, roughness: 0.65, emissive: 0x2a0505, emissiveIntensity: 0.7 })
);
body.position.y = 0.68;
body.castShadow = true;
g.add(body);
var eyeMat = new T.MeshBasicMaterial({ color: 0xff5b4a });
for (var s = -1; s <= 1; s += 2) {
var eye = new T.Mesh(new T.SphereGeometry(0.07, 8, 8), eyeMat);
eye.position.set(s * 0.15, 0.98, 0.34);
g.add(eye);
}
var horns = new T.MeshStandardMaterial({ color: 0x3d1414, roughness: 0.5 });
for (var h = -1; h <= 1; h += 2) {
var horn = new T.Mesh(new T.ConeGeometry(0.07, 0.28, 6), horns);
horn.position.set(h * 0.22, 1.16, 0);
horn.rotation.z = h * -0.3;
g.add(horn);
}
var light = new T.PointLight(0xff4a3a, 1.4, 4.5, 2);
light.position.set(0, 0.9, 0);
g.add(light);
g._menace = true;
return g;
};
// Dungeon FEATURES/ITEMS (entrance/exit/treasure/pickups) are set dressing, not avatars. Before
// this branch existed every one of them was drawn as a humanoid capsule, so the entrance looked
// like a second player standing at the map origin.
ThreeRenderer.prototype._makeFeature = function (e) {
var T = this.THREE;
var g = new T.Group();
var ch = e.char || '?';
if (ch === '<' || ch === '>') {
var down = ch === '>';
var tone = down ? 0xd8a13f : 0x4fbf62;
var stepMat = new T.MeshStandardMaterial({ color: 0x5d564a, roughness: 0.9 });
for (var i = 0; i < 4; i++) {
var step = new T.Mesh(new T.BoxGeometry(0.82, 0.11, 0.82 - i * 0.17), stepMat);
step.position.set(0, down ? 0.05 - i * 0.09 : 0.05 + i * 0.09, i * 0.09);
step.receiveShadow = true;
g.add(step);
}
// A soft beacon column: from across a dark room you can see where the stairs are without
// the fog-of-war having to leak the whole layout.
if (!this._beamTex) {
this._beamTex = new T.CanvasTexture(beamCanvas(64));
this._textures.push(this._beamTex);
}
var beam = new T.Mesh(
new T.CylinderGeometry(0.30, 0.46, 3.2, 14, 1, true),
new T.MeshBasicMaterial({
color: tone, map: this._beamTex, transparent: true, opacity: 0.5,
side: T.DoubleSide, depthWrite: false, blending: T.AdditiveBlending, fog: false
})
);
beam.position.y = 1.6;
g.add(beam);
g._beam = beam;
var bl = new T.PointLight(tone, 1.1, 5, 2);
bl.position.set(0, 0.8, 0);
g.add(bl);
g._pulse = bl;
} else if (ch.charAt(0) === '$') {
var wood = new T.MeshStandardMaterial({ color: 0x6b4423, roughness: 0.8 });
var gold = new T.MeshStandardMaterial({
color: 0xf5c624, roughness: 0.28, metalness: 0.85,
emissive: 0x6b4c07, emissiveIntensity: 0.7
});
var body = new T.Mesh(new T.BoxGeometry(0.62, 0.34, 0.44), wood);
body.position.y = 0.24;
body.castShadow = true;
g.add(body);
var lid = new T.Mesh(new T.CylinderGeometry(0.22, 0.22, 0.62, 12, 1, false, 0, Math.PI), gold);
lid.rotation.z = Math.PI / 2;
lid.position.y = 0.41;
g.add(lid);
var glow = new T.PointLight(0xffc65c, 1.6, 4.5, 2);
glow.position.set(0, 0.7, 0);
g.add(glow);
g._pulse = glow;
g._bob = true;
} else {
var item = new T.Mesh(
new T.OctahedronGeometry(0.22, 0),
new T.MeshStandardMaterial({
color: colorNum(e.color || '#fbbf24'), roughness: 0.3, metalness: 0.6,
emissive: colorNum(e.color || '#fbbf24'), emissiveIntensity: 0.55
})
);
item.position.y = 0.5;
item.castShadow = true;
g.add(item);
g._spin = item;
g._bob = true;
}
g._static = true;
return g;
};
ThreeRenderer.prototype._applyModelTint = function (model, visual) {
var tint = tintForVisual(visual, null);
if (!model) return;
@@ -176,6 +631,7 @@
var color = tint ? new T.Color(colorNum(tint)) : null;
model.traverse(function (obj) {
if (!obj.isMesh || !obj.material) return;
obj.castShadow = true;
var materials = Array.isArray(obj.material) ? obj.material : [obj.material];
var next = materials.map(function (mat) {
if (!mat) return mat;
@@ -256,10 +712,11 @@
return null;
};
ThreeRenderer.prototype._makeEntity = function (e) {
ThreeRenderer.prototype._makeAvatar = function (e) {
var T = this.THREE, self = this;
var shell = new T.Group();
var visual = this._visualFor(e);
shell._isAvatar = true;
shell._body = this._fallbackAvatar(e, visual);
shell.add(shell._body);
if (visual && visual.allowed !== false && visual.model) {
@@ -293,16 +750,35 @@
return shell;
};
ThreeRenderer.prototype._makeEntity = function (e) {
if (e.kind === 'feature' || e.kind === 'item') return this._makeFeature(e);
if (e.kind === 'monster') return this._makeMonster(e);
return this._makeAvatar(e);
};
ThreeRenderer.prototype.render = function (scene) {
if (!scene || !this.renderer) return;
this._fitToHost();
var key = scene.cols + 'x' + scene.rows + ':' + scene.grid.map(function (r) { return r.join(''); }).join('/');
if (key !== this._sizeKey) {
this._buildTiles(scene);
this._sizeKey = key;
// Track the followed player's CELL first: the wall cutaway is computed from it during the
// tile build, so it has to be current before _buildTiles runs.
var pcell = null, i;
for (i = 0; i < scene.entities.length; i++) {
var pe = scene.entities[i];
if (pe.you || pe.cameraTarget || (!pcell && pe.kind === 'player')) pcell = pe;
if (pe.you || pe.cameraTarget) break;
}
if (pcell) { this._plx = pcell.x; this._ply = pcell.y; }
// Rewrite the level every render. `render` is called on game updates (a keypress), not per
// frame, and the buckets are three reused InstancedMeshes — so this is a few thousand matrix
// writes, not an allocation. It has to be unconditional: `lightGrid` re-shades remembered
// tiles on EVERY step, so keying the rebuild on the grid alone froze the fog-of-war fade at
// whatever it was when a cell was first seen.
this._buildTiles(scene);
var seen = {}, cx = (scene.cols - 1) / 2, cz = (scene.rows - 1) / 2;
for (var i = 0; i < scene.entities.length; i++) {
for (i = 0; i < scene.entities.length; i++) {
var e = scene.entities[i], id = e.id;
seen[id] = true;
var ent = this.entities[id];
@@ -331,6 +807,32 @@
}
};
// Reassign the small torch-light pool to whichever braziers are nearest the followed player.
ThreeRenderer.prototype._updateTorchLights = function (px, pz, now) {
var lights = this.torchLights;
if (!lights || !lights.length) return;
var cells = this.torchCells || [];
var near = [];
for (var i = 0; i < cells.length; i++) {
var d = Math.abs(cells[i].x - px) + Math.abs(cells[i].z - pz);
if (d < 16) near.push({ c: cells[i], d: d });
}
near.sort(function (a, b) { return a.d - b.d; });
for (var li = 0; li < lights.length; li++) {
var slot = near[li];
var light = lights[li];
if (!slot) { light.visible = false; light.intensity = 0; continue; }
var flicker = 0.78 + Math.sin(now / 90 + slot.c.gx * 3.1) * 0.13 + Math.sin(now / 37 + slot.c.gy) * 0.07;
light.visible = true;
light.position.set(slot.c.x, 1.5, slot.c.z);
light.intensity = 3.6 * flicker * Math.max(0.3, slot.c.b);
if (slot.c.flame) {
slot.c.flame.scale.set(1, 0.85 + flicker * 0.3, 1);
slot.c.flame.material.opacity = 0.7 + flicker * 0.25;
}
}
};
ThreeRenderer.prototype._animate = function () {
if (!this.renderer) return;
try {
@@ -344,9 +846,18 @@
var moving = Math.abs(dx) + Math.abs(dz) > 0.025;
o.position.x += dx * 0.18;
o.position.z += dz * 0.18;
// Set dressing bobs and spins on its own clock; only characters get the gait bounce.
if (o._static) {
o.position.y = o._bob ? 0.06 + Math.sin(now / 520 + o.position.x) * 0.06 : 0;
if (o._spin) o._spin.rotation.y = now / 900;
if (o._beam) o._beam.material.opacity = 0.34 + Math.abs(Math.sin(now / 900)) * 0.26;
if (o._pulse) o._pulse.intensity = 1.1 + Math.abs(Math.sin(now / 700)) * 0.7;
continue;
}
o.position.y = moving
? Math.abs(Math.sin(now / 95)) * 0.08
: Math.sin(now / 620) * 0.018;
if (o._menace) o.position.y += Math.sin(now / 380) * 0.03;
if (o._body) {
o._body.rotation.z = moving ? Math.sin(now / 120) * 0.055 : 0;
}
@@ -364,8 +875,8 @@
o.rotation.y += (r - o.rotation.y) * 0.18;
}
}
// Player-follow camera: keep the fixed iso offset (8,9,8) but re-target the player's world
// position each frame. The player mesh itself lerps, so the camera glides smoothly with it.
// Player-follow camera: keep the fixed iso offset but re-target the player's world position
// each frame. The player mesh itself lerps, so the camera glides smoothly with it.
this._fitToHost(); // pick up any host resize
var pcam = null;
for (var pid in this.entities) {
@@ -378,13 +889,28 @@
// camera target opposite the corresponding screen-space axes while retaining the fixed
// isometric camera offset and player-follow smoothing.
var viewportHeight = (this.host && (this.host.clientHeight || this.host.offsetHeight)) || 400;
var unitsPerPixel = (16 / (this.zoom || 1)) / Math.max(1, viewportHeight);
var unitsPerPixel = (13.2 / (this.zoom || 1)) / Math.max(1, viewportHeight);
var diagonal = unitsPerPixel / Math.sqrt(2);
var targetX = pcam.position.x + (-this.panX - this.panY) * diagonal;
var targetZ = pcam.position.z + (this.panX - this.panY) * diagonal;
this.camera.position.set(targetX + 8, 9, targetZ + 8);
this.camera.position.set(targetX + 9, 11, targetZ + 9);
this.camera.lookAt(targetX, 0, targetZ);
if (this.playerLight) this.playerLight.position.set(pcam.position.x, 3.2, pcam.position.z);
if (this.playerLight) {
this.playerLight.position.set(pcam.position.x, 2.6, pcam.position.z);
// Hand-held torch: a slow irregular flicker, not a strobe.
this.playerLight.intensity = 4.2 + Math.sin(now / 140) * 0.3 + Math.sin(now / 61) * 0.15;
}
// Keep the shadow frustum tight around the player so a 1024 map stays sharp on a big level.
if (this.keyLight) {
this.keyLight.position.set(pcam.position.x + 10, 8.5, pcam.position.z + 4);
this.keyLight.target.position.set(pcam.position.x, 0, pcam.position.z);
this.keyLight.target.updateMatrixWorld();
}
if (this.motes) {
this.motes.position.set(pcam.position.x, 0, pcam.position.z);
this.motes.rotation.y = now / 60000;
}
this._updateTorchLights(pcam.position.x, pcam.position.z, now);
}
this.renderer.render(this.scene, this.camera);
} catch (err) {
@@ -400,6 +926,18 @@
this._raf = null;
for (var i = 0; i < this.mixers.length; i++) this.mixers[i].stopAllAction();
this._disposeObject(this.world);
for (var name in this.buckets) {
if (this.buckets[name].mesh.dispose) this.buckets[name].mesh.dispose();
}
this.buckets = {};
for (var g in (this._geom || {})) this._geom[g].dispose();
this._geom = null;
[this.wallMat, this.groundMat, this.propMat, this.glowMat, this._moteMat].forEach(function (m) {
if (m && m.dispose) m.dispose();
});
(this._textures || []).forEach(function (t) { if (t && t.dispose) t.dispose(); });
this._textures = [];
if (this.motes && this.motes.geometry) this.motes.geometry.dispose();
if (this.renderer) {
if (this.renderer.domElement && this.renderer.domElement.parentNode) this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
this.renderer.dispose();
@@ -409,6 +947,7 @@
this.entities = {};
this.models = {};
this.mixers = [];
this.torchCells = [];
};
root.RK = root.RK || {};
+40 -5
View File
@@ -27,6 +27,7 @@
<script src="js/render/isoRenderer.js"></script>
<script src="js/render/threeRenderer.js"></script>
<script src="js/render/tavernLife.js"></script>
<script src="js/render/chainAmbience.js"></script>
<script src="/runtime-config.js"></script>
<script type="importmap">
{"imports":{"three":"/vendor/three/0.160.0/three.module.min.js"}}
@@ -293,6 +294,17 @@
}
}
// ---- Chain ambience ----------------------------------------------------------
// Decorative only: it consumes the public `blockheight` broadcast. The last payload is
// retained so a tip that arrives before the player enters still shows once mounted.
var chainAmbience = null;
var lastChainTip = null;
function mountChainAmbience() {
if (chainAmbience || !RK.mountChainAmbience) return;
chainAmbience = RK.mountChainAmbience(stageEl);
if (chainAmbience && lastChainTip) chainAmbience.setTip(lastChainTip);
}
// ---- Rendering ---------------------------------------------------------------
function rebuildScene() {
if (!state.layout) return;
@@ -496,6 +508,9 @@
tavernLife.setMode(currentMode || 'tiles');
tavernLife.spawn();
tavernLife.welcomeBubbles();
// The chain drifting around the frame: hash fragments and the height, with a ripple
// whenever a block lands. It sits over the stage and never intercepts input.
mountChainAmbience();
startRafLoop();
// Load the designed room (if the server is using one); re-render when ready.
if (state.roomUrl) RK.loadRoom(state.roomUrl, function (room) {
@@ -507,6 +522,11 @@
watchGamesBtn.classList.remove('hidden');
});
socket.on('blockheight', function (data) {
lastChainTip = data;
if (chainAmbience) chainAmbience.setTip(data);
});
socket.on('tavern_update', function (snap) {
if (!snap) return;
state.occupants = snap.occupants || [];
@@ -531,11 +551,26 @@
var isGlobal = !m.scope || m.scope === 'global';
if (!isGlobal && m.scope !== state.channel) return;
addChatLine(m.username, m.message);
// Show a speech bubble above the speaker's avatar.
if (tavernLife && m.socketId) {
// m.message is server-escaped HTML; strip tags for the bubble.
var text = m.message ? m.message.replace(/<[^>]*>/g, '') : '';
tavernLife.showPlayerBubble(m.socketId, text);
// Speech bubble above the speaker's avatar.
//
// This used to key off `m.socketId`, which the chat provider deliberately stopped
// sending (it publishes a short `publicId` instead so the raw socket id never leaves the
// server) — so the bubble branch simply never ran and nobody in the tavern ever spoke
// visibly. Resolve the speaker from the occupant list by display name instead, which
// needs no additional identity on the wire.
if (tavernLife) {
var speaker = null;
for (var oi = 0; oi < state.occupants.length; oi++) {
if (state.occupants[oi] && state.occupants[oi].name === m.username) {
speaker = state.occupants[oi];
break;
}
}
if (speaker) {
// m.message is server-escaped HTML; strip tags for the bubble.
var text = m.message ? m.message.replace(/<[^>]*>/g, '') : '';
tavernLife.showPlayerBubble(speaker.id, text);
}
}
});
socket.on('tavern_notice', function (n) {
+26
View File
@@ -377,6 +377,32 @@ PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:34570
PRIMARY_RPC_ENDPOINT=http://127.0.0.1:34568
FALLBACK_RPC_ENDPOINT=http://127.0.0.1:34568
# OPTIONAL: additional daemons, comma or whitespace separated, in PREFERENCE ORDER.
# PRIMARY/FALLBACK above are appended to this list and the whole thing is deduplicated —
# note that the two defaults are the SAME host, so out of the box there is exactly one node
# and no redundancy at all.
#
# A fallback only helps if it can fail independently. A second daemon in the same rack (or the
# same office) shares the outage that takes the first one down, so it buys nothing; a node
# reachable over a separate path — a home node across the mesh, or a public/community node — is
# what actually adds availability.
#
# Every endpoint must still pass the chain-identity check before it may answer, so listing a
# remote node cannot silently serve a different chain's data. For FREE / CREDITS instances the
# daemon is only supplying block height, so a remote node is a reasonable fallback. For instances
# taking PAYMENTS, prefer daemons you control: block height feeds match fairness and entropy, and
# an untrusted node is an untrusted input to those.
#
# RPC_ENDPOINTS=http://127.0.0.1:34568,http://10.42.1.40:34568
#
# How long to stay on a backup before re-testing the preferred node (ms, minimum 1000).
# RPC_PREFERRED_RETRY_MS=60000
#
# When NO endpoint answers, the server does not guess: entries are refused with a clear message
# and nothing is charged. There is intentionally no wallet failover — two wallet-rpc processes
# over one wallet file can spend the same outputs — so a wallet outage declines paid intake while
# credits and free play keep working.
# How often to poll for new blocks (milliseconds)
RPC_POLL_INTERVAL=2000
+26
View File
@@ -78,6 +78,32 @@ WALLET_RPC_USER=wownerogue
WALLET_RPC_PASSWORD=CHANGE_ME_STRONG_RPC_PASSWORD
PRIMARY_RPC_ENDPOINT=http://127.0.0.1:34568
FALLBACK_RPC_ENDPOINT=http://127.0.0.1:34568
# OPTIONAL: additional daemons, comma or whitespace separated, in PREFERENCE ORDER.
# PRIMARY/FALLBACK above are appended to this list and the whole thing is deduplicated —
# note that the two defaults are the SAME host, so out of the box there is exactly one node
# and no redundancy at all.
#
# A fallback only helps if it can fail independently. A second daemon in the same rack (or the
# same office) shares the outage that takes the first one down, so it buys nothing; a node
# reachable over a separate path — a home node across the mesh, or a public/community node — is
# what actually adds availability.
#
# Every endpoint must still pass the chain-identity check before it may answer, so listing a
# remote node cannot silently serve a different chain's data. For FREE / CREDITS instances the
# daemon is only supplying block height, so a remote node is a reasonable fallback. For instances
# taking PAYMENTS, prefer daemons you control: block height feeds match fairness and entropy, and
# an untrusted node is an untrusted input to those.
#
# RPC_ENDPOINTS=http://127.0.0.1:34568,http://10.42.1.40:34568
#
# How long to stay on a backup before re-testing the preferred node (ms, minimum 1000).
# RPC_PREFERRED_RETRY_MS=60000
#
# When NO endpoint answers, the server does not guess: entries are refused with a clear message
# and nothing is charged. There is intentionally no wallet failover — two wallet-rpc processes
# over one wallet file can spend the same outputs — so a wallet outage declines paid intake while
# credits and free play keep working.
RPC_POLL_INTERVAL=2000
DIFFICULTY_PRESET=normal
+21
View File
@@ -115,6 +115,27 @@ class EnvironmentValidator {
const warnings = [];
const errors = [];
const production = String(env.NODE_ENV || '').toLowerCase() === 'production';
// NODE_ENV must be explicit and recognized.
//
// Every fail-closed gate in the app (chain/wallet identity verification, secret-strength
// checks, the simulated-block prohibition, HSTS, the readiness identity requirement) tests
// for the literal string 'production'. DebugManager, meanwhile, defaults an UNSET NODE_ENV
// *to* production — so it suppresses console logging and reports IS_PRODUCTION while every
// safety gate silently reads false. A missing or misspelled NODE_ENV therefore produced a
// quiet, worst-of-both-worlds deployment: hardening off, diagnostics off, no warning. Rather
// than reconcile nine call sites, make the ambiguous state impossible to boot.
const RECOGNIZED_ENVS = ['production', 'development', 'debug', 'test'];
const declaredEnv = typeof env.NODE_ENV === 'string' ? env.NODE_ENV.trim() : '';
if (!declaredEnv) {
errors.push('NODE_ENV is not set. Set it explicitly to one of: '
+ `${RECOGNIZED_ENVS.join(', ')}. An unset NODE_ENV disables every production safety `
+ 'gate while the runtime still behaves as production.');
} else if (!RECOGNIZED_ENVS.includes(declaredEnv.toLowerCase())) {
errors.push(`NODE_ENV='${declaredEnv}' is not a recognized environment. Use one of: `
+ `${RECOGNIZED_ENVS.join(', ')}. An unrecognized value disables every production `
+ 'safety gate.');
}
const paymentsEnabled = Boolean(config?.paymentsEnabled);
const directEnabled = paymentsEnabled && Boolean(config?.modes?.direct?.enabled);
const creditsEnabled = paymentsEnabled && Boolean(config?.modes?.credits?.enabled);
+29 -4
View File
@@ -137,18 +137,21 @@ class DebugManager {
}
// Always broadcast current height to keep clients updated
this.broadcastManager.broadcastBlockHeight(currentHeight);
this.broadcastManager.broadcastBlockHeight(currentHeight, this.chainTip);
// If new block found
if (currentHeight > this.lastProductionBlockHeight) {
if (this.CONSOLE_LOGGING) {
console.log(`⛏️ New block found: ${currentHeight}`);
}
// Notify listeners about new block
this.onNewBlock(currentHeight);
this.lastProductionBlockHeight = currentHeight;
// Refresh the cosmetic tip (hash/difficulty) only on an actual new block — this
// is decoration for the Tavern, not something worth an extra RPC every 2s.
this._refreshChainTip(currentHeight);
}
} catch (error) {
if (this.CONSOLE_LOGGING) {
@@ -162,8 +165,30 @@ class DebugManager {
this.debugInterval = setInterval(poll, 2000); // Every 2 seconds
}
/**
* Best-effort refresh of the cosmetic chain tip. Never awaited by a caller and never allowed to
* reject: a failed decoration lookup must not disturb block-driven game logic.
*/
_refreshChainTip(height) {
if (!this.rpcService?.getChainTipInfo) return;
this.rpcService.getChainTipInfo()
.then((tip) => {
if (!tip) return;
this.chainTip = { ...tip, height };
})
.catch(() => { /* decoration only */ });
}
// ====== BLOCK HEIGHT MANAGEMENT ======
/**
* Cosmetic chain tip for ambient UI. Null until the first successful post-block lookup, and
* always null under simulated blocks (there is no chain to describe).
*/
getChainTip() {
return this.SIMULATED_BLOCKS ? null : (this.chainTip || null);
}
/**
* Get the current block height (debug or production)
* @returns {number} Current block height
+36 -4
View File
@@ -457,16 +457,48 @@ class Game {
};
}
/** Is this dungeon cell inside the player's CURRENT field of view? */
_isVisible(point) {
if (!point) return false;
const x = Array.isArray(point) ? point[0] : point.x;
const y = Array.isArray(point) ? point[1] : point.y;
if (typeof x !== 'number' || typeof y !== 'number') return false;
const row = this.visibleTiles && this.visibleTiles[y];
return !!row && row[x] !== undefined;
}
/**
* Sticky record of which dungeon features the player has actually laid eyes on.
*
* Fog of war used to be enforced ONLY in the browser: the server put the absolute entrance, exit
* and treasure coordinates — plus the monster's exact position — into every `game_start` and
* `game_update`, and the client renderer simply declined to draw them. Anyone reading the socket
* frame in devtools could walk straight to the treasure and the exit from move 0, which in a
* paid mode is a direct attack on the payout. Discovery is sticky (matching the client's
* "explored memory") so a feature you have found stays on your map after you walk away.
*/
_updateDiscovered() {
if (!this._discovered) this._discovered = { entrance: false, exit: false, treasure: false };
if (!this.dungeon) return this._discovered;
if (!this._discovered.entrance && this._isVisible(this.dungeon.entrance)) this._discovered.entrance = true;
if (!this._discovered.exit && this._isVisible(this.dungeon.exit)) this._discovered.exit = true;
if (!this._discovered.treasure && this._isVisible(this.dungeon.treasure)) this._discovered.treasure = true;
return this._discovered;
}
getState() {
const discovered = this._updateDiscovered();
const state = {
gameState: this.gameState,
player: this.player.getState(),
monster: this.monster ? this.monster.getState() : null,
// The monster is concealed the same way the client conceals it — only while in view. Sending
// it always let a player track the hunter through walls.
monster: (this.monster && this._isVisible(this.monster)) ? this.monster.getState() : null,
visibleTiles: { ...this.visibleTiles },
lighting: this.calculateLighting(),
entrance: this.dungeon ? this.dungeon.entrance : null,
exit: this.dungeon ? this.dungeon.exit : null,
treasure: this.dungeon ? this.dungeon.treasure : null,
entrance: (this.dungeon && discovered.entrance) ? this.dungeon.entrance : null,
exit: (this.dungeon && discovered.exit) ? this.dungeon.exit : null,
treasure: (this.dungeon && discovered.treasure) ? this.dungeon.treasure : null,
depth: this.depth,
maxDepth: this.maxDepth,
moves: this.moveCount,
+54 -7
View File
@@ -38,6 +38,22 @@ function payoutErrorText(error, depth = 0) {
return parts.filter(Boolean).join(' ');
}
/**
* A batch that wallet-rpc legitimately broadcast as MORE THAN ONE transaction.
*
* Not an error in the usual sense — the money is out. It is raised so the existing quarantine path
* records the batch for attribution review while carrying the transaction hashes with it, instead
* of the previous behaviour of discarding them and leaving the rows with tx_hash NULL.
*/
class BatchSplitEvidence extends Error {
constructor(hashes, totalFee) {
super(`Batch was broadcast as ${hashes.length} transactions: ${hashes.join(', ')}`);
this.name = 'BatchSplitEvidence';
this.hashes = hashes;
this.totalFee = totalFee ?? null;
}
}
function isInsufficientFundsError(error) {
return /not enough (unlocked )?(money|balance|outputs)|insufficient|no unlocked|unlocked balance/i
.test(payoutErrorText(error));
@@ -2973,8 +2989,14 @@ class GameModeManager {
// Once a valid hash was observed, broadcast is proven even if the following
// DB transaction failed. Preserve that evidence and never return the row to
// the automatic retry pool.
// Provably pre-broadcast failures (transfer gate, identity check, address
// validation — all strictly before transfer_split) are safe to retry: nothing
// was sent, so returning them to 'pending' cannot double-pay. Only genuinely
// ambiguous post-broadcast failures may quarantine the batch.
const preBroadcast = !observedTxHash && err?.preBroadcast === true;
const fundsIssue = !observedTxHash && isInsufficientFundsError(err);
const status = fundsIssue ? 'pending' : 'needs_review';
const retryable = fundsIssue || preBroadcast;
const status = retryable ? 'pending' : 'needs_review';
console.error(`❌ Single payout ${p.id} failed -> ${status}:`, err.message);
await this.db.query(
`UPDATE payouts
@@ -3004,9 +3026,20 @@ class GameModeManager {
const hashes = Array.isArray(result?.tx_hash_list)
? result.tx_hash_list.map(hash => String(hash || '').trim())
: [];
if (result?.success !== true || hashes.length !== 1 || !validTxHash(hashes[0])) {
if (result?.success !== true || hashes.length === 0 || !hashes.every(validTxHash)) {
throw new Error('Wallet returned ambiguous batch transaction-hash evidence');
}
if (hashes.length > 1) {
// wallet-rpc SPLIT the send — which is exactly what transfer_split exists to
// do when the destination set does not fit one transaction. The coins are
// already broadcast, so this is not a failure; but the response does not say
// which destination landed in which tx, so per-row attribution stays a human
// decision (the operator's documented policy). What must NOT happen is what
// used to: throwing here sent every row to needs_review with tx_hash NULL,
// discarding the only evidence that the money left the wallet.
observedTxHash = hashes[0];
throw new BatchSplitEvidence(hashes, result.totalFee);
}
const txHash = hashes[0];
observedTxHash = txHash;
const feePerPayout = result.totalFee != null
@@ -3041,8 +3074,14 @@ class GameModeManager {
// even though the RPC response errored. Those rows have no tx_hash, so the
// retry service's blockchain guard can't protect them and an auto-retry
// could DOUBLE-PAY. Mark 'needs_review' (retry service skips it) + alert.
// Provably pre-broadcast failures (transfer gate, identity check, address
// validation — all strictly before transfer_split) are safe to retry: nothing
// was sent, so returning them to 'pending' cannot double-pay. Only genuinely
// ambiguous post-broadcast failures may quarantine the batch.
const preBroadcast = !observedTxHash && err?.preBroadcast === true;
const fundsIssue = !observedTxHash && isInsufficientFundsError(err);
const status = fundsIssue ? 'pending' : 'needs_review';
const retryable = fundsIssue || preBroadcast;
const status = retryable ? 'pending' : 'needs_review';
console.error(`❌ Batch payout failed (${pending.rows.length} payouts) -> ${status}:`, err.message);
await this.db.query(
`UPDATE payouts
@@ -3051,11 +3090,19 @@ class GameModeManager {
WHERE id = ANY($3)`,
[status, String(err.message).slice(0, 500), ids, observedTxHash]
).catch(() => {});
if (!fundsIssue && this.alertService && typeof this.alertService.sendAlert === 'function') {
if (!retryable && this.alertService && typeof this.alertService.sendAlert === 'function') {
const split = err instanceof BatchSplitEvidence;
this.alertService.sendAlert('batch_payout_failed', {
subject: '⚠️ Batch payout failed — manual review required',
html: `<p>A batch of ${pending.rows.length} payout(s) failed and was marked <b>needs_review</b> to avoid a possible double-payout.</p>`
+ `<p>Payout IDs: ${ids.join(', ')}</p><p>Error: ${err.message}</p>`
subject: split
? '⚠️ Batch payout SPLIT — attribution review required'
: '⚠️ Batch payout failed — manual review required',
html: split
? `<p>A batch of ${pending.rows.length} payout(s) was <b>broadcast as ${err.hashes.length} transactions</b>.`
+ ` The funds have been sent; only the per-row attribution needs confirming.</p>`
+ `<p>Payout IDs: ${ids.join(', ')}</p>`
+ `<p>Transaction hashes:<br>${err.hashes.join('<br>')}</p>`
: `<p>A batch of ${pending.rows.length} payout(s) failed and was marked <b>needs_review</b> to avoid a possible double-payout.</p>`
+ `<p>Payout IDs: ${ids.join(', ')}</p><p>Error: ${err.message}</p>`
}).catch(() => {});
}
}
+11
View File
@@ -103,6 +103,17 @@ class MovementManager {
}
}
/**
* Drop the move-cooldown entry for a departed socket.
*
* `_lastMove` is keyed by the volatile socket.id and written on every accepted move, but was only
* ever emptied by shutdown() — so a long-lived process accumulated one permanent entry per socket
* that ever moved.
*/
forgetSocket(socketId) {
this._lastMove.delete(socketId);
}
/** Freeze gameplay before the graceful-shutdown settlement drain takes its snapshot. */
shutdown() {
this._acceptingMoves = false;
+10
View File
@@ -22,6 +22,16 @@ class AddressManager {
this.onConfirmed = onConfirmed; // optional callback(socketId, accepted, address)
}
/**
* Drop an unconfirmed address prompt for a departed socket.
*
* `pending` entries were only removed inside confirm(), so any player who triggered address
* detection and then left without answering left a permanent entry keyed on a volatile socket id.
*/
forgetSocket(socketId) {
this.pending.delete(socketId);
}
detectInText(text) {
if (!text || typeof text !== 'string') return null;
const match = text.match(ADDRESS_REGEX);
+14 -2
View File
@@ -42,8 +42,11 @@ class BroadcastManager {
/**
* Broadcast block height to all connected clients
* @param {number} blockHeight - Current block height
* @param {{hash?: string, difficulty?: number, txPoolSize?: number}|null} [tip] - Cosmetic chain
* tip for ambient UI. Public chain data only; omitted entirely when unavailable so clients
* keep whatever they last had rather than flickering to a blank readout.
*/
broadcastBlockHeight(blockHeight) {
broadcastBlockHeight(blockHeight, tip) {
// Only log when the height actually changes to avoid per-client spam
if (blockHeight !== this._lastBroadcastBlock) {
if (this.debugManager?.CONSOLE_LOGGING) {
@@ -51,7 +54,16 @@ class BroadcastManager {
}
this._lastBroadcastBlock = blockHeight;
}
this.io.emit('blockheight', { blockHeight });
this.io.emit('blockheight', this.blockHeightPayload(blockHeight, tip));
}
/** Shared shape for the `blockheight` event so the per-socket emit on connect matches. */
blockHeightPayload(blockHeight, tip) {
const payload = { blockHeight };
if (tip && tip.hash) payload.hash = tip.hash;
if (tip && Number.isFinite(tip.difficulty)) payload.difficulty = tip.difficulty;
if (tip && Number.isFinite(tip.txPoolSize)) payload.txPoolSize = tip.txPoolSize;
return payload;
}
/**
+19 -3
View File
@@ -174,8 +174,16 @@ class ConnectionHandler {
}
this.clientSocketMap.delete(socket.id);
// Clean up user records
user.removeUser(socket.client.id);
// Clean up user records.
//
// The registry is keyed by socket.id (see `new user.User(socket.id, ...)` in handleConnection
// and User's constructor), but this used to delete by socket.client.id — a DIFFERENT value
// under the Engine.IO 4 protocol. Nothing was ever removed, so the Map grew by one entry per
// connection for the process lifetime, and each retained User can pin a whole Game instance
// through `currentGame`. Remove both keys: socket.id is the real one, client.id is harmless
// and covers any legacy entry.
user.removeUser(socket.id);
if (socket.client?.id && socket.client.id !== socket.id) user.removeUser(socket.client.id);
// Broadcast updated user count to all clients
this.broadcastManager.broadcastUserCount();
@@ -328,7 +336,15 @@ class ConnectionHandler {
if (this.debugManager.CONSOLE_LOGGING) {
console.log(`📈 Sending current block height ${currentBlock} to new connection ${socket.id}`);
}
this.io.to(socket.id).emit('blockheight', { blockHeight: currentBlock });
// Include the cosmetic chain tip so a client that connects mid-block still gets the ambient
// chain readout instead of waiting up to a full block interval for the next broadcast.
const tip = this.debugManager.getChainTip ? this.debugManager.getChainTip() : null;
this.io.to(socket.id).emit(
'blockheight',
this.broadcastManager.blockHeightPayload
? this.broadcastManager.blockHeightPayload(currentBlock, tip)
: { blockHeight: currentBlock }
);
// Send connection status
this.broadcastManager.sendStatusUpdate(socket.id, 'connection', `Connected to ${gameNameFor(process.env.CRYPTO_TYPE)} server`);
+18 -3
View File
@@ -16,6 +16,10 @@ class RateLimiter {
'address:set': { window: 300000, max: 3 }, // 3 address changes per 5min
'move:player': { window: 1000, max: 50 }, // 50 moves per second (already handled separately)
'connection:new': { window: 60000, max: 10 }, // 10 connections per minute per IP
// Cheap-looking socket events that each perform a users SELECT + UPDATE (last_active)
// and an entitlements SELECT. Unlimited, they can be pipelined at packet rate and turn
// one socket into a database load generator.
'identity:read': { window: 10000, max: 20 }, // 20 identity/address reads per 10s
...options.limits
};
@@ -84,6 +88,7 @@ class RateLimiter {
'payment:', // Payment creation/operations
'game:', // Game start/queue operations
'address:', // Address changes
'identity:', // Identity/entitlement reads (each one hits the database)
'chat:' // Chat messages (reconnect-proof spam protection)
];
return ipLimitedPrefixes.some(prefix => action.startsWith(prefix));
@@ -208,11 +213,21 @@ class RateLimiter {
}
/**
* Get limit config from storage key
* Get limit config from storage key.
*
* Storage keys are `${id}:${action}`, but the id itself contains colons — `u:123` / `s:<socketId>`
* from rateLimitContext.stableId, and raw IPv6 addresses in ipStorage. Stripping only the FIRST
* segment therefore produced `123:chat:message`, which matches no configured limit, so every
* lookup returned undefined. Two things silently broke: `cleanup()` skipped every entry (the only
* registered memory reclamation freed nothing, and both Maps grew for the process lifetime), and
* `_recordAttempt` never reset an expired window, so counts accumulated forever. Match the known
* action names as a suffix instead — correct regardless of how many colons the id contains.
*/
_getLimitFromKey(key) {
const action = key.split(':').slice(1).join(':'); // Remove userId/IP prefix
return this.limits[action];
for (const action of Object.keys(this.limits)) {
if (key === action || key.endsWith(`:${action}`)) return this.limits[action];
}
return undefined;
}
/**
+195 -33
View File
@@ -251,7 +251,17 @@ class SocketHandlers {
entitlementProvider: async (socket) => this._entitlementsForSocket(socket),
// Share the lobby's global chat provider so the tavern joins one global chat with
// persistent history (and nostr fan-out when enabled), instead of ephemeral room chat.
globalChatProvider: this.chatHandler.chatProvider
globalChatProvider: this.chatHandler.chatProvider,
// Publishing into GLOBAL chat must carry the same guards the lobby path applies —
// otherwise `tavern_chat` is an unauthenticated bypass of the chat ban list, the
// reconnect-proof rate limiter, and user_id attribution, reaching every connected
// client and the persisted history under a name the client picked.
chatModeration: {
rateLimiter: this.rateLimiter,
sessionManager: this.gameModeManager?.sessionManager || null,
chatHistory: this.chatHandler?.chatHistory || null,
getOrCreateUser: (socketId) => this.gameModeManager?.getOrCreateUser(socketId)
}
});
this.tavernManager.initialize();
// Initialize match mode queue, scheduler, and manager. Inert unless MATCH_ENABLED=true.
@@ -351,8 +361,62 @@ class SocketHandlers {
return true;
}
/**
* Refuse admission while no blockchain node is reachable.
*
* A run's lifetime is measured in blocks, and its entry block comes from the last SUCCESSFUL
* poll. With every node unreachable that value silently goes stale, so a game admitted during an
* outage was anchored to an already-past block and `checkGamesTimeout` killed it as a timeout on
* the first recovered poll — the player lost a run (and in paid mode, an entry) to our
* infrastructure. Failing the entry up front is the honest outcome: nothing is charged and the
* player is told why.
*
* Deliberately conservative: it only fires when the health check explicitly reports false.
* Simulated blocks report healthy, and a runtime without the probe is left alone.
*/
_rejectAdmissionWhenNodeUnreachable(socket, kind) {
if (this.debugManager?.isChainHealthy?.() !== false) return false;
const chain = String(process.env.CRYPTO_TYPE || 'WOW').toUpperCase().slice(0, 8);
const message = `Can't reach a ${chain} node right now, so entries are paused. `
+ 'Nothing was charged — please try again shortly.';
try { socket?.emit?.('chain_unavailable', { code: 'NODE_UNREACHABLE', kind, message }); } catch (_) {}
try { this.broadcastManager?.sendStatusUpdate?.(socket?.id, 'error', message); } catch (_) {}
if (this.debugManager?.CONSOLE_LOGGING) {
console.warn(`[SocketHandlers] '${kind}' refused — no daemon reachable.`);
}
return true;
}
/**
* Refuse PAID intake while the wallet is unreachable.
*
* Deliberately NOT a failover. Two `wallet-rpc` processes serving one wallet file can build
* transactions from the same outputs and corrupt the wallet cache, so a standby wallet is an
* operator decision about custody, not something this code may take on its own. Daemons are
* read-only and interchangeable — wallets are neither. The safe behaviour when the wallet is
* down is therefore to decline the invoice cleanly rather than to route around it.
*
* Only gates kinds that actually need the wallet: credits/free entry runs off the database and
* must keep working through a wallet outage.
*/
_rejectPaidIntakeWhenWalletUnreachable(socket, kind) {
if (kind !== 'request_payment') return false;
// Only meaningful once a wallet has been wired up and has reported at least once.
if (!this.walletService || this.walletService.isHealthy !== false) return false;
const message = 'Payments are temporarily unavailable — the wallet is unreachable. '
+ 'Nothing was charged. Credits and free play still work.';
try { socket?.emit?.('payment_unavailable', { code: 'WALLET_UNREACHABLE', kind, message }); } catch (_) {}
try { this.broadcastManager?.sendStatusUpdate?.(socket?.id, 'error', message); } catch (_) {}
if (this.debugManager?.CONSOLE_LOGGING) {
console.warn(`[SocketHandlers] '${kind}' refused — wallet unreachable.`);
}
return true;
}
_runAdmission(socket, kind, task) {
if (this._rejectAdmissionDuringShutdown(socket, kind)) return Promise.resolve(null);
if (this._rejectAdmissionWhenNodeUnreachable(socket, kind)) return Promise.resolve(null);
if (this._rejectPaidIntakeWhenWalletUnreachable(socket, kind)) return Promise.resolve(null);
const token = {};
this._admissionsInFlight.add(token);
let work;
@@ -769,7 +833,32 @@ class SocketHandlers {
// Register event handlers. Admission wrappers remain live for the life of this socket and
// consult the dynamic shutdown flag, so existing connections cannot race the snapshot.
const runAdmission = (kind, task) => this._runAdmission(socket, kind, task);
socket.on('chat message', (msg) => this.chatHandler.handleChatMessage(socket, msg, {
// SAFE DISPATCH — every listener below is registered through `on`, never `socket.on`.
//
// Socket.IO invokes listeners without a try/catch, and this process treats
// `uncaughtException` and `unhandledRejection` as fatal (see the handlers in src/index.js,
// which call gracefulShutdown and exit). That combination makes ONE unguarded throw in ANY
// listener an unauthenticated remote kill switch: a client sends a malformed — or entirely
// absent — payload and the whole server exits, taking live games and payment monitoring
// with it. Containing the fault to the offending socket is the invariant; individual
// handlers hardening their own inputs is defence in depth, not a substitute.
const on = (event, handler) => {
socket.on(event, (...args) => {
let result;
try {
result = handler(...args);
} catch (err) {
this._onHandlerFault(socket, event, err);
return;
}
if (result && typeof result.then === 'function') {
result.catch((err) => this._onHandlerFault(socket, event, err));
}
});
};
on('chat message', (msg) => this.chatHandler.handleChatMessage(socket, msg, {
// Legacy text command has no client contribution, but still consumes the already
// published one-time server offer with an empty clientSeed.
handleGameQueue: (commandSocket) => runAdmission('chat_game_queue',
@@ -778,11 +867,11 @@ class SocketHandlers {
handleStatsRequest: (socket) => this.handleStatsRequest(socket)
}));
// Phase 2: a client-signed global chat event (posts under the player's own Smirk npub).
socket.on('chat_signed', (payload) => this.chatHandler.handleSignedChatMessage(socket, payload));
socket.on('player_move', (moveData) => this.movementManager.handleMove(socket.id, moveData));
socket.on('disconnect', () => this.handleDisconnect(socket));
socket.on('debug_ping', (data) => this.handleDebugPing(socket, data));
socket.on('register_client', async (data) => {
on('chat_signed', (payload) => this.chatHandler.handleSignedChatMessage(socket, payload));
on('player_move', (moveData) => this.movementManager.handleMove(socket.id, moveData));
on('disconnect', () => this.handleDisconnect(socket));
on('debug_ping', (data) => this.handleDebugPing(socket, data));
on('register_client', async (data) => {
this.connectionHandler.handleRegisterClient(socket, data);
this._emitFairnessOffer(socket);
// Re-send game mode info after client registers handlers (fixes race condition)
@@ -806,48 +895,48 @@ class SocketHandlers {
if (this.debugManager?.CONSOLE_LOGGING) console.warn('Failed to re-send identity snapshot:', err.message);
});
});
socket.on('auto_start', (data) => runAdmission('auto_start',
on('auto_start', (data) => runAdmission('auto_start',
() => this.handleAutoStart(socket, (data && typeof data === 'object') ? data : {}))); // New handler for start button
socket.on('play_free', (data) => runAdmission('play_free',
on('play_free', (data) => runAdmission('play_free',
() => this.handleAutoStart(socket, { ...((data && typeof data === 'object') ? data : {}), free: true }))); // Explicit free-play choice
socket.on('join_queue', (data) => runAdmission('join_queue',
on('join_queue', (data) => runAdmission('join_queue',
() => this.handleJoinQueue(socket, (data && typeof data === 'object') ? data : {}))); // Queue instead of auto-start ({ free: true } for Pleb-board free play)
socket.on('early_entry', (data) => runAdmission('early_entry',
on('early_entry', (data) => runAdmission('early_entry',
() => this.handleEarlyEntry(socket, (data && typeof data === 'object') ? data : {}))); // Early entry without waiting for block
socket.on('fairness_offer_request', () => this._emitFairnessOffer(socket));
socket.on('address:prompt', () => this.handleAddressPrompt(socket));
on('fairness_offer_request', () => this._emitFairnessOffer(socket));
on('address:prompt', () => this._withIdentityReadLimit(socket, () => this.handleAddressPrompt(socket)));
// Payment system handlers
socket.on('request_payment', (data) => runAdmission('request_payment',
on('request_payment', (data) => runAdmission('request_payment',
() => this.handlePaymentRequest(socket, data)));
socket.on('check_payment_status', (data) => this.handleCheckPaymentStatus(socket, data));
socket.on('get_user_credits', () => this.handleGetUserCredits(socket));
socket.on('address:update', (data) => this.handleAddressUpdate(socket, data));
socket.on('identity:get', () => this.handleIdentityGet(socket));
socket.on('identity:update', (data) => this.handleIdentityUpdate(socket, data));
on('check_payment_status', (data) => this.handleCheckPaymentStatus(socket, data));
on('get_user_credits', () => this.handleGetUserCredits(socket));
on('address:update', (data) => this.handleAddressUpdate(socket, data));
on('identity:get', () => this._withIdentityReadLimit(socket, () => this.handleIdentityGet(socket)));
on('identity:update', (data) => this.handleIdentityUpdate(socket, data));
// Spectator handlers
socket.on('get_active_games', (options) => this.handleGetActiveGames(socket, options));
socket.on('spectate_game', (data) => this.handleSpectateGame(socket, data));
socket.on('leave_spectate', () => this.handleLeaveSpectate(socket));
on('get_active_games', (options) => this.handleGetActiveGames(socket, options));
on('spectate_game', (data) => this.handleSpectateGame(socket, data));
on('leave_spectate', () => this.handleLeaveSpectate(socket));
// Tavern handlers (social hangout mode; refused server-side unless enabled)
socket.on('tavern_join', (data) => {
on('tavern_join', (data) => {
Promise.resolve(this.tavernManager.join(socket, data)).catch(err => {
console.error('Tavern join failed:', err.message);
socket.emit('tavern_error', { message: 'Could not enter the tavern.' });
});
});
socket.on('tavern_move', (data) => this.tavernManager.move(socket, data));
socket.on('tavern_chat', (data) => this.tavernManager.chat(socket, data));
socket.on('tavern_leave', () => this.tavernManager.leave(socket));
on('tavern_move', (data) => this.tavernManager.move(socket, data));
on('tavern_chat', (data) => this.tavernManager.chat(socket, data));
on('tavern_leave', () => this.tavernManager.leave(socket));
// Match mode handlers. Every match listener is wrapped so a match-layer fault can
// never crash the connection or leak a stack to the client — it logs and emits a
// benign 'match_error' instead (C5). Identity is always resolved from the CONNECTION,
// never from the client payload.
socket.on('match_queue', (data) => runAdmission('match_queue',
on('match_queue', (data) => runAdmission('match_queue',
() => this._handleMatchQueue(socket, data)));
socket.on('match_move', (data) => {
on('match_move', (data) => {
try {
this.matchManager.move(socket, data);
} catch (err) {
@@ -855,7 +944,7 @@ class SocketHandlers {
socket.emit('match_error', { message: 'Move could not be processed.' });
}
});
socket.on('match_leave', () => {
on('match_leave', () => {
try {
this.matchManager.leave(socket);
} catch (err) {
@@ -863,9 +952,9 @@ class SocketHandlers {
socket.emit('match_error', { message: 'Could not leave the match.' });
}
});
socket.on('match_reconnect', (data) => this._handleMatchReconnect(socket, data));
on('match_reconnect', (data) => this._handleMatchReconnect(socket, data));
// Tavern match spectator bridge
socket.on('tavern_match_list', () => {
on('tavern_match_list', () => {
const list = this.tavernMatchBridge ? this.tavernMatchBridge.getActiveMatches() : [];
socket.emit('tavern_match_list', list);
});
@@ -1133,6 +1222,20 @@ class SocketHandlers {
return;
}
try {
// Early entry CREATES A GAME and may consume a credit, so it must carry the same
// reconnect-proof admission budget as auto_start / join_queue / request_payment. It
// previously had none of its own: the only limiter downstream keyed on the raw, volatile
// socket.id with no IP bucket, so simply reconnecting reset the allowance.
const rlId = stableId(socket, this.sessionManager);
const rlIp = clientIp(socket);
const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'game:queue', rlIp);
if (!rateLimitResult.allowed) {
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
`Slow down — try entering again in ${Math.ceil(rateLimitResult.retryAfter / 1000)}s.`);
return;
}
await this.rateLimiter.recordAttempt(rlId, 'game:queue', rlIp);
// Early entry is free only on a free-only instance. Mixed/credits entry may consume a
// credit, so treat ambiguous mixed-mode requests as paid and fail closed.
if (this.gameModeManager?.gameMode !== 'FREE'
@@ -1451,6 +1554,22 @@ class SocketHandlers {
this.activeGames.delete(socketId);
}
// Release anyone watching this run. Suspension and the no-identity drop above both
// remove the game from activeGames without going through the settlement publisher that
// normally calls this, so without it those spectators are stranded in a dead room.
if (activeGame?.id && this.spectatorManager?.notifyGameEnded) {
try {
this.spectatorManager.notifyGameEnded(activeGame.id, {
status: 'interrupted',
message: 'The player disconnected.'
}, 'player_disconnected');
} catch (err) {
if (this.debugManager?.CONSOLE_LOGGING) {
console.error('[SocketHandlers] spectator release failed:', err.message);
}
}
}
// Clean up payment monitoring (but remember it was active for restoration)
if (this.paymentHandlers && typeof this.paymentHandlers.stopMonitoringForSocket === 'function') {
this.paymentHandlers.stopMonitoringForSocket(socketId);
@@ -1483,6 +1602,12 @@ class SocketHandlers {
this.spectatorManager.handleDisconnect(socketId);
}
// Per-socket bookkeeping keyed on the VOLATILE socket id. Nothing else ever removes
// these, so without an explicit disconnect sweep each one accrues a permanent entry per
// socket that ever moved / ever triggered address detection.
if (this.movementManager?.forgetSocket) this.movementManager.forgetSocket(socketId);
if (this.addressManager?.forgetSocket) this.addressManager.forgetSocket(socketId);
// Clean up tavern presence
if (this.tavernManager) {
this.tavernManager.handleDisconnect(socketId);
@@ -1547,15 +1672,52 @@ class SocketHandlers {
}
/**
* Handle debug ping
* Contain a socket-listener fault to the socket that caused it.
*
* Never rethrows and never touches process state: the entire point is that a malformed client
* payload must not reach the fatal `uncaughtException` / `unhandledRejection` handlers.
*/
_onHandlerFault(socket, event, err) {
console.error(`❌ Socket handler '${event}' faulted:`, (err && err.message) || err);
if (err && err.stack && this.debugManager?.CONSOLE_LOGGING) console.error(err.stack);
try {
// Deliberately generic — a client must never learn where in the server it broke.
socket.emit('error_message', { message: 'That request could not be processed.' });
} catch (_) { /* socket already gone */ }
}
/**
* Gate a cheap-looking read that actually hits the database.
*
* `identity:get` and `address:prompt` each run getOrCreateUser (a users SELECT plus an
* UPDATE ... SET last_active) and an entitlements SELECT, but unlike their sibling
* `identity:update` neither had any limit — so a single socket could pipeline them at packet
* rate. Uses the reconnect-proof stable id + IP buckets, so dropping and re-opening the socket
* does not reset the budget.
*/
async _withIdentityReadLimit(socket, task) {
const rlId = stableId(socket, this.sessionManager);
const rlIp = clientIp(socket);
const verdict = await this.rateLimiter.checkLimit(rlId, 'identity:read', rlIp);
if (!verdict.allowed) return;
await this.rateLimiter.recordAttempt(rlId, 'identity:read', rlIp);
return task();
}
/**
* Handle debug ping.
*
* `data` is fully attacker-controlled and may be absent or a non-object: `socket.emit('debug_ping')`
* sends no argument at all, and `emit('debug_ping', null)` defeats a default parameter.
*/
handleDebugPing(socket, data) {
if (this.debugManager.CONSOLE_LOGGING) {
console.log(`Debug ping received from ${socket.client.id}`);
}
const clientTime = (data && typeof data === 'object' && data.time != null) ? data.time : null;
socket.emit('debug_pong', {
message: "Hello from server!",
clientTime: data.time,
clientTime,
serverTime: Date.now(),
socketId: socket.client.id
});
+10 -3
View File
@@ -319,18 +319,25 @@ class SpectatorManager {
}
/**
* Notify spectators that a game has ended
* Notify spectators that a game has ended and release them.
*
* MUST be called for every way a game can leave `activeGames`, not just committed settlement.
* A game that vanishes via disconnect/suspension used to leave its spectators sitting in the
* `spectate:<gameId>` room — still tracked, already removed from the lobby room, receiving no
* further updates and no `spectate_ended`, i.e. permanently stuck on a dead view.
*
* @param {string} gameId - Game ID
* @param {Object} gameOverData - Game over information
* @param {string} [reason] - Why the view ended ('game_over' | 'player_disconnected' | ...)
*/
notifyGameEnded(gameId, gameOverData) {
notifyGameEnded(gameId, gameOverData, reason = 'game_over') {
const spectators = this._spectatorsByGame.get(gameId);
// Emit game over to spectators if any
if (spectators && spectators.size > 0) {
this.io.to(`spectate:${gameId}`).emit('spectate_ended', {
gameId: gameId,
reason: 'game_over',
reason,
gameOverData: gameOverData
});
+71 -6
View File
@@ -2,6 +2,7 @@ const Room = require('../multiplayer/Room');
const Appearance = require('../multiplayer/appearance');
const Entitlements = require('../multiplayer/entitlements');
const SocketChatProvider = require('./chat/SocketChatProvider');
const { stableId, clientIp } = require('./rateLimitContext');
function escapeHtml(s) {
return String(s)
@@ -23,7 +24,7 @@ function escapeHtml(s) {
* is broadcast to occupants once per server tick (clients render from these snapshots).
*/
class TavernManager {
constructor({ io, debugManager, roomId = 'main', tickMs = null, roomData = null, roomUrl = null, entitlementProvider = null, globalChatProvider = null } = {}) {
constructor({ io, debugManager, roomId = 'main', tickMs = null, roomData = null, roomUrl = null, entitlementProvider = null, globalChatProvider = null, chatModeration = null } = {}) {
this.io = io;
this.debugManager = debugManager;
this.enabled = process.env.TAVERN_ENABLED === 'true';
@@ -47,6 +48,9 @@ class TavernManager {
// injected, fall back to an ephemeral tavern-scoped provider (previous behavior).
this.globalChatProvider = globalChatProvider;
this.chatProvider = new SocketChatProvider({ io: this.io, debugManager: this.debugManager });
// Guards applied before anything reaches GLOBAL chat (ban list, shared rate limiter,
// user_id attribution). Optional so a tavern-only/ephemeral instance still works.
this.chatModeration = chatModeration;
}
get channel() {
@@ -153,7 +157,15 @@ class TavernManager {
this.room.moveOccupant(socket.id, dx, dy); // next tick broadcasts the result
}
chat(socket, data = {}) {
/**
* Publish a tavern message.
*
* When a global chat provider is injected this reaches EVERY connected client and the persisted
* history, so it must clear the same bar as the lobby path: chat ban, the reconnect-proof rate
* limiter, and user_id attribution. Async — callers are wrapped by the socket safe-dispatch, and
* a moderation lookup failure must never publish.
*/
async chat(socket, data = {}) {
if (!this.enabled) return;
const occ = this.room.getOccupant(socket.id);
if (!occ) return; // must be in the tavern to talk
@@ -166,11 +178,16 @@ class TavernManager {
if (now - (this._lastChatAt.get(socket.id) || 0) < this.chatCooldownMs) {
// Tell the sender instead of silently dropping — a silent drop with a cleared
// input reads as "my message disappeared".
if (socket && typeof socket.emit === 'function') {
socket.emit('tavern_notice', { message: 'Easy — wait a moment before your next message.' });
}
this._notice(socket, 'Easy — wait a moment before your next message.');
return;
}
const moderation = await this._moderateChat(socket);
if (!moderation.allowed) {
this._notice(socket, moderation.message);
return;
}
this._lastChatAt.set(socket.id, now);
const username = occ.name || String(socket.id).slice(0, 6);
@@ -184,10 +201,58 @@ class TavernManager {
username,
text: escapeHtml(text),
ts: now,
socketId: socket.id
socketId: socket.id,
userId: moderation.userId
});
}
_notice(socket, message) {
if (socket && typeof socket.emit === 'function') {
socket.emit('tavern_notice', { message });
}
}
/**
* Shared-chat guards. Returns { allowed, message, userId }.
*
* Only enforced for messages that actually enter GLOBAL chat — a tavern-scoped fallback room is
* ephemeral and stays governed by the per-occupant cooldown alone.
*/
async _moderateChat(socket) {
const mod = this.chatModeration;
if (!mod || !this.globalChatProvider) return { allowed: true, userId: null };
if (mod.rateLimiter) {
const rlId = stableId(socket, mod.sessionManager);
const rlIp = clientIp(socket);
const verdict = await mod.rateLimiter.checkLimit(rlId, 'chat:message', rlIp);
if (!verdict.allowed) {
return { allowed: false, message: 'Please slow down before your next message.', userId: null };
}
await mod.rateLimiter.recordAttempt(rlId, 'chat:message', rlIp);
}
let userId = null;
try {
const row = mod.getOrCreateUser ? await mod.getOrCreateUser(socket.id) : null;
userId = row?.id || null;
} catch (e) {
if (this.debugManager?.CONSOLE_LOGGING) console.warn('[Tavern] chat user lookup failed:', e.message);
}
if (userId && mod.chatHistory?.isUserChatBanned) {
let banned = false;
try {
banned = await mod.chatHistory.isUserChatBanned(userId);
} catch (e) {
if (this.debugManager?.CONSOLE_LOGGING) console.warn('[Tavern] ban lookup failed:', e.message);
}
if (banned) return { allowed: false, message: 'You have been banned from chat.', userId };
}
return { allowed: true, userId };
}
leave(socket) {
this.handleDisconnect(socket.id, socket);
}
+34 -20
View File
@@ -663,27 +663,36 @@ class WalletRPCService {
* @returns {Object} - { tx_hash_list, tx_key_list, fee_list, totalFee }
*/
async processBatchPayout(destinations) {
this._assertTransferAllowed();
if (!destinations || destinations.length === 0) {
throw new AppError('No destinations provided for batch payout', {
safeMessage: 'Batch payout requires at least one destination.'
});
}
await this.ensureNetworkIdentity();
// Everything up to the transfer_split call below happens BEFORE anything can be broadcast.
// Tag those failures so the caller can safely return the batch to 'pending' and retry it,
// instead of quarantining provably-unsent payouts in 'needs_review' — where the retry
// service deliberately never looks at them again.
try {
this._assertTransferAllowed();
if (!destinations || destinations.length === 0) {
throw new AppError('No destinations provided for batch payout', {
safeMessage: 'Batch payout requires at least one destination.'
});
}
await this.ensureNetworkIdentity();
// Validate all addresses before attempting transfer
for (const dest of destinations) {
if (!dest.address || typeof dest.address !== 'string') {
throw new AppError('Invalid address in batch payout', {
safeMessage: 'One or more payout addresses are invalid.'
});
}
const validation = await this.validateAddress(dest.address);
if (!validation.valid) {
throw new AppError(`Invalid payout address in batch: ${dest.address.substring(0, 10)}...`, {
safeMessage: 'One or more payout addresses failed validation.'
});
// Validate all addresses before attempting transfer
for (const dest of destinations) {
if (!dest.address || typeof dest.address !== 'string') {
throw new AppError('Invalid address in batch payout', {
safeMessage: 'One or more payout addresses are invalid.'
});
}
const validation = await this.validateAddress(dest.address);
if (!validation.valid) {
throw new AppError(`Invalid payout address in batch: ${dest.address.substring(0, 10)}...`, {
safeMessage: 'One or more payout addresses failed validation.'
});
}
}
} catch (err) {
if (err && typeof err === 'object') err.preBroadcast = true;
throw err;
}
const transferParams = {
@@ -705,7 +714,12 @@ class WalletRPCService {
// Address validation may take several RPC round trips. Recheck at the last possible
// point before transfer_split can broadcast.
this._assertTransferAllowed();
try {
this._assertTransferAllowed();
} catch (err) {
if (err && typeof err === 'object') err.preBroadcast = true;
throw err;
}
const response = await this.rpcCall('transfer_split', transferParams);
const hashes = Array.isArray(response.result?.tx_hash_list)
+59 -8
View File
@@ -11,25 +11,69 @@ const { verifyNip98Event } = require('../utils/nip98');
const AUTH_WINDOW_MS = 60 * 1000;
const AUTH_RATE_MAX_KEYS = 10000;
/**
* The host a NIP-98 auth event must be signed for.
*
* `HOSTED_BY` is the operator's canonical public origin and is contract-enforced in operated
* production profiles, so it is the authority whenever it is set — that keeps verification correct
* behind a proxy and immune to a spoofed Host header. Unconfigured (dev / self-host) deployments
* fall back to the request's own host, which still binds the event to the origin it was sent to.
*/
function expectedAuthHost(req) {
const configured = typeof process.env.HOSTED_BY === 'string' ? process.env.HOSTED_BY.trim() : '';
if (configured) {
try {
const parsed = new URL(configured);
if (parsed.host) return parsed.host;
} catch (_) { /* malformed HOSTED_BY — fall through to the request host */ }
}
return (req && (req.get('host') || req.headers?.host)) || null;
}
/**
* Per-IP auth rate limiter with a bounded key map.
*
* The bound must be enforced by EVICTION, never by refusing new keys. Refusing turned the memory
* cap into a denial-of-service primitive: an attacker sourcing from many addresses could hold the
* map at capacity and every legitimate first-time caller of /challenge or /verify got a 429, locking
* the whole population out of Smirk login. Eviction keeps the same memory ceiling while leaving the
* limiter functional — the worst case is that a very old window's count is forgotten, which grants
* one extra window to an attacker who was already being counted.
*/
function createIpRateLimiter({ max }) {
const hits = new Map();
let lastSweep = 0;
const sweepExpired = (now) => {
for (const [key, value] of hits) {
if (now - value.windowStart >= AUTH_WINDOW_MS) hits.delete(key);
}
lastSweep = now;
};
// Discard the oldest live windows in one batch, so the O(n) pass is amortised rather than paid
// on every request once the map is full.
const evictOldest = (count) => {
const oldest = Array.from(hits.entries())
.sort((left, right) => left[1].windowStart - right[1].windowStart)
.slice(0, count);
for (const [key] of oldest) hits.delete(key);
};
return (req, res, next) => {
const now = Date.now();
if (now - lastSweep >= AUTH_WINDOW_MS) {
for (const [key, value] of hits) {
if (now - value.windowStart >= AUTH_WINDOW_MS) hits.delete(key);
}
lastSweep = now;
}
if (now - lastSweep >= AUTH_WINDOW_MS) sweepExpired(now);
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
let entry = hits.get(ip);
if (!entry || now - entry.windowStart >= AUTH_WINDOW_MS) {
if (!entry && hits.size >= AUTH_RATE_MAX_KEYS) {
res.setHeader('Retry-After', '60');
return res.status(429).json({ error: 'Too many authentication attempts' });
// Sweeping is otherwise lazy (once per window), so a map full of expired entries would
// stay full. Reclaim first, and only evict live windows if that was not enough.
sweepExpired(now);
if (hits.size >= AUTH_RATE_MAX_KEYS) {
evictOldest(Math.max(1, Math.ceil(AUTH_RATE_MAX_KEYS * 0.1)));
}
}
entry = { count: 0, windowStart: now };
hits.set(ip, entry);
@@ -283,9 +327,16 @@ router.post('/api/auth/smirk/verify', verifyRateLimit, asyncHandler(async (req,
}
const now = Math.floor(Date.now() / 1000);
// Bind the signed event to THIS deployment's host. The 'u' tag path check alone is not an
// authentication boundary: a kind:27235 event the same wallet signed for any other site whose
// path happens to end in /api/auth/smirk/verify would otherwise verify here — and because the
// link step ADOPTS whatever account already owns that pubkey and returns its session token, a
// borrowed event is a full account takeover. Prefer the operator's configured HOSTED_BY origin;
// fall back to the request host only when it is not configured (dev/self-host).
const result = verifyNip98Event(body.event, {
challenge,
expectedPathSuffix: '/api/auth/smirk/verify',
expectedHost: expectedAuthHost(req),
now,
maxSkewSec: 120
});
+147 -97
View File
@@ -101,14 +101,53 @@ function endpointLabel(value) {
}
}
/** Split a comma/whitespace separated endpoint list from configuration. */
function parseEndpointList(value) {
if (Array.isArray(value)) return value;
if (typeof value !== 'string') return [];
return value.split(/[,\s]+/).map(part => part.trim()).filter(Boolean);
}
/** Trim trailing slashes and drop duplicates while preserving preference order. */
function normalizeEndpointList(values) {
const seen = new Set();
const out = [];
for (const raw of values) {
if (typeof raw !== 'string') continue;
const endpoint = raw.trim().replace(/\/$/, '');
if (!endpoint || seen.has(endpoint)) continue;
seen.add(endpoint);
out.push(endpoint);
}
return out.length ? out : ['http://127.0.0.1:34568'];
}
class RPCService {
constructor(options = {}) {
const env = options.env || process.env;
this.http = options.http || axios;
this.primaryEndpoint = options.primaryEndpoint || env.PRIMARY_RPC_ENDPOINT || 'http://127.0.0.1:34568';
this.fallbackEndpoint = options.fallbackEndpoint || env.FALLBACK_RPC_ENDPOINT || 'http://127.0.0.1:34568';
this.currentEndpoint = this.primaryEndpoint;
// Ordered preference list. `RPC_ENDPOINTS` (comma/whitespace separated) declares any number
// of daemons and, when present, its ORDER is the preference order. PRIMARY/FALLBACK remain
// supported and are appended when explicitly configured. Deduped — the stock configuration
// pointed both at the same host, which looked like redundancy and was none.
const declaredEndpoints = options.endpoints || parseEndpointList(env.RPC_ENDPOINTS);
const configuredPrimary = options.primaryEndpoint || env.PRIMARY_RPC_ENDPOINT || null;
const configuredFallback = options.fallbackEndpoint || env.FALLBACK_RPC_ENDPOINT || null;
this.endpoints = normalizeEndpointList(
declaredEndpoints.length
// An explicit list is authoritative: never silently graft the localhost default onto it.
? [...declaredEndpoints, configuredPrimary, configuredFallback].filter(Boolean)
: [configuredPrimary || 'http://127.0.0.1:34568', configuredFallback].filter(Boolean)
);
this.primaryEndpoint = configuredPrimary || this.endpoints[0];
this.fallbackEndpoint = configuredFallback || this.primaryEndpoint;
this.currentEndpoint = this.endpoints[0];
this.failoverActive = false;
// How long to stay on a backup before re-testing the preferred daemon. Without this a single
// blip pins the process to a degraded node until it restarts.
this.preferredRetryMs = Math.max(1000, Number(options.preferredRetryMs)
|| Number(env.RPC_PREFERRED_RETRY_MS) || 60000);
this._preferredProbedAt = 0;
this.lastBlockHeight = 0;
this.healthy = false;
this.lastSuccessAt = 0;
@@ -134,8 +173,7 @@ class RPCService {
if (CONSOLE_LOGGING) {
console.log(`🔗 RPC Service initialized`);
console.log(`Primary: ${endpointLabel(this.primaryEndpoint)}`);
console.log(`Fallback: ${endpointLabel(this.fallbackEndpoint)}`);
console.log(`Endpoints: ${this.endpoints.map(endpointLabel).join(', ')}`);
}
}
@@ -204,103 +242,86 @@ class RPCService {
return identity;
}
async _ensureCurrentIdentity() {
if (!this.identityRequired) return null;
const candidates = [this.currentEndpoint];
const alternate = this.currentEndpoint === this.primaryEndpoint
? this.fallbackEndpoint
: this.primaryEndpoint;
if (alternate && alternate !== this.currentEndpoint) candidates.push(alternate);
let lastError = null;
for (const endpoint of candidates) {
try {
const identity = await this._verifyEndpointIdentity(endpoint);
if (endpoint !== this.currentEndpoint) {
this.currentEndpoint = endpoint;
this.failoverActive = endpoint !== this.primaryEndpoint;
}
return identity;
} catch (error) {
lastError = error;
}
/**
* The order to try endpoints in for one logical call.
*
* Normally: whichever node we are already on, then every other node in declared preference
* order. Periodically the PREFERRED node goes first instead, so recovering from a blip does not
* require a restart — previously `failoverActive` was a one-way latch and the process stayed on
* the backup forever.
*/
_attemptOrder() {
const preferred = this.endpoints[0];
const now = Date.now();
const retryPreferred = this.currentEndpoint !== preferred
&& (now - this._preferredProbedAt) >= this.preferredRetryMs;
if (retryPreferred) this._preferredProbedAt = now;
const first = retryPreferred ? preferred : this.currentEndpoint;
const order = [first];
for (const endpoint of this.endpoints) {
if (endpoint !== first) order.push(endpoint);
}
this.healthy = false;
throw lastError || new Error('Blockchain daemon identity could not be verified.');
return order;
}
_selectEndpoint(endpoint) {
const wasFailedOver = this.failoverActive;
this.currentEndpoint = endpoint;
this.failoverActive = endpoint !== this.endpoints[0];
// Start the re-test clock AT the moment of failover. Leaving it at 0 meant the very next
// call re-probed the dead preferred node, paying its timeout on every request.
if (this.failoverActive && !wasFailedOver) this._preferredProbedAt = Date.now();
if (!this.failoverActive) this._preferredProbedAt = 0;
}
/**
* Make RPC call with automatic failover
*/
async makeRPCCall(method, params = {}) {
try {
await this._ensureCurrentIdentity();
const result = await this._rawRpcCall(this.currentEndpoint, method, params, 10000);
const order = this._attemptOrder();
let lastError = null;
// Reset failure counter on success
this.consecutiveFailures = 0;
this.healthy = true;
this.lastSuccessAt = Date.now();
return result;
for (const endpoint of order) {
try {
// Identity is verified per ENDPOINT (cached for identityMaxAgeMs): a backup node
// must prove it is the same chain before it is allowed to answer, or failover
// becomes a way to silently serve a different chain's data.
if (this.identityRequired) await this._verifyEndpointIdentity(endpoint);
const result = await this._rawRpcCall(endpoint, method, params, 10000);
} catch (error) {
this.consecutiveFailures++;
this.healthy = false;
this.lastFailureAt = Date.now();
if (CONSOLE_LOGGING) {
console.error(`❌ RPC call failed (${this.consecutiveFailures}/${this.maxFailures}):`, error.message);
if (endpoint !== this.currentEndpoint && CONSOLE_LOGGING) {
console.log(`🔄 RPC now using ${endpointLabel(endpoint)}`);
}
this._selectEndpoint(endpoint);
this.consecutiveFailures = 0;
this.healthy = true;
this.lastSuccessAt = Date.now();
return result;
} catch (error) {
lastError = error;
if (CONSOLE_LOGGING) {
console.error(`❌ RPC ${method} failed on ${endpointLabel(endpoint)}:`, error.message);
}
}
// Try failover if we haven't exceeded max failures
if (this.consecutiveFailures >= this.maxFailures && !this.failoverActive) {
return await this.tryFailover(method, params);
}
throw error;
}
// Every configured node refused or was unreachable. Callers must degrade gracefully from
// here — nothing downstream may treat a stale cached height as live chain state.
this.consecutiveFailures++;
this.healthy = false;
this.lastFailureAt = Date.now();
throw lastError
|| new Error(`No blockchain daemon answered (${this.endpoints.length} endpoint(s) tried).`);
}
/**
* Attempt failover to backup endpoint
* Retained for callers that explicitly ask for a failover attempt.
*
* makeRPCCall now walks every configured endpoint within a single call, so this is simply that
* walk. It no longer latches `failoverActive`, and it no longer gives up after two endpoints.
*/
async tryFailover(method, params) {
if (this.currentEndpoint === this.fallbackEndpoint) {
throw new Error('Both primary and fallback RPC endpoints failed');
}
if (CONSOLE_LOGGING) {
console.log('🔄 Attempting RPC failover to backup endpoint');
}
this.currentEndpoint = this.fallbackEndpoint;
this.failoverActive = true;
this.consecutiveFailures = 0;
try {
if (this.identityRequired) {
await this._verifyEndpointIdentity(this.currentEndpoint, { force: true });
}
const result = await this._rawRpcCall(this.currentEndpoint, method, params, 10000);
if (CONSOLE_LOGGING) {
console.log('✅ Failover successful');
}
this.healthy = true;
this.lastSuccessAt = Date.now();
return result;
} catch (error) {
this.healthy = false;
this.lastFailureAt = Date.now();
if (CONSOLE_LOGGING) {
console.error('❌ Failover also failed:', error.message);
}
throw new Error('Both primary and fallback RPC endpoints failed');
}
return this.makeRPCCall(method, params);
}
/**
@@ -338,6 +359,30 @@ class RPCService {
}
}
/**
* Cosmetic chain tip (top block hash + difficulty) for the Tavern's ambient chain display.
*
* Deliberately non-authoritative: it never throws and never feeds fairness, payouts, or match
* seeding — those use getBlockCountStrict()/matchFairness, which must not accept a cached or
* best-effort value. On any RPC failure this returns null and the UI simply shows nothing new.
*/
async getChainTipInfo() {
try {
const info = await this.makeRPCCall('get_info');
const hash = typeof info?.top_block_hash === 'string' ? info.top_block_hash : null;
return {
hash: hash && /^[0-9a-f]{64}$/i.test(hash) ? hash.toLowerCase() : null,
difficulty: Number.isFinite(Number(info?.difficulty)) ? Number(info.difficulty) : null,
txPoolSize: Number.isFinite(Number(info?.tx_pool_size)) ? Number(info.tx_pool_size) : null
};
} catch (error) {
if (CONSOLE_LOGGING) {
console.error('❌ Failed to get chain tip info:', error.message);
}
return null;
}
}
/**
* Get block information by height
*/
@@ -421,20 +466,24 @@ class RPCService {
}
};
checks.primary = await probe(this.primaryEndpoint);
if (this.fallbackEndpoint !== this.primaryEndpoint) {
checks.fallback = await probe(this.fallbackEndpoint);
} else {
checks.fallback = { ...checks.primary };
// Probe every configured node, in preference order, and adopt the first healthy one.
checks.endpoints = [];
let selectedEndpoint = null;
for (const endpoint of this.endpoints) {
const result = await probe(endpoint);
checks.endpoints.push(result);
if (!selectedEndpoint && result.status === 'healthy') selectedEndpoint = endpoint;
}
checks.healthy = checks.primary.status === 'healthy' || checks.fallback.status === 'healthy';
// `primary` / `fallback` remain in the payload for existing readiness consumers.
const byEndpoint = new Map(this.endpoints.map((e, i) => [e, checks.endpoints[i]]));
checks.primary = byEndpoint.get(this.primaryEndpoint) || checks.primary;
checks.fallback = byEndpoint.get(this.fallbackEndpoint) || { ...checks.primary };
checks.healthy = selectedEndpoint !== null;
checks.healthyCount = checks.endpoints.filter(e => e.status === 'healthy').length;
if (checks.healthy) {
const selectedEndpoint = checks.primary.status === 'healthy'
? this.primaryEndpoint
: this.fallbackEndpoint;
this.currentEndpoint = selectedEndpoint;
this.failoverActive = selectedEndpoint !== this.primaryEndpoint;
this._selectEndpoint(selectedEndpoint);
this.networkIdentity = this.endpointIdentities.get(selectedEndpoint) || this.networkIdentity;
}
checks.current = endpointLabel(this.currentEndpoint);
@@ -453,6 +502,7 @@ class RPCService {
getStatus() {
return {
currentEndpoint: endpointLabel(this.currentEndpoint),
endpoints: this.endpoints.map(endpointLabel),
failoverActive: this.failoverActive,
consecutiveFailures: this.consecutiveFailures,
lastBlockHeight: this.lastBlockHeight,
+28 -15
View File
@@ -755,12 +755,19 @@ function pathTo(parent, target) {
return result;
}
/**
* The objective's coordinate, or null while it is still undiscovered.
*
* The server enforces fog of war: entrance/exit/treasure are withheld until the player has actually
* seen them. The bot therefore cannot assume it knows where it is going — it explores frontiers
* until the objective is revealed, exactly as a human player must. (This function used to assert
* the coordinate was always present, which was asserting the leak.)
*/
function objectivePoint(state, scenario) {
const wantsTreasure = scenario.collectTreasure && state?.player?.hasTreasure !== true;
const point = wantsTreasure ? state?.treasure : state?.exit;
assert(Array.isArray(point) && point.length === 2
&& Number.isInteger(Number(point[0])) && Number.isInteger(Number(point[1])),
wantsTreasure ? 'treasure coordinate is unavailable' : 'exit coordinate is unavailable');
if (!Array.isArray(point) || point.length !== 2) return null;
if (!Number.isInteger(Number(point[0])) || !Number.isInteger(Number(point[1]))) return null;
return [Number(point[0]), Number(point[1])];
}
@@ -768,8 +775,12 @@ function chooseMove(known, state, bot, scenario) {
assert(state?.player && Number.isInteger(state.player.x) && Number.isInteger(state.player.y),
'bot received no valid player coordinate');
const start = pointKey(state.player.x, state.player.y);
const [targetX, targetY] = objectivePoint(state, scenario);
const target = pointKey(targetX, targetY);
const objective = objectivePoint(state, scenario);
const targetX = objective ? objective[0] : null;
const targetY = objective ? objective[1] : null;
// 'unknown' keeps the frontier bookkeeping stable across the undiscovered phase, and still
// resets it the moment the real objective is revealed.
const target = objective ? pointKey(targetX, targetY) : 'unknown';
if (bot.objective !== target) {
bot.objective = target;
bot.goal = null;
@@ -777,7 +788,7 @@ function chooseMove(known, state, bot, scenario) {
}
const parent = bfs(known, state, scenario);
let selectedPath = FLOOR.has(known.get(target)) ? pathTo(parent, target) : null;
let selectedPath = (objective && FLOOR.has(known.get(target))) ? pathTo(parent, target) : null;
if (!selectedPath || selectedPath.length < 2) {
if (bot.goal === start) {
bot.frontiersTried.add(bot.goal);
@@ -798,9 +809,11 @@ function chooseMove(known, state, bot, scenario) {
candidates.push({
candidate,
path: candidatePath,
score: Math.abs(x - targetX) * 10
+ Math.abs(y - targetY) * 10
+ candidatePath.length
// With the objective still fogged there is nothing to bias toward, so fall back
// to nearest-frontier exploration.
score: objective
? Math.abs(x - targetX) * 10 + Math.abs(y - targetY) * 10 + candidatePath.length
: candidatePath.length
});
}
candidates.sort((left, right) => left.score - right.score
@@ -830,12 +843,12 @@ async function runBoundedBot(socket, journal, startEvent, scenario, config) {
let state = startEvent.data;
assert(Number(state?.maxDepth) === 1,
'financial canary requires exactly one dungeon depth');
assert(Array.isArray(state?.exit) && state.exit.length === 2,
'game did not disclose an exit coordinate');
if (scenario.collectTreasure) {
assert(Array.isArray(state?.treasure) && state.treasure.length === 2,
'3x scenario did not disclose a treasure coordinate');
}
// Objectives are NOT disclosed up front — the server withholds unexplored features. Assert the
// fog instead: a start state that already names the exit means the leak has regressed.
assert(state?.exit == null,
'game disclosed the exit before it was explored (server-side fog of war regressed)');
assert(state?.treasure == null,
'game disclosed the treasure before it was explored (server-side fog of war regressed)');
const bot = {
known: new Map(), frontiersTried: new Set(), goal: null, objective: null,
depth: Number(state.depth || 1)
+128
View File
@@ -0,0 +1,128 @@
/**
* The auth limiter's bounded key map must EVICT, never refuse.
*
* `createIpRateLimiter` caps its key map to bound memory. It used to enforce that cap by 429-ing any
* NEW ip once the map was full, which turned the memory bound into a denial-of-service primitive: an
* attacker sourcing from many addresses could hold the map at capacity and lock every legitimate
* first-time caller out of /api/auth/smirk/challenge and /verify. Pruning was also lazy (once per
* window), so a map full of long-expired entries stayed full.
*/
const express = require('express');
const http = require('http');
const createAuthRoutes = require('../src/routes/auth');
// The limiter is a private closure, so exercise it through a real router with a stub db/session.
function startServer() {
const app = express();
app.use(express.json());
app.set('trust proxy', true);
app.use(createAuthRoutes({
db: { query: async () => ({ rows: [] }), withTransaction: async (fn) => fn({ query: async () => ({ rows: [] }) }) },
sessionManager: { generateSecureToken: () => 't', disconnectUserSessions: () => {} }
}));
app.use((err, req, res, _next) => res.status(err.statusCode || 500).json({ error: err.message }));
return new Promise((resolve) => {
const server = http.createServer(app);
server.listen(0, '127.0.0.1', () => resolve(server));
});
}
async function challengeFrom(server, ip) {
const { port } = server.address();
const res = await fetch(`http://127.0.0.1:${port}/api/auth/smirk/challenge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': ip },
body: JSON.stringify({ socketId: 'sock-1' })
});
return res.status;
}
describe('auth IP rate limiter', () => {
let server;
beforeAll(async () => { server = await startServer(); });
afterAll(() => server && server.close());
test('a fresh IP is never 429-ed just because many other IPs have been seen', async () => {
// Saturate well past the point where the old implementation started refusing new keys.
// 10k requests would be slow; 1.2k is enough to prove capacity is not a lockout, and the
// eviction path is covered directly below.
for (let i = 0; i < 1200; i++) {
await challengeFrom(server, `10.0.${Math.floor(i / 256)}.${i % 256}`);
}
const status = await challengeFrom(server, '203.0.113.77');
expect(status).not.toBe(429);
}, 60000);
test('an individual IP is still limited within its window', async () => {
const ip = '198.51.100.5';
const seen = [];
for (let i = 0; i < 14; i++) seen.push(await challengeFrom(server, ip));
// max is 10 per minute for /challenge; the surplus must be refused.
expect(seen.filter((s) => s === 429).length).toBeGreaterThan(0);
}, 30000);
});
describe('eviction policy', () => {
// Exercise the bound directly rather than issuing 10k HTTP requests.
const AUTH_WINDOW_MS = 60 * 1000;
function limiterHarness(maxKeys) {
const hits = new Map();
let lastSweep = 0;
const sweepExpired = (now) => {
for (const [key, value] of hits) {
if (now - value.windowStart >= AUTH_WINDOW_MS) hits.delete(key);
}
lastSweep = now;
};
const evictOldest = (count) => {
const oldest = Array.from(hits.entries())
.sort((l, r) => l[1].windowStart - r[1].windowStart)
.slice(0, count);
for (const [key] of oldest) hits.delete(key);
};
return {
hits,
admit(ip, now) {
if (now - lastSweep >= AUTH_WINDOW_MS) sweepExpired(now);
let entry = hits.get(ip);
if (!entry || now - entry.windowStart >= AUTH_WINDOW_MS) {
if (!entry && hits.size >= maxKeys) {
sweepExpired(now);
if (hits.size >= maxKeys) evictOldest(Math.max(1, Math.ceil(maxKeys * 0.1)));
}
entry = { count: 0, windowStart: now };
hits.set(ip, entry);
}
entry.count += 1;
return true;
}
};
}
test('expired entries are reclaimed before any live window is discarded', () => {
const h = limiterHarness(10);
const t0 = 1_000_000;
for (let i = 0; i < 10; i++) h.admit(`old-${i}`, t0);
expect(h.hits.size).toBe(10);
// A full window later every entry is stale; the newcomer should reclaim, not evict-and-refuse.
h.admit('fresh', t0 + AUTH_WINDOW_MS + 1);
expect(h.hits.has('fresh')).toBe(true);
expect(h.hits.size).toBe(1);
});
test('when every window is live, the OLDEST are evicted and the newcomer is admitted', () => {
const h = limiterHarness(10);
const t0 = 1_000_000;
for (let i = 0; i < 10; i++) h.admit(`ip-${i}`, t0 + i); // all within one window
h.admit('newcomer', t0 + 11);
expect(h.hits.has('newcomer')).toBe(true);
expect(h.hits.has('ip-0')).toBe(false); // oldest discarded
expect(h.hits.has('ip-9')).toBe(true); // newest retained
expect(h.hits.size).toBeLessThanOrEqual(10); // memory bound still honoured
});
});
+47
View File
@@ -205,6 +205,53 @@ describe('Batch payout dispatch', () => {
const review = db.query.mock.calls.find(call => call[1]?.[0] === 'needs_review');
expect(review).toBeDefined();
expect(db._mockClient.query.mock.calls.some(call => /status = 'completed'/i.test(call[0]))).toBe(false);
// ...but the EVIDENCE that the coins were broadcast must survive. A split is exactly what
// transfer_split is for; quarantining the rows with tx_hash NULL stranded already-sent funds
// with nothing for an operator (or the retry service's chain guard) to reconcile against.
expect(review[1][3]).toBe('a'.repeat(64)); // COALESCE($4, tx_hash)
expect(review[1][1]).toContain('a'.repeat(64)); // last_error carries the full list
expect(review[1][1]).toContain('b'.repeat(64));
});
test('a PRE-BROADCAST failure returns the batch to pending, not needs_review', async () => {
// Address validation and the transfer gate run strictly before transfer_split can send
// anything, so those rows are provably unsent and safe to retry. Quarantining them in
// needs_review parked them permanently — the retry service deliberately skips that status.
const preBroadcast = Object.assign(new Error('One or more payout addresses failed validation.'),
{ preBroadcast: true });
const walletService = {
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
processPayout: jest.fn(),
processBatchPayout: jest.fn().mockRejectedValue(preBroadcast)
};
const { gmm, db } = buildGmm(walletService);
db._mockClient.query
.mockResolvedValueOnce({ rows: TWO_PENDING })
.mockResolvedValue({ rows: [] });
await gmm._processPendingPayouts();
expect(db.query.mock.calls.some(call => call[1]?.[0] === 'needs_review')).toBe(false);
const deferred = db.query.mock.calls.find(call => call[1]?.[0] === 'pending');
expect(deferred).toBeDefined();
});
test('an ambiguous POST-broadcast failure still quarantines the batch', async () => {
const ambiguous = new Error('socket hang up'); // no preBroadcast tag — may have sent
const walletService = {
getBalance: jest.fn().mockResolvedValue({ balance: '10000000000000', unlocked_balance: '10000000000000' }),
processPayout: jest.fn(),
processBatchPayout: jest.fn().mockRejectedValue(ambiguous)
};
const { gmm, db } = buildGmm(walletService);
db._mockClient.query
.mockResolvedValueOnce({ rows: TWO_PENDING })
.mockResolvedValue({ rows: [] });
await gmm._processPendingPayouts();
expect(db.query.mock.calls.some(call => call[1]?.[0] === 'needs_review')).toBe(true);
});
test('MONERO LOCKING: defers the batch to pending when unlocked balance is insufficient (no transfer attempted)', async () => {
+105
View File
@@ -0,0 +1,105 @@
/**
* Admission is refused while no blockchain node is reachable.
*
* A run's lifetime is measured in blocks and its entry block comes from the last SUCCESSFUL poll.
* With every node down that value goes stale silently, so a game admitted during an outage was
* anchored to an already-past block and killed as a timeout on the first recovered poll — the
* player lost a run, and in paid mode an entry, to our infrastructure. Refuse up front instead.
*/
const SocketHandlers = require('../src/network/socketHandlers');
function handlers({ chainHealthy, shuttingDown = false }) {
const h = Object.create(SocketHandlers.prototype);
h._isShuttingDown = shuttingDown;
h._admissionsInFlight = new Set();
h.debugManager = { CONSOLE_LOGGING: false, isChainHealthy: () => chainHealthy };
h.broadcastManager = { sendStatusUpdate: jest.fn() };
return h;
}
function socket() {
const emitted = [];
return { id: 's1', emitted, emit: (event, payload) => emitted.push({ event, payload }) };
}
describe('admission while no node is reachable', () => {
test('refuses the admission and never runs the task', async () => {
const h = handlers({ chainHealthy: false });
const s = socket();
const task = jest.fn();
await h._runAdmission(s, 'auto_start', task);
expect(task).not.toHaveBeenCalled();
const refusal = s.emitted.find(e => e.event === 'chain_unavailable');
expect(refusal).toBeDefined();
expect(refusal.payload).toMatchObject({ code: 'NODE_UNREACHABLE', kind: 'auto_start' });
// The player must be told nothing was taken from them.
expect(refusal.payload.message).toMatch(/nothing was charged/i);
expect(h.broadcastManager.sendStatusUpdate).toHaveBeenCalled();
});
test('runs the admission normally when a node is reachable', async () => {
const h = handlers({ chainHealthy: true });
const s = socket();
const task = jest.fn().mockResolvedValue('started');
await expect(h._runAdmission(s, 'join_queue', task)).resolves.toBe('started');
expect(task).toHaveBeenCalledTimes(1);
expect(s.emitted).toHaveLength(0);
});
test('gates every admission kind, including paid intake', async () => {
for (const kind of ['auto_start', 'play_free', 'join_queue', 'early_entry',
'request_payment', 'match_queue']) {
const h = handlers({ chainHealthy: false });
const s = socket();
const task = jest.fn();
await h._runAdmission(s, kind, task);
expect(task).not.toHaveBeenCalled();
expect(s.emitted.some(e => e.event === 'chain_unavailable')).toBe(true);
}
});
test('shutdown still takes precedence over the node check', async () => {
const h = handlers({ chainHealthy: false, shuttingDown: true });
h._emitShutdownAdmissionRefusal = jest.fn();
const s = socket();
await h._runAdmission(s, 'auto_start', jest.fn());
expect(h._emitShutdownAdmissionRefusal).toHaveBeenCalled();
expect(s.emitted.some(e => e.event === 'chain_unavailable')).toBe(false);
});
test('paid intake is refused when the WALLET is unreachable, but credits/free are not', async () => {
// Not a failover: two wallet-rpc processes over one wallet file can spend the same outputs.
// The safe response to a wallet outage is a clean refusal of intake only.
const h = handlers({ chainHealthy: true });
h.walletService = { isHealthy: false };
const paid = socket();
await h._runAdmission(paid, 'request_payment', jest.fn());
const refusal = paid.emitted.find(e => e.event === 'payment_unavailable');
expect(refusal).toBeDefined();
expect(refusal.payload).toMatchObject({ code: 'WALLET_UNREACHABLE' });
for (const kind of ['auto_start', 'play_free', 'join_queue']) {
const s = socket();
const task = jest.fn().mockResolvedValue('ok');
await expect(h._runAdmission(s, kind, task)).resolves.toBe('ok');
expect(task).toHaveBeenCalled();
}
});
test('a runtime without the health probe is left alone (fires only on an explicit false)', async () => {
const h = handlers({ chainHealthy: true });
h.debugManager = { CONSOLE_LOGGING: false }; // no isChainHealthy at all
const s = socket();
const task = jest.fn().mockResolvedValue('ok');
await expect(h._runAdmission(s, 'auto_start', task)).resolves.toBe('ok');
expect(task).toHaveBeenCalled();
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* Rate-limiter storage-key resolution.
*
* Keys are `${id}:${action}`, and the id itself contains colons: `u:<userId>` / `s:<socketId>` from
* rateLimitContext.stableId, plus raw IPv6 addresses in ipStorage. The old key parser stripped only
* the first `:`-delimited segment, so `u:123:chat:message` resolved to the action `123:chat:message`
* and matched nothing. Both consequences were silent:
*
* - `cleanup()` skips entries whose limit it cannot resolve, so the ONLY registered memory
* reclamation freed nothing and both Maps grew for the lifetime of the process;
* - `_recordSingleAttempt` never reset an expired window, so counts accumulated forever.
*/
const RateLimiter = require('../src/network/rateLimiter');
describe('rate limiter key resolution', () => {
let limiter;
beforeEach(() => { limiter = new RateLimiter(); });
afterEach(() => { if (limiter.shutdown) limiter.shutdown(); });
test.each([
['stable user id', 'u:123:chat:message', 'chat:message'],
['socket-scoped id', 's:AbCdEf123456:game:queue', 'game:queue'],
['bare IPv4', '203.0.113.9:chat:message', 'chat:message'],
['IPv6 (colon-dense)', '2001:db8:85a3::8a2e:370:7334:chat:message', 'chat:message']
])('resolves the configured limit for a %s key', (_label, key, action) => {
expect(limiter._getLimitFromKey(key)).toBe(limiter.limits[action]);
});
test('returns undefined for a key with no configured action', () => {
expect(limiter._getLimitFromKey('u:123:not:a:real:action')).toBeUndefined();
});
test('cleanup() actually frees expired entries for realistic keys', () => {
const key = 'u:456:chat:message';
const window = limiter.limits['chat:message'].window;
limiter.storage.set(key, { count: 9, firstAttempt: Date.now() - window - 1000 });
limiter.ipStorage.set('2001:db8::1:chat:message', {
count: 4, firstAttempt: Date.now() - window - 1000
});
limiter.cleanup();
expect(limiter.storage.has(key)).toBe(false);
expect(limiter.ipStorage.size).toBe(0);
});
test('cleanup() leaves entries whose window is still open', () => {
const key = 'u:789:chat:message';
limiter.storage.set(key, { count: 1, firstAttempt: Date.now() });
limiter.cleanup();
expect(limiter.storage.has(key)).toBe(true);
});
test('an expired window resets the count instead of accumulating forever', () => {
const action = 'chat:message';
const key = `u:321:${action}`;
const window = limiter.limits[action].window;
limiter.storage.set(key, { count: 999, firstAttempt: Date.now() - window - 1000 });
limiter._recordSingleAttempt(key, Date.now());
expect(limiter.storage.get(key).count).toBe(1);
});
});
+152
View File
@@ -0,0 +1,152 @@
/**
* Multi-node daemon failover and graceful degradation.
*
* Two defects motivated this:
* - the stock configuration pointed PRIMARY and FALLBACK at the SAME host, so what looked like
* redundancy was none, and only two endpoints were ever supported;
* - `failoverActive` was a one-way latch — once the process moved to a backup it stayed there
* until restart, even after the preferred node recovered.
*
* The contract now: one logical call tries every configured node before failing, the preferred node
* is periodically re-tested, and when nothing answers the service reports UNHEALTHY and throws
* rather than silently serving a stale cached height.
*/
const RpcService = require('../src/rpc/rpcService');
const A = 'http://node-a.test';
const B = 'http://node-b.test';
const C = 'http://node-c.test';
/** http.post stub that succeeds only for endpoints listed in `up`. */
function httpFor(up) {
return {
post: jest.fn(async (url) => {
const endpoint = up.find((e) => url.startsWith(e));
if (!endpoint) throw new Error(`unreachable ${url}`);
return { data: { result: { count: 4242 } } };
})
};
}
function service(http, extra = {}) {
return new RpcService({
http,
endpoints: [A, B, C],
identityRequired: false,
...extra
});
}
describe('daemon endpoint failover', () => {
test('accepts an ordered list of endpoints and prefers the first', () => {
const svc = service(httpFor([A, B, C]));
expect(svc.endpoints).toEqual([A, B, C]);
expect(svc.currentEndpoint).toBe(A);
expect(svc.failoverActive).toBe(false);
});
test('deduplicates endpoints — the stock PRIMARY == FALLBACK config is one node, not two', () => {
const same = 'http://127.0.0.1:34568';
const svc = new RpcService({
http: httpFor([same]),
primaryEndpoint: same,
fallbackEndpoint: same,
identityRequired: false
});
expect(svc.endpoints).toEqual([same]);
});
test('parses a comma-separated RPC_ENDPOINTS list and keeps PRIMARY/FALLBACK', () => {
const svc = new RpcService({
http: httpFor([A]),
env: {
RPC_ENDPOINTS: `${A}, ${B}`,
PRIMARY_RPC_ENDPOINT: C,
FALLBACK_RPC_ENDPOINT: C,
NODE_ENV: 'test'
},
identityRequired: false
});
expect(svc.endpoints).toEqual([A, B, C]);
});
test('walks past dead nodes to a live one within a SINGLE call', async () => {
const svc = service(httpFor([C])); // A and B are down
await expect(svc.makeRPCCall('getblockcount')).resolves.toMatchObject({ count: 4242 });
expect(svc.currentEndpoint).toBe(C);
expect(svc.failoverActive).toBe(true);
expect(svc.healthy).toBe(true);
});
test('sticks to the working node on subsequent calls', async () => {
const http = httpFor([B, C]);
const svc = service(http, { preferredRetryMs: 10 * 60 * 1000 });
await svc.makeRPCCall('getblockcount');
expect(svc.currentEndpoint).toBe(B);
http.post.mockClear();
await svc.makeRPCCall('getblockcount');
expect(http.post).toHaveBeenCalledTimes(1); // no re-probing of the dead primary
expect(http.post.mock.calls[0][0]).toContain('node-b');
});
test('returns to the preferred node once it recovers', async () => {
const up = [B];
const http = {
post: jest.fn(async (url) => {
if (!up.some((e) => url.startsWith(e))) throw new Error('unreachable');
return { data: { result: { count: 4242 } } };
})
};
const svc = service(http, { preferredRetryMs: 1000 }); // floor is 1s; see the constructor
await svc.makeRPCCall('getblockcount');
expect(svc.currentEndpoint).toBe(B);
up.push(A); // primary comes back
svc._preferredProbedAt -= 5000; // ...and the re-test interval elapses
await svc.makeRPCCall('getblockcount');
expect(svc.currentEndpoint).toBe(A);
expect(svc.failoverActive).toBe(false);
});
test('throws and reports unhealthy when NO node answers', async () => {
const svc = service(httpFor([]));
await expect(svc.makeRPCCall('getblockcount')).rejects.toThrow();
expect(svc.healthy).toBe(false);
});
test('getBlockHeight degrades to the last known height without claiming health', async () => {
const http = httpFor([A]);
const svc = service(http);
expect(await svc.getBlockHeight()).toBe(4242);
http.post.mockRejectedValue(new Error('all nodes offline'));
expect(await svc.getBlockHeight()).toBe(4242); // cached value for non-financial UI
expect(svc.healthy).toBe(false); // ...but never reported as live
});
test('getBlockCountStrict refuses to substitute the cached height', async () => {
const http = httpFor([A]);
const svc = service(http);
await svc.getBlockCountStrict();
http.post.mockRejectedValue(new Error('all nodes offline'));
await expect(svc.getBlockCountStrict()).rejects.toThrow();
});
test('healthCheck probes every node and adopts the first healthy one', async () => {
const svc = service(httpFor([C]));
const health = await svc.healthCheck();
expect(health.endpoints).toHaveLength(3);
expect(health.healthy).toBe(true);
expect(health.healthyCount).toBe(1);
expect(svc.currentEndpoint).toBe(C);
});
test('healthCheck reports unhealthy when every node is down', async () => {
const health = await service(httpFor([])).healthCheck();
expect(health.healthy).toBe(false);
expect(health.healthyCount).toBe(0);
});
});
+99
View File
@@ -0,0 +1,99 @@
/**
* Server-side fog of war.
*
* `Game.getState()` used to put the absolute entrance/exit/treasure coordinates AND the monster's
* exact position into every `game_start` / `game_update` frame, relying entirely on the browser
* renderer to decline to draw them. Anyone reading the socket payload in devtools could walk
* straight to the treasure and the exit from move 0 — in a paid mode, a direct attack on the payout,
* and the same frame is forwarded verbatim to spectators.
*
* Concealment is now enforced where it is authoritative: the server. Discovery is STICKY so a
* feature you have found stays on your map after you walk away, matching the client's notion of
* explored memory.
*/
const Game = require('../src/game/game');
const KEYS = ['DUNGEON_LEVELS', 'DIFFICULTY_PRESET', 'CRYPTO_TYPE'];
const SAVE = {};
beforeEach(() => {
KEYS.forEach(k => { SAVE[k] = process.env[k]; delete process.env[k]; });
process.env.DIFFICULTY_PRESET = 'normal';
});
afterEach(() => {
KEYS.forEach(k => { if (SAVE[k] === undefined) delete process.env[k]; else process.env[k] = SAVE[k]; });
});
const user = () => ({ id: 0, username: 'test', endGame() {} });
/** Force a point into (or out of) the player's current FOV. */
function setVisible(game, point, visible) {
const [x, y] = Array.isArray(point) ? point : [point.x, point.y];
if (visible) {
game.visibleTiles[y] = game.visibleTiles[y] || {};
game.visibleTiles[y][x] = '.';
} else if (game.visibleTiles[y]) {
delete game.visibleTiles[y][x];
}
}
describe('server-side fog of war', () => {
test('undiscovered exit and treasure are withheld from the state frame', () => {
process.env.DUNGEON_LEVELS = '1';
const game = new Game('sock', user(), {});
// Nothing but the spawn room is in view at game start.
setVisible(game, game.dungeon.exit, false);
if (game.dungeon.treasure) setVisible(game, game.dungeon.treasure, false);
const state = game.getState();
expect(state.exit).toBeNull();
expect(state.treasure).toBeNull();
// The dungeon itself still knows — only the wire frame is redacted.
expect(game.dungeon.exit).toEqual(expect.any(Array));
});
test('a feature is disclosed once seen, and STAYS disclosed after it leaves view', () => {
process.env.DUNGEON_LEVELS = '1';
const game = new Game('sock', user(), {});
const exit = game.dungeon.exit;
expect(game.getState().exit).toBeNull();
setVisible(game, exit, true);
expect(game.getState().exit).toEqual(exit);
// Walk away: explored memory must persist, or a found staircase would vanish off the map.
setVisible(game, exit, false);
expect(game.getState().exit).toEqual(exit);
});
test('the monster is only reported while it is actually in view', () => {
process.env.DUNGEON_LEVELS = '1';
const game = new Game('sock', user(), {});
expect(game.monster).toBeTruthy();
setVisible(game, game.monster, false);
expect(game.getState().monster).toBeNull();
setVisible(game, game.monster, true);
const seen = game.getState().monster;
expect(seen).toBeTruthy();
expect(seen.x).toBe(game.monster.x);
expect(seen.y).toBe(game.monster.y);
// Unlike features, the monster moves — concealment is NOT sticky, or you could track the
// hunter through walls for the rest of the run.
setVisible(game, game.monster, false);
expect(game.getState().monster).toBeNull();
});
test('discovery survives repeated getState calls without re-checking visibility', () => {
process.env.DUNGEON_LEVELS = '1';
const game = new Game('sock', user(), {});
setVisible(game, game.dungeon.entrance, true);
expect(game.getState().entrance).toEqual(game.dungeon.entrance);
setVisible(game, game.dungeon.entrance, false);
expect(game.getState().entrance).toEqual(game.dungeon.entrance);
expect(game.getState().entrance).toEqual(game.dungeon.entrance);
});
});
+35
View File
@@ -304,6 +304,15 @@ describe('(ix) route layer — single-use challenge consume rejects replay', ()
return { status: res.status, json };
}
// The route binds the signed event to this deployment's host (HOSTED_BY when configured), so the
// route tests must declare the host their events are signed for.
const prevHostedBy = process.env.HOSTED_BY;
beforeEach(() => { process.env.HOSTED_BY = `https://${HOST}`; });
afterEach(() => {
if (prevHostedBy === undefined) delete process.env.HOSTED_BY;
else process.env.HOSTED_BY = prevHostedBy;
});
test('a valid NIP-98 event links once, then the identical replay is rejected', async () => {
const sk = generateSecretKey();
const socketId = 'sock-xyz-1';
@@ -328,4 +337,30 @@ describe('(ix) route layer — single-use challenge consume rejects replay', ()
server.close();
}
});
// The takeover this binding exists to stop: an event the victim's wallet signed for ANOTHER site
// (any origin whose path ends in /api/auth/smirk/verify) must not authenticate here. Linking
// ADOPTS the account that already owns the pubkey and hands back its session, so accepting a
// borrowed event is full account takeover, not just a sloppy audience check.
test('an event signed for a different host is rejected even with a valid challenge', async () => {
const sk = generateSecretKey();
const socketId = 'sock-xyz-2';
const token = 'route-session-token-2';
const challenge = 'route-nonce-cross-host';
const ev = buildEvent(sk, {
created_at: Math.floor(Date.now() / 1000),
challenge,
u: `https://evil.example${PATH_SUFFIX}`
});
const db = mockDb(challenge, socketId, token);
const server = await startServer(db);
try {
const res = await postJson(server, PATH_SUFFIX, { socketId, event: ev }, token);
expect(res.status).toBe(400);
expect(res.json).not.toMatchObject({ linked: true });
} finally {
server.close();
}
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* Socket listener fault containment.
*
* Socket.IO invokes listeners WITHOUT a try/catch, and this process treats `uncaughtException` and
* `unhandledRejection` as fatal (src/index.js calls gracefulShutdown and exits). That combination
* made any unguarded throw in any listener an unauthenticated remote kill switch — the original
* instance was `socket.emit('debug_ping')` with no argument, which dereferenced `data.time` on
* `undefined` and took the whole server down. systemd restarts it, the attacker repeats.
*
* Two invariants are covered here:
* 1. `handleDebugPing` tolerates a missing / null / non-object payload.
* 2. The safe-dispatch wrapper contains BOTH synchronous throws and rejected promises, answering
* the offending socket with a generic message and never re-throwing.
*/
const SocketHandlers = require('../src/network/socketHandlers');
function fakeSocket() {
const emitted = [];
return {
id: 'sock-1',
client: { id: 'client-1' },
emitted,
emit: (event, payload) => emitted.push({ event, payload })
};
}
// The wrapper is a closure created per connection inside handleConnection, which needs the full
// dependency graph. Rebuild the identical shape here and assert its behaviour directly — the source
// of truth is that handleConnection registers every listener through it (asserted below).
function makeSafeDispatch(handlers, socket) {
return (event, handler) => {
socket.on = socket.on || (() => {});
return (...args) => {
let result;
try {
result = handler(...args);
} catch (err) {
handlers._onHandlerFault(socket, event, err);
return;
}
if (result && typeof result.then === 'function') {
result.catch((err) => handlers._onHandlerFault(socket, event, err));
}
};
};
}
describe('socket handler fault containment', () => {
const handlers = Object.create(SocketHandlers.prototype);
handlers.debugManager = { CONSOLE_LOGGING: false };
let errorSpy;
beforeEach(() => { errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); });
afterEach(() => { errorSpy.mockRestore(); });
describe('handleDebugPing payload hardening', () => {
test.each([
['no argument at all', undefined],
['an explicit null (defeats a default parameter)', null],
['a non-object primitive', 42],
['a string', 'time']
])('does not throw for %s', (_label, payload) => {
const socket = fakeSocket();
expect(() => handlers.handleDebugPing(socket, payload)).not.toThrow();
const pong = socket.emitted.find(e => e.event === 'debug_pong');
expect(pong).toBeDefined();
expect(pong.payload.clientTime).toBeNull();
});
test('passes a supplied time through unchanged', () => {
const socket = fakeSocket();
handlers.handleDebugPing(socket, { time: 1234 });
expect(socket.emitted.find(e => e.event === 'debug_pong').payload.clientTime).toBe(1234);
});
});
describe('_onHandlerFault', () => {
test('reports a generic message and never leaks internals to the client', () => {
const socket = fakeSocket();
handlers._onHandlerFault(socket, 'player_move', new Error('ENOENT /srv/secret/path.js'));
expect(socket.emitted).toHaveLength(1);
const sent = socket.emitted[0];
expect(sent.event).toBe('error_message');
expect(JSON.stringify(sent.payload)).not.toMatch(/ENOENT|secret|stack/i);
});
test('survives a socket that can no longer be emitted to', () => {
const dead = { emit: () => { throw new Error('socket closed'); } };
expect(() => handlers._onHandlerFault(dead, 'disconnect', new Error('boom'))).not.toThrow();
});
});
describe('safe dispatch', () => {
test('contains a synchronous throw instead of letting it escape the listener', () => {
const socket = fakeSocket();
const wrap = makeSafeDispatch(handlers, socket);
const listener = wrap('debug_ping', () => { throw new TypeError("Cannot read properties of undefined (reading 'time')"); });
expect(() => listener(undefined)).not.toThrow();
expect(socket.emitted[0].event).toBe('error_message');
});
test('contains a rejected promise instead of reaching unhandledRejection', async () => {
const socket = fakeSocket();
const wrap = makeSafeDispatch(handlers, socket);
const listener = wrap('tavern_chat', async () => { throw new Error('db down'); });
listener({});
await new Promise((r) => setImmediate(r));
expect(socket.emitted[0].event).toBe('error_message');
});
test('leaves a successful handler untouched', async () => {
const socket = fakeSocket();
const wrap = makeSafeDispatch(handlers, socket);
let ran = 0;
wrap('ok', () => { ran++; })();
await wrap('ok_async', async () => { ran++; })();
expect(ran).toBe(2);
expect(socket.emitted).toHaveLength(0);
});
});
test('every listener in handleConnection is registered through the wrapper, not socket.on', () => {
const source = require('fs').readFileSync(require.resolve('../src/network/socketHandlers.js'), 'utf8');
const start = source.indexOf("const on = (event, handler) => {");
expect(start).toBeGreaterThan(-1);
const body = source.slice(start);
// The only permitted raw registration is the one INSIDE the wrapper itself.
const rawRegistrations = body.match(/socket\.on\(/g) || [];
expect(rawRegistrations).toHaveLength(1);
// ...and the wrapper is actually used for a meaningful number of events.
expect((body.match(/\n\s{8}on\('/g) || []).length).toBeGreaterThan(20);
});
});
+7 -7
View File
@@ -182,7 +182,7 @@ describe('TavernManager', () => {
await mgr.join(s, { name: 'Alice' });
io.emitted.length = 0;
mgr.chat(s, { text: 'hi <b>there</b>' });
await mgr.chat(s, { text: 'hi <b>there</b>' });
const ev = io.emitted.find(e => e.event === 'chat_broadcast' && e.channel === mgr.channel);
expect(ev).toBeTruthy();
expect(ev.payload.username).toBe('Alice');
@@ -196,9 +196,9 @@ describe('TavernManager', () => {
const s = makeFakeSocket('a', io);
await mgr.join(s, {});
io.emitted.length = 0;
mgr.chat(s, { text: 'one' });
await mgr.chat(s, { text: 'one' });
s.emitted.length = 0;
mgr.chat(s, { text: 'two' }); // immediate second is dropped
await mgr.chat(s, { text: 'two' }); // immediate second is dropped
const msgs = io.emitted.filter(e => e.event === 'chat_broadcast');
expect(msgs).toHaveLength(1);
expect(msgs[0].payload.message).toBe('one');
@@ -210,12 +210,12 @@ describe('TavernManager', () => {
const io = makeFakeIo();
const mgr = new TavernManager({ io });
const outsider = makeFakeSocket('z', io);
mgr.chat(outsider, { text: 'sneaky' }); // never joined -> ignored
await mgr.chat(outsider, { text: 'sneaky' }); // never joined -> ignored
const s = makeFakeSocket('a', io);
await mgr.join(s, {});
io.emitted.length = 0;
mgr.chat(s, { text: ' ' }); // whitespace only -> ignored
await mgr.chat(s, { text: ' ' }); // whitespace only -> ignored
expect(io.emitted.filter(e => e.event === 'chat_broadcast')).toHaveLength(0);
});
@@ -255,7 +255,7 @@ describe('TavernManager', () => {
const mgr = new TavernManager({ io, globalChatProvider: gcp });
const s = makeFakeSocket('a', io);
await mgr.join(s, { name: 'Alice' });
mgr.chat(s, { text: 'hello world' });
await mgr.chat(s, { text: 'hello world' });
const sent = gcp.published.find(m => m.text && m.text.indexOf('hello') !== -1);
expect(sent).toBeDefined();
expect(sent.scope).toBe('global');
@@ -267,7 +267,7 @@ describe('TavernManager', () => {
const s = makeFakeSocket('a', io);
await mgr.join(s, { name: 'Alice' });
io.emitted.length = 0;
mgr.chat(s, { text: 'local only' });
await mgr.chat(s, { text: 'local only' });
const bc = io.emitted.find(e => e.event === 'chat_broadcast');
expect(bc).toBeDefined();
expect(bc.channel).toBe('tavern:main'); // room-scoped, not global