fix: close pre-launch review findings and light the premium renderers

Adversarial multi-agent review of the launch candidate produced 22 confirmed
findings (12 more were refuted). This closes the critical, high, and the
mediums/lows that are cheap and real, then does the iso/3D/Tavern art pass.

CRITICAL — unauthenticated remote kill switch
  Socket.IO does not wrap listener invocation, and this process treats
  uncaughtException/unhandledRejection as fatal, so ONE unguarded throw in ANY
  listener exits the server. `socket.emit('debug_ping')` with no argument did
  exactly that, and systemd restarts straight back into it. Every registration
  in handleConnection now goes through a safe-dispatch wrapper that contains
  sync throws and rejected promises to the offending socket; handleDebugPing
  also stops trusting its payload.

HIGH
  - NIP-98 login now binds the signed event to this deployment's host. The path
    check alone let an event signed for any other origin authenticate here, and
    linking ADOPTS the account owning that pubkey — full account takeover.
  - Fog of war is enforced server-side. getState() shipped absolute
    entrance/exit/treasure coordinates and the monster's exact position in every
    frame, with concealment implemented only in the browser. Discovery is sticky;
    the monster is reported only while in view.
  - NODE_ENV must now be explicit and recognized. Unset, every production gate
    read false while DebugManager reported production and silenced logging.
  - Tavern chat applies the lobby's ban list, rate limiter and user_id
    attribution before entering global chat.
  - userRegistry is deleted by socket.id (it was keyed by socket.id and deleted
    by socket.client.id, so nothing was ever freed).

MEDIUM / LOW
  - Payouts: a legitimately split transfer_split kept its transaction hashes
    instead of quarantining already-broadcast funds with tx_hash NULL, and
    provably pre-broadcast failures return to 'pending' rather than needs_review.
  - RateLimiter key parsing fixed — cleanup() freed nothing and expired windows
    never reset, because ids contain colons.
  - Enter no longer submits a paid entry from behind a modal.
  - Lobby chat no longer double-escapes server-escaped text.
  - Rate limits for identity:get / address:prompt / early_entry.
  - Spectators released when a game ends by disconnect, not just settlement.
  - movementManager / addressManager per-socket maps cleaned on disconnect.

Renderers
  Iso: opaque shading instead of translucent fog, additive torch/lantern/beacon
  light pools, crevice AO, embers, cold memory wash, and it stops reallocating
  the whole canvas every animation frame.
  3D: instanced level geometry (3 draw calls), procedural stone/flagstone
  materials, torch lights + shadows, real meshes for stairs/chest/monster
  (they were humanoid capsules), dust motes, wall cutaway, and lighting
  rebalanced now that fog is measured from the camera.

Tavern
  Block height and hash fragments drift around the stage edge and ripple when a
  block lands, fed by a cosmetic chain tip added to the blockheight broadcast.
  Speech bubbles work again (they keyed off a socketId the server stopped
  sending).

126 suites / 1005 tests pass.
This commit is contained in:
jwinterm
2026-07-26 07:29:47 -04:00
parent a5a210e426
commit dacbf356ed
30 changed files with 2117 additions and 184 deletions
+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) {
+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;
}
/**
+141 -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.
@@ -769,7 +779,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 +813,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 +841,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 +890,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 +898,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 +1168,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 +1500,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 +1548,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 +1618,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)
+26
View File
@@ -11,6 +11,25 @@ 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;
}
function createIpRateLimiter({ max }) {
const hits = new Map();
let lastSweep = 0;
@@ -283,9 +302,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
});
+24
View File
@@ -338,6 +338,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
*/
+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)
+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 () => {
+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);
});
});
+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