mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 14:51:26 +02:00
Merge ultra/prelaunch-review-and-graphics: pre-launch review fixes, renderer lighting pass, node failover
This commit is contained in:
@@ -275,6 +275,41 @@ To run multiple instances (e.g., Wownero on port 3000 and Monero stagenet on por
|
||||
| `PRIMARY_WALLET_ENDPOINT` | http://127.0.0.1:34570 | http://127.0.0.1:38083 |
|
||||
| `PRIMARY_RPC_ENDPOINT` | http://127.0.0.1:34568 | http://127.0.0.1:38081 |
|
||||
|
||||
### Daemon redundancy and graceful degradation
|
||||
|
||||
`RPC_ENDPOINTS` takes any number of daemons, comma or whitespace separated, in **preference order**:
|
||||
|
||||
```
|
||||
RPC_ENDPOINTS=http://127.0.0.1:34568,http://10.42.1.40:34568,http://10.42.1.41:34568
|
||||
```
|
||||
|
||||
`PRIMARY_RPC_ENDPOINT` / `FALLBACK_RPC_ENDPOINT` still work and are appended to the list. The list
|
||||
is deduplicated — note that the stock configuration sets primary and fallback to the *same* host, so
|
||||
by default there is exactly one node and no redundancy at all. Add at least one genuinely separate
|
||||
daemon before relying on failover.
|
||||
|
||||
Behaviour:
|
||||
|
||||
- one logical RPC call tries **every** configured node before it fails;
|
||||
- each node must pass the same chain-identity check before it may answer, so failover can never
|
||||
silently serve a different chain's data;
|
||||
- after failing over, the preferred node is re-tested every `RPC_PREFERRED_RETRY_MS` (default 60s,
|
||||
minimum 1s), so recovery does not need a restart;
|
||||
- when **no** node answers, the service reports unhealthy and throws. `getBlockCountStrict()` —
|
||||
used by fairness and match seeding — never substitutes a cached height.
|
||||
|
||||
**Entries are refused while no node is reachable.** A run's lifetime is counted in blocks and its
|
||||
entry block comes from the last successful poll; admitting a game during an outage anchors it to an
|
||||
already-past block and it is killed as a timeout on the first recovered poll. The player is told
|
||||
`Can't reach a <CHAIN> node right now…` and nothing is charged. Credits are not consumed, because
|
||||
the gate runs before any spend.
|
||||
|
||||
**The wallet is deliberately NOT failed over.** Two `wallet-rpc` processes serving one wallet file
|
||||
can build transactions from the same outputs and corrupt the wallet cache — a standby wallet is a
|
||||
custody decision for the operator, not something the app may do on its own. Daemons are read-only
|
||||
and interchangeable; wallets are neither. When the wallet is unreachable the server declines paid
|
||||
intake with `Payments are temporarily unavailable…`, and credits and free play keep working.
|
||||
|
||||
### Separate systemd Service
|
||||
|
||||
Install the hash-pinned fleet copy of `monerogue.service`; it is intentionally absent from the
|
||||
|
||||
+44
-1
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:';
|
||||
|
||||
@@ -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 ' and every ampersand as &. 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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -377,6 +377,32 @@ PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:34570
|
||||
PRIMARY_RPC_ENDPOINT=http://127.0.0.1:34568
|
||||
FALLBACK_RPC_ENDPOINT=http://127.0.0.1:34568
|
||||
|
||||
# OPTIONAL: additional daemons, comma or whitespace separated, in PREFERENCE ORDER.
|
||||
# PRIMARY/FALLBACK above are appended to this list and the whole thing is deduplicated —
|
||||
# note that the two defaults are the SAME host, so out of the box there is exactly one node
|
||||
# and no redundancy at all.
|
||||
#
|
||||
# A fallback only helps if it can fail independently. A second daemon in the same rack (or the
|
||||
# same office) shares the outage that takes the first one down, so it buys nothing; a node
|
||||
# reachable over a separate path — a home node across the mesh, or a public/community node — is
|
||||
# what actually adds availability.
|
||||
#
|
||||
# Every endpoint must still pass the chain-identity check before it may answer, so listing a
|
||||
# remote node cannot silently serve a different chain's data. For FREE / CREDITS instances the
|
||||
# daemon is only supplying block height, so a remote node is a reasonable fallback. For instances
|
||||
# taking PAYMENTS, prefer daemons you control: block height feeds match fairness and entropy, and
|
||||
# an untrusted node is an untrusted input to those.
|
||||
#
|
||||
# RPC_ENDPOINTS=http://127.0.0.1:34568,http://10.42.1.40:34568
|
||||
#
|
||||
# How long to stay on a backup before re-testing the preferred node (ms, minimum 1000).
|
||||
# RPC_PREFERRED_RETRY_MS=60000
|
||||
#
|
||||
# When NO endpoint answers, the server does not guess: entries are refused with a clear message
|
||||
# and nothing is charged. There is intentionally no wallet failover — two wallet-rpc processes
|
||||
# over one wallet file can spend the same outputs — so a wallet outage declines paid intake while
|
||||
# credits and free play keep working.
|
||||
|
||||
# How often to poll for new blocks (milliseconds)
|
||||
RPC_POLL_INTERVAL=2000
|
||||
|
||||
|
||||
@@ -78,6 +78,32 @@ WALLET_RPC_USER=wownerogue
|
||||
WALLET_RPC_PASSWORD=CHANGE_ME_STRONG_RPC_PASSWORD
|
||||
PRIMARY_RPC_ENDPOINT=http://127.0.0.1:34568
|
||||
FALLBACK_RPC_ENDPOINT=http://127.0.0.1:34568
|
||||
|
||||
# OPTIONAL: additional daemons, comma or whitespace separated, in PREFERENCE ORDER.
|
||||
# PRIMARY/FALLBACK above are appended to this list and the whole thing is deduplicated —
|
||||
# note that the two defaults are the SAME host, so out of the box there is exactly one node
|
||||
# and no redundancy at all.
|
||||
#
|
||||
# A fallback only helps if it can fail independently. A second daemon in the same rack (or the
|
||||
# same office) shares the outage that takes the first one down, so it buys nothing; a node
|
||||
# reachable over a separate path — a home node across the mesh, or a public/community node — is
|
||||
# what actually adds availability.
|
||||
#
|
||||
# Every endpoint must still pass the chain-identity check before it may answer, so listing a
|
||||
# remote node cannot silently serve a different chain's data. For FREE / CREDITS instances the
|
||||
# daemon is only supplying block height, so a remote node is a reasonable fallback. For instances
|
||||
# taking PAYMENTS, prefer daemons you control: block height feeds match fairness and entropy, and
|
||||
# an untrusted node is an untrusted input to those.
|
||||
#
|
||||
# RPC_ENDPOINTS=http://127.0.0.1:34568,http://10.42.1.40:34568
|
||||
#
|
||||
# How long to stay on a backup before re-testing the preferred node (ms, minimum 1000).
|
||||
# RPC_PREFERRED_RETRY_MS=60000
|
||||
#
|
||||
# When NO endpoint answers, the server does not guess: entries are refused with a clear message
|
||||
# and nothing is charged. There is intentionally no wallet failover — two wallet-rpc processes
|
||||
# over one wallet file can spend the same outputs — so a wallet outage declines paid intake while
|
||||
# credits and free play keep working.
|
||||
RPC_POLL_INTERVAL=2000
|
||||
|
||||
DIFFICULTY_PRESET=normal
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -16,6 +16,10 @@ class RateLimiter {
|
||||
'address:set': { window: 300000, max: 3 }, // 3 address changes per 5min
|
||||
'move:player': { window: 1000, max: 50 }, // 50 moves per second (already handled separately)
|
||||
'connection:new': { window: 60000, max: 10 }, // 10 connections per minute per IP
|
||||
// Cheap-looking socket events that each perform a users SELECT + UPDATE (last_active)
|
||||
// and an entitlements SELECT. Unlimited, they can be pipelined at packet rate and turn
|
||||
// one socket into a database load generator.
|
||||
'identity:read': { window: 10000, max: 20 }, // 20 identity/address reads per 10s
|
||||
...options.limits
|
||||
};
|
||||
|
||||
@@ -84,6 +88,7 @@ class RateLimiter {
|
||||
'payment:', // Payment creation/operations
|
||||
'game:', // Game start/queue operations
|
||||
'address:', // Address changes
|
||||
'identity:', // Identity/entitlement reads (each one hits the database)
|
||||
'chat:' // Chat messages (reconnect-proof spam protection)
|
||||
];
|
||||
return ipLimitedPrefixes.some(prefix => action.startsWith(prefix));
|
||||
@@ -208,11 +213,21 @@ class RateLimiter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get limit config from storage key
|
||||
* Get limit config from storage key.
|
||||
*
|
||||
* Storage keys are `${id}:${action}`, but the id itself contains colons — `u:123` / `s:<socketId>`
|
||||
* from rateLimitContext.stableId, and raw IPv6 addresses in ipStorage. Stripping only the FIRST
|
||||
* segment therefore produced `123:chat:message`, which matches no configured limit, so every
|
||||
* lookup returned undefined. Two things silently broke: `cleanup()` skipped every entry (the only
|
||||
* registered memory reclamation freed nothing, and both Maps grew for the process lifetime), and
|
||||
* `_recordAttempt` never reset an expired window, so counts accumulated forever. Match the known
|
||||
* action names as a suffix instead — correct regardless of how many colons the id contains.
|
||||
*/
|
||||
_getLimitFromKey(key) {
|
||||
const action = key.split(':').slice(1).join(':'); // Remove userId/IP prefix
|
||||
return this.limits[action];
|
||||
for (const action of Object.keys(this.limits)) {
|
||||
if (key === action || key.endsWith(`:${action}`)) return this.limits[action];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+195
-33
@@ -251,7 +251,17 @@ class SocketHandlers {
|
||||
entitlementProvider: async (socket) => this._entitlementsForSocket(socket),
|
||||
// Share the lobby's global chat provider so the tavern joins one global chat with
|
||||
// persistent history (and nostr fan-out when enabled), instead of ephemeral room chat.
|
||||
globalChatProvider: this.chatHandler.chatProvider
|
||||
globalChatProvider: this.chatHandler.chatProvider,
|
||||
// Publishing into GLOBAL chat must carry the same guards the lobby path applies —
|
||||
// otherwise `tavern_chat` is an unauthenticated bypass of the chat ban list, the
|
||||
// reconnect-proof rate limiter, and user_id attribution, reaching every connected
|
||||
// client and the persisted history under a name the client picked.
|
||||
chatModeration: {
|
||||
rateLimiter: this.rateLimiter,
|
||||
sessionManager: this.gameModeManager?.sessionManager || null,
|
||||
chatHistory: this.chatHandler?.chatHistory || null,
|
||||
getOrCreateUser: (socketId) => this.gameModeManager?.getOrCreateUser(socketId)
|
||||
}
|
||||
});
|
||||
this.tavernManager.initialize();
|
||||
// Initialize match mode queue, scheduler, and manager. Inert unless MATCH_ENABLED=true.
|
||||
@@ -351,8 +361,62 @@ class SocketHandlers {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse admission while no blockchain node is reachable.
|
||||
*
|
||||
* A run's lifetime is measured in blocks, and its entry block comes from the last SUCCESSFUL
|
||||
* poll. With every node unreachable that value silently goes stale, so a game admitted during an
|
||||
* outage was anchored to an already-past block and `checkGamesTimeout` killed it as a timeout on
|
||||
* the first recovered poll — the player lost a run (and in paid mode, an entry) to our
|
||||
* infrastructure. Failing the entry up front is the honest outcome: nothing is charged and the
|
||||
* player is told why.
|
||||
*
|
||||
* Deliberately conservative: it only fires when the health check explicitly reports false.
|
||||
* Simulated blocks report healthy, and a runtime without the probe is left alone.
|
||||
*/
|
||||
_rejectAdmissionWhenNodeUnreachable(socket, kind) {
|
||||
if (this.debugManager?.isChainHealthy?.() !== false) return false;
|
||||
const chain = String(process.env.CRYPTO_TYPE || 'WOW').toUpperCase().slice(0, 8);
|
||||
const message = `Can't reach a ${chain} node right now, so entries are paused. `
|
||||
+ 'Nothing was charged — please try again shortly.';
|
||||
try { socket?.emit?.('chain_unavailable', { code: 'NODE_UNREACHABLE', kind, message }); } catch (_) {}
|
||||
try { this.broadcastManager?.sendStatusUpdate?.(socket?.id, 'error', message); } catch (_) {}
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.warn(`[SocketHandlers] '${kind}' refused — no daemon reachable.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse PAID intake while the wallet is unreachable.
|
||||
*
|
||||
* Deliberately NOT a failover. Two `wallet-rpc` processes serving one wallet file can build
|
||||
* transactions from the same outputs and corrupt the wallet cache, so a standby wallet is an
|
||||
* operator decision about custody, not something this code may take on its own. Daemons are
|
||||
* read-only and interchangeable — wallets are neither. The safe behaviour when the wallet is
|
||||
* down is therefore to decline the invoice cleanly rather than to route around it.
|
||||
*
|
||||
* Only gates kinds that actually need the wallet: credits/free entry runs off the database and
|
||||
* must keep working through a wallet outage.
|
||||
*/
|
||||
_rejectPaidIntakeWhenWalletUnreachable(socket, kind) {
|
||||
if (kind !== 'request_payment') return false;
|
||||
// Only meaningful once a wallet has been wired up and has reported at least once.
|
||||
if (!this.walletService || this.walletService.isHealthy !== false) return false;
|
||||
const message = 'Payments are temporarily unavailable — the wallet is unreachable. '
|
||||
+ 'Nothing was charged. Credits and free play still work.';
|
||||
try { socket?.emit?.('payment_unavailable', { code: 'WALLET_UNREACHABLE', kind, message }); } catch (_) {}
|
||||
try { this.broadcastManager?.sendStatusUpdate?.(socket?.id, 'error', message); } catch (_) {}
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.warn(`[SocketHandlers] '${kind}' refused — wallet unreachable.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_runAdmission(socket, kind, task) {
|
||||
if (this._rejectAdmissionDuringShutdown(socket, kind)) return Promise.resolve(null);
|
||||
if (this._rejectAdmissionWhenNodeUnreachable(socket, kind)) return Promise.resolve(null);
|
||||
if (this._rejectPaidIntakeWhenWalletUnreachable(socket, kind)) return Promise.resolve(null);
|
||||
const token = {};
|
||||
this._admissionsInFlight.add(token);
|
||||
let work;
|
||||
@@ -769,7 +833,32 @@ class SocketHandlers {
|
||||
// Register event handlers. Admission wrappers remain live for the life of this socket and
|
||||
// consult the dynamic shutdown flag, so existing connections cannot race the snapshot.
|
||||
const runAdmission = (kind, task) => this._runAdmission(socket, kind, task);
|
||||
socket.on('chat message', (msg) => this.chatHandler.handleChatMessage(socket, msg, {
|
||||
|
||||
// SAFE DISPATCH — every listener below is registered through `on`, never `socket.on`.
|
||||
//
|
||||
// Socket.IO invokes listeners without a try/catch, and this process treats
|
||||
// `uncaughtException` and `unhandledRejection` as fatal (see the handlers in src/index.js,
|
||||
// which call gracefulShutdown and exit). That combination makes ONE unguarded throw in ANY
|
||||
// listener an unauthenticated remote kill switch: a client sends a malformed — or entirely
|
||||
// absent — payload and the whole server exits, taking live games and payment monitoring
|
||||
// with it. Containing the fault to the offending socket is the invariant; individual
|
||||
// handlers hardening their own inputs is defence in depth, not a substitute.
|
||||
const on = (event, handler) => {
|
||||
socket.on(event, (...args) => {
|
||||
let result;
|
||||
try {
|
||||
result = handler(...args);
|
||||
} catch (err) {
|
||||
this._onHandlerFault(socket, event, err);
|
||||
return;
|
||||
}
|
||||
if (result && typeof result.then === 'function') {
|
||||
result.catch((err) => this._onHandlerFault(socket, event, err));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
on('chat message', (msg) => this.chatHandler.handleChatMessage(socket, msg, {
|
||||
// Legacy text command has no client contribution, but still consumes the already
|
||||
// published one-time server offer with an empty clientSeed.
|
||||
handleGameQueue: (commandSocket) => runAdmission('chat_game_queue',
|
||||
@@ -778,11 +867,11 @@ class SocketHandlers {
|
||||
handleStatsRequest: (socket) => this.handleStatsRequest(socket)
|
||||
}));
|
||||
// Phase 2: a client-signed global chat event (posts under the player's own Smirk npub).
|
||||
socket.on('chat_signed', (payload) => this.chatHandler.handleSignedChatMessage(socket, payload));
|
||||
socket.on('player_move', (moveData) => this.movementManager.handleMove(socket.id, moveData));
|
||||
socket.on('disconnect', () => this.handleDisconnect(socket));
|
||||
socket.on('debug_ping', (data) => this.handleDebugPing(socket, data));
|
||||
socket.on('register_client', async (data) => {
|
||||
on('chat_signed', (payload) => this.chatHandler.handleSignedChatMessage(socket, payload));
|
||||
on('player_move', (moveData) => this.movementManager.handleMove(socket.id, moveData));
|
||||
on('disconnect', () => this.handleDisconnect(socket));
|
||||
on('debug_ping', (data) => this.handleDebugPing(socket, data));
|
||||
on('register_client', async (data) => {
|
||||
this.connectionHandler.handleRegisterClient(socket, data);
|
||||
this._emitFairnessOffer(socket);
|
||||
// Re-send game mode info after client registers handlers (fixes race condition)
|
||||
@@ -806,48 +895,48 @@ class SocketHandlers {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) console.warn('Failed to re-send identity snapshot:', err.message);
|
||||
});
|
||||
});
|
||||
socket.on('auto_start', (data) => runAdmission('auto_start',
|
||||
on('auto_start', (data) => runAdmission('auto_start',
|
||||
() => this.handleAutoStart(socket, (data && typeof data === 'object') ? data : {}))); // New handler for start button
|
||||
socket.on('play_free', (data) => runAdmission('play_free',
|
||||
on('play_free', (data) => runAdmission('play_free',
|
||||
() => this.handleAutoStart(socket, { ...((data && typeof data === 'object') ? data : {}), free: true }))); // Explicit free-play choice
|
||||
socket.on('join_queue', (data) => runAdmission('join_queue',
|
||||
on('join_queue', (data) => runAdmission('join_queue',
|
||||
() => this.handleJoinQueue(socket, (data && typeof data === 'object') ? data : {}))); // Queue instead of auto-start ({ free: true } for Pleb-board free play)
|
||||
socket.on('early_entry', (data) => runAdmission('early_entry',
|
||||
on('early_entry', (data) => runAdmission('early_entry',
|
||||
() => this.handleEarlyEntry(socket, (data && typeof data === 'object') ? data : {}))); // Early entry without waiting for block
|
||||
socket.on('fairness_offer_request', () => this._emitFairnessOffer(socket));
|
||||
socket.on('address:prompt', () => this.handleAddressPrompt(socket));
|
||||
on('fairness_offer_request', () => this._emitFairnessOffer(socket));
|
||||
on('address:prompt', () => this._withIdentityReadLimit(socket, () => this.handleAddressPrompt(socket)));
|
||||
|
||||
// Payment system handlers
|
||||
socket.on('request_payment', (data) => runAdmission('request_payment',
|
||||
on('request_payment', (data) => runAdmission('request_payment',
|
||||
() => this.handlePaymentRequest(socket, data)));
|
||||
socket.on('check_payment_status', (data) => this.handleCheckPaymentStatus(socket, data));
|
||||
socket.on('get_user_credits', () => this.handleGetUserCredits(socket));
|
||||
socket.on('address:update', (data) => this.handleAddressUpdate(socket, data));
|
||||
socket.on('identity:get', () => this.handleIdentityGet(socket));
|
||||
socket.on('identity:update', (data) => this.handleIdentityUpdate(socket, data));
|
||||
on('check_payment_status', (data) => this.handleCheckPaymentStatus(socket, data));
|
||||
on('get_user_credits', () => this.handleGetUserCredits(socket));
|
||||
on('address:update', (data) => this.handleAddressUpdate(socket, data));
|
||||
on('identity:get', () => this._withIdentityReadLimit(socket, () => this.handleIdentityGet(socket)));
|
||||
on('identity:update', (data) => this.handleIdentityUpdate(socket, data));
|
||||
|
||||
// Spectator handlers
|
||||
socket.on('get_active_games', (options) => this.handleGetActiveGames(socket, options));
|
||||
socket.on('spectate_game', (data) => this.handleSpectateGame(socket, data));
|
||||
socket.on('leave_spectate', () => this.handleLeaveSpectate(socket));
|
||||
on('get_active_games', (options) => this.handleGetActiveGames(socket, options));
|
||||
on('spectate_game', (data) => this.handleSpectateGame(socket, data));
|
||||
on('leave_spectate', () => this.handleLeaveSpectate(socket));
|
||||
|
||||
// Tavern handlers (social hangout mode; refused server-side unless enabled)
|
||||
socket.on('tavern_join', (data) => {
|
||||
on('tavern_join', (data) => {
|
||||
Promise.resolve(this.tavernManager.join(socket, data)).catch(err => {
|
||||
console.error('Tavern join failed:', err.message);
|
||||
socket.emit('tavern_error', { message: 'Could not enter the tavern.' });
|
||||
});
|
||||
});
|
||||
socket.on('tavern_move', (data) => this.tavernManager.move(socket, data));
|
||||
socket.on('tavern_chat', (data) => this.tavernManager.chat(socket, data));
|
||||
socket.on('tavern_leave', () => this.tavernManager.leave(socket));
|
||||
on('tavern_move', (data) => this.tavernManager.move(socket, data));
|
||||
on('tavern_chat', (data) => this.tavernManager.chat(socket, data));
|
||||
on('tavern_leave', () => this.tavernManager.leave(socket));
|
||||
// Match mode handlers. Every match listener is wrapped so a match-layer fault can
|
||||
// never crash the connection or leak a stack to the client — it logs and emits a
|
||||
// benign 'match_error' instead (C5). Identity is always resolved from the CONNECTION,
|
||||
// never from the client payload.
|
||||
socket.on('match_queue', (data) => runAdmission('match_queue',
|
||||
on('match_queue', (data) => runAdmission('match_queue',
|
||||
() => this._handleMatchQueue(socket, data)));
|
||||
socket.on('match_move', (data) => {
|
||||
on('match_move', (data) => {
|
||||
try {
|
||||
this.matchManager.move(socket, data);
|
||||
} catch (err) {
|
||||
@@ -855,7 +944,7 @@ class SocketHandlers {
|
||||
socket.emit('match_error', { message: 'Move could not be processed.' });
|
||||
}
|
||||
});
|
||||
socket.on('match_leave', () => {
|
||||
on('match_leave', () => {
|
||||
try {
|
||||
this.matchManager.leave(socket);
|
||||
} catch (err) {
|
||||
@@ -863,9 +952,9 @@ class SocketHandlers {
|
||||
socket.emit('match_error', { message: 'Could not leave the match.' });
|
||||
}
|
||||
});
|
||||
socket.on('match_reconnect', (data) => this._handleMatchReconnect(socket, data));
|
||||
on('match_reconnect', (data) => this._handleMatchReconnect(socket, data));
|
||||
// Tavern match spectator bridge
|
||||
socket.on('tavern_match_list', () => {
|
||||
on('tavern_match_list', () => {
|
||||
const list = this.tavernMatchBridge ? this.tavernMatchBridge.getActiveMatches() : [];
|
||||
socket.emit('tavern_match_list', list);
|
||||
});
|
||||
@@ -1133,6 +1222,20 @@ class SocketHandlers {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Early entry CREATES A GAME and may consume a credit, so it must carry the same
|
||||
// reconnect-proof admission budget as auto_start / join_queue / request_payment. It
|
||||
// previously had none of its own: the only limiter downstream keyed on the raw, volatile
|
||||
// socket.id with no IP bucket, so simply reconnecting reset the allowance.
|
||||
const rlId = stableId(socket, this.sessionManager);
|
||||
const rlIp = clientIp(socket);
|
||||
const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'game:queue', rlIp);
|
||||
if (!rateLimitResult.allowed) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
|
||||
`Slow down — try entering again in ${Math.ceil(rateLimitResult.retryAfter / 1000)}s.`);
|
||||
return;
|
||||
}
|
||||
await this.rateLimiter.recordAttempt(rlId, 'game:queue', rlIp);
|
||||
|
||||
// Early entry is free only on a free-only instance. Mixed/credits entry may consume a
|
||||
// credit, so treat ambiguous mixed-mode requests as paid and fail closed.
|
||||
if (this.gameModeManager?.gameMode !== 'FREE'
|
||||
@@ -1451,6 +1554,22 @@ class SocketHandlers {
|
||||
this.activeGames.delete(socketId);
|
||||
}
|
||||
|
||||
// Release anyone watching this run. Suspension and the no-identity drop above both
|
||||
// remove the game from activeGames without going through the settlement publisher that
|
||||
// normally calls this, so without it those spectators are stranded in a dead room.
|
||||
if (activeGame?.id && this.spectatorManager?.notifyGameEnded) {
|
||||
try {
|
||||
this.spectatorManager.notifyGameEnded(activeGame.id, {
|
||||
status: 'interrupted',
|
||||
message: 'The player disconnected.'
|
||||
}, 'player_disconnected');
|
||||
} catch (err) {
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.error('[SocketHandlers] spectator release failed:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up payment monitoring (but remember it was active for restoration)
|
||||
if (this.paymentHandlers && typeof this.paymentHandlers.stopMonitoringForSocket === 'function') {
|
||||
this.paymentHandlers.stopMonitoringForSocket(socketId);
|
||||
@@ -1483,6 +1602,12 @@ class SocketHandlers {
|
||||
this.spectatorManager.handleDisconnect(socketId);
|
||||
}
|
||||
|
||||
// Per-socket bookkeeping keyed on the VOLATILE socket id. Nothing else ever removes
|
||||
// these, so without an explicit disconnect sweep each one accrues a permanent entry per
|
||||
// socket that ever moved / ever triggered address detection.
|
||||
if (this.movementManager?.forgetSocket) this.movementManager.forgetSocket(socketId);
|
||||
if (this.addressManager?.forgetSocket) this.addressManager.forgetSocket(socketId);
|
||||
|
||||
// Clean up tavern presence
|
||||
if (this.tavernManager) {
|
||||
this.tavernManager.handleDisconnect(socketId);
|
||||
@@ -1547,15 +1672,52 @@ class SocketHandlers {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle debug ping
|
||||
* Contain a socket-listener fault to the socket that caused it.
|
||||
*
|
||||
* Never rethrows and never touches process state: the entire point is that a malformed client
|
||||
* payload must not reach the fatal `uncaughtException` / `unhandledRejection` handlers.
|
||||
*/
|
||||
_onHandlerFault(socket, event, err) {
|
||||
console.error(`❌ Socket handler '${event}' faulted:`, (err && err.message) || err);
|
||||
if (err && err.stack && this.debugManager?.CONSOLE_LOGGING) console.error(err.stack);
|
||||
try {
|
||||
// Deliberately generic — a client must never learn where in the server it broke.
|
||||
socket.emit('error_message', { message: 'That request could not be processed.' });
|
||||
} catch (_) { /* socket already gone */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a cheap-looking read that actually hits the database.
|
||||
*
|
||||
* `identity:get` and `address:prompt` each run getOrCreateUser (a users SELECT plus an
|
||||
* UPDATE ... SET last_active) and an entitlements SELECT, but unlike their sibling
|
||||
* `identity:update` neither had any limit — so a single socket could pipeline them at packet
|
||||
* rate. Uses the reconnect-proof stable id + IP buckets, so dropping and re-opening the socket
|
||||
* does not reset the budget.
|
||||
*/
|
||||
async _withIdentityReadLimit(socket, task) {
|
||||
const rlId = stableId(socket, this.sessionManager);
|
||||
const rlIp = clientIp(socket);
|
||||
const verdict = await this.rateLimiter.checkLimit(rlId, 'identity:read', rlIp);
|
||||
if (!verdict.allowed) return;
|
||||
await this.rateLimiter.recordAttempt(rlId, 'identity:read', rlIp);
|
||||
return task();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle debug ping.
|
||||
*
|
||||
* `data` is fully attacker-controlled and may be absent or a non-object: `socket.emit('debug_ping')`
|
||||
* sends no argument at all, and `emit('debug_ping', null)` defeats a default parameter.
|
||||
*/
|
||||
handleDebugPing(socket, data) {
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`Debug ping received from ${socket.client.id}`);
|
||||
}
|
||||
const clientTime = (data && typeof data === 'object' && data.time != null) ? data.time : null;
|
||||
socket.emit('debug_pong', {
|
||||
message: "Hello from server!",
|
||||
clientTime: data.time,
|
||||
clientTime,
|
||||
serverTime: Date.now(),
|
||||
socketId: socket.client.id
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -663,27 +663,36 @@ class WalletRPCService {
|
||||
* @returns {Object} - { tx_hash_list, tx_key_list, fee_list, totalFee }
|
||||
*/
|
||||
async processBatchPayout(destinations) {
|
||||
this._assertTransferAllowed();
|
||||
if (!destinations || destinations.length === 0) {
|
||||
throw new AppError('No destinations provided for batch payout', {
|
||||
safeMessage: 'Batch payout requires at least one destination.'
|
||||
});
|
||||
}
|
||||
await this.ensureNetworkIdentity();
|
||||
// Everything up to the transfer_split call below happens BEFORE anything can be broadcast.
|
||||
// Tag those failures so the caller can safely return the batch to 'pending' and retry it,
|
||||
// instead of quarantining provably-unsent payouts in 'needs_review' — where the retry
|
||||
// service deliberately never looks at them again.
|
||||
try {
|
||||
this._assertTransferAllowed();
|
||||
if (!destinations || destinations.length === 0) {
|
||||
throw new AppError('No destinations provided for batch payout', {
|
||||
safeMessage: 'Batch payout requires at least one destination.'
|
||||
});
|
||||
}
|
||||
await this.ensureNetworkIdentity();
|
||||
|
||||
// Validate all addresses before attempting transfer
|
||||
for (const dest of destinations) {
|
||||
if (!dest.address || typeof dest.address !== 'string') {
|
||||
throw new AppError('Invalid address in batch payout', {
|
||||
safeMessage: 'One or more payout addresses are invalid.'
|
||||
});
|
||||
}
|
||||
const validation = await this.validateAddress(dest.address);
|
||||
if (!validation.valid) {
|
||||
throw new AppError(`Invalid payout address in batch: ${dest.address.substring(0, 10)}...`, {
|
||||
safeMessage: 'One or more payout addresses failed validation.'
|
||||
});
|
||||
// Validate all addresses before attempting transfer
|
||||
for (const dest of destinations) {
|
||||
if (!dest.address || typeof dest.address !== 'string') {
|
||||
throw new AppError('Invalid address in batch payout', {
|
||||
safeMessage: 'One or more payout addresses are invalid.'
|
||||
});
|
||||
}
|
||||
const validation = await this.validateAddress(dest.address);
|
||||
if (!validation.valid) {
|
||||
throw new AppError(`Invalid payout address in batch: ${dest.address.substring(0, 10)}...`, {
|
||||
safeMessage: 'One or more payout addresses failed validation.'
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object') err.preBroadcast = true;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const transferParams = {
|
||||
@@ -705,7 +714,12 @@ class WalletRPCService {
|
||||
|
||||
// Address validation may take several RPC round trips. Recheck at the last possible
|
||||
// point before transfer_split can broadcast.
|
||||
this._assertTransferAllowed();
|
||||
try {
|
||||
this._assertTransferAllowed();
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object') err.preBroadcast = true;
|
||||
throw err;
|
||||
}
|
||||
const response = await this.rpcCall('transfer_split', transferParams);
|
||||
|
||||
const hashes = Array.isArray(response.result?.tx_hash_list)
|
||||
|
||||
+59
-8
@@ -11,25 +11,69 @@ const { verifyNip98Event } = require('../utils/nip98');
|
||||
const AUTH_WINDOW_MS = 60 * 1000;
|
||||
const AUTH_RATE_MAX_KEYS = 10000;
|
||||
|
||||
/**
|
||||
* The host a NIP-98 auth event must be signed for.
|
||||
*
|
||||
* `HOSTED_BY` is the operator's canonical public origin and is contract-enforced in operated
|
||||
* production profiles, so it is the authority whenever it is set — that keeps verification correct
|
||||
* behind a proxy and immune to a spoofed Host header. Unconfigured (dev / self-host) deployments
|
||||
* fall back to the request's own host, which still binds the event to the origin it was sent to.
|
||||
*/
|
||||
function expectedAuthHost(req) {
|
||||
const configured = typeof process.env.HOSTED_BY === 'string' ? process.env.HOSTED_BY.trim() : '';
|
||||
if (configured) {
|
||||
try {
|
||||
const parsed = new URL(configured);
|
||||
if (parsed.host) return parsed.host;
|
||||
} catch (_) { /* malformed HOSTED_BY — fall through to the request host */ }
|
||||
}
|
||||
return (req && (req.get('host') || req.headers?.host)) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-IP auth rate limiter with a bounded key map.
|
||||
*
|
||||
* The bound must be enforced by EVICTION, never by refusing new keys. Refusing turned the memory
|
||||
* cap into a denial-of-service primitive: an attacker sourcing from many addresses could hold the
|
||||
* map at capacity and every legitimate first-time caller of /challenge or /verify got a 429, locking
|
||||
* the whole population out of Smirk login. Eviction keeps the same memory ceiling while leaving the
|
||||
* limiter functional — the worst case is that a very old window's count is forgotten, which grants
|
||||
* one extra window to an attacker who was already being counted.
|
||||
*/
|
||||
function createIpRateLimiter({ max }) {
|
||||
const hits = new Map();
|
||||
let lastSweep = 0;
|
||||
|
||||
const sweepExpired = (now) => {
|
||||
for (const [key, value] of hits) {
|
||||
if (now - value.windowStart >= AUTH_WINDOW_MS) hits.delete(key);
|
||||
}
|
||||
lastSweep = now;
|
||||
};
|
||||
|
||||
// Discard the oldest live windows in one batch, so the O(n) pass is amortised rather than paid
|
||||
// on every request once the map is full.
|
||||
const evictOldest = (count) => {
|
||||
const oldest = Array.from(hits.entries())
|
||||
.sort((left, right) => left[1].windowStart - right[1].windowStart)
|
||||
.slice(0, count);
|
||||
for (const [key] of oldest) hits.delete(key);
|
||||
};
|
||||
|
||||
return (req, res, next) => {
|
||||
const now = Date.now();
|
||||
if (now - lastSweep >= AUTH_WINDOW_MS) {
|
||||
for (const [key, value] of hits) {
|
||||
if (now - value.windowStart >= AUTH_WINDOW_MS) hits.delete(key);
|
||||
}
|
||||
lastSweep = now;
|
||||
}
|
||||
if (now - lastSweep >= AUTH_WINDOW_MS) sweepExpired(now);
|
||||
|
||||
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
let entry = hits.get(ip);
|
||||
if (!entry || now - entry.windowStart >= AUTH_WINDOW_MS) {
|
||||
if (!entry && hits.size >= AUTH_RATE_MAX_KEYS) {
|
||||
res.setHeader('Retry-After', '60');
|
||||
return res.status(429).json({ error: 'Too many authentication attempts' });
|
||||
// Sweeping is otherwise lazy (once per window), so a map full of expired entries would
|
||||
// stay full. Reclaim first, and only evict live windows if that was not enough.
|
||||
sweepExpired(now);
|
||||
if (hits.size >= AUTH_RATE_MAX_KEYS) {
|
||||
evictOldest(Math.max(1, Math.ceil(AUTH_RATE_MAX_KEYS * 0.1)));
|
||||
}
|
||||
}
|
||||
entry = { count: 0, windowStart: now };
|
||||
hits.set(ip, entry);
|
||||
@@ -283,9 +327,16 @@ router.post('/api/auth/smirk/verify', verifyRateLimit, asyncHandler(async (req,
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// Bind the signed event to THIS deployment's host. The 'u' tag path check alone is not an
|
||||
// authentication boundary: a kind:27235 event the same wallet signed for any other site whose
|
||||
// path happens to end in /api/auth/smirk/verify would otherwise verify here — and because the
|
||||
// link step ADOPTS whatever account already owns that pubkey and returns its session token, a
|
||||
// borrowed event is a full account takeover. Prefer the operator's configured HOSTED_BY origin;
|
||||
// fall back to the request host only when it is not configured (dev/self-host).
|
||||
const result = verifyNip98Event(body.event, {
|
||||
challenge,
|
||||
expectedPathSuffix: '/api/auth/smirk/verify',
|
||||
expectedHost: expectedAuthHost(req),
|
||||
now,
|
||||
maxSkewSec: 120
|
||||
});
|
||||
|
||||
+147
-97
@@ -101,14 +101,53 @@ function endpointLabel(value) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Split a comma/whitespace separated endpoint list from configuration. */
|
||||
function parseEndpointList(value) {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (typeof value !== 'string') return [];
|
||||
return value.split(/[,\s]+/).map(part => part.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Trim trailing slashes and drop duplicates while preserving preference order. */
|
||||
function normalizeEndpointList(values) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const raw of values) {
|
||||
if (typeof raw !== 'string') continue;
|
||||
const endpoint = raw.trim().replace(/\/$/, '');
|
||||
if (!endpoint || seen.has(endpoint)) continue;
|
||||
seen.add(endpoint);
|
||||
out.push(endpoint);
|
||||
}
|
||||
return out.length ? out : ['http://127.0.0.1:34568'];
|
||||
}
|
||||
|
||||
class RPCService {
|
||||
constructor(options = {}) {
|
||||
const env = options.env || process.env;
|
||||
this.http = options.http || axios;
|
||||
this.primaryEndpoint = options.primaryEndpoint || env.PRIMARY_RPC_ENDPOINT || 'http://127.0.0.1:34568';
|
||||
this.fallbackEndpoint = options.fallbackEndpoint || env.FALLBACK_RPC_ENDPOINT || 'http://127.0.0.1:34568';
|
||||
this.currentEndpoint = this.primaryEndpoint;
|
||||
// Ordered preference list. `RPC_ENDPOINTS` (comma/whitespace separated) declares any number
|
||||
// of daemons and, when present, its ORDER is the preference order. PRIMARY/FALLBACK remain
|
||||
// supported and are appended when explicitly configured. Deduped — the stock configuration
|
||||
// pointed both at the same host, which looked like redundancy and was none.
|
||||
const declaredEndpoints = options.endpoints || parseEndpointList(env.RPC_ENDPOINTS);
|
||||
const configuredPrimary = options.primaryEndpoint || env.PRIMARY_RPC_ENDPOINT || null;
|
||||
const configuredFallback = options.fallbackEndpoint || env.FALLBACK_RPC_ENDPOINT || null;
|
||||
this.endpoints = normalizeEndpointList(
|
||||
declaredEndpoints.length
|
||||
// An explicit list is authoritative: never silently graft the localhost default onto it.
|
||||
? [...declaredEndpoints, configuredPrimary, configuredFallback].filter(Boolean)
|
||||
: [configuredPrimary || 'http://127.0.0.1:34568', configuredFallback].filter(Boolean)
|
||||
);
|
||||
this.primaryEndpoint = configuredPrimary || this.endpoints[0];
|
||||
this.fallbackEndpoint = configuredFallback || this.primaryEndpoint;
|
||||
this.currentEndpoint = this.endpoints[0];
|
||||
this.failoverActive = false;
|
||||
// How long to stay on a backup before re-testing the preferred daemon. Without this a single
|
||||
// blip pins the process to a degraded node until it restarts.
|
||||
this.preferredRetryMs = Math.max(1000, Number(options.preferredRetryMs)
|
||||
|| Number(env.RPC_PREFERRED_RETRY_MS) || 60000);
|
||||
this._preferredProbedAt = 0;
|
||||
this.lastBlockHeight = 0;
|
||||
this.healthy = false;
|
||||
this.lastSuccessAt = 0;
|
||||
@@ -134,8 +173,7 @@ class RPCService {
|
||||
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`🔗 RPC Service initialized`);
|
||||
console.log(`Primary: ${endpointLabel(this.primaryEndpoint)}`);
|
||||
console.log(`Fallback: ${endpointLabel(this.fallbackEndpoint)}`);
|
||||
console.log(`Endpoints: ${this.endpoints.map(endpointLabel).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,103 +242,86 @@ class RPCService {
|
||||
return identity;
|
||||
}
|
||||
|
||||
async _ensureCurrentIdentity() {
|
||||
if (!this.identityRequired) return null;
|
||||
const candidates = [this.currentEndpoint];
|
||||
const alternate = this.currentEndpoint === this.primaryEndpoint
|
||||
? this.fallbackEndpoint
|
||||
: this.primaryEndpoint;
|
||||
if (alternate && alternate !== this.currentEndpoint) candidates.push(alternate);
|
||||
|
||||
let lastError = null;
|
||||
for (const endpoint of candidates) {
|
||||
try {
|
||||
const identity = await this._verifyEndpointIdentity(endpoint);
|
||||
if (endpoint !== this.currentEndpoint) {
|
||||
this.currentEndpoint = endpoint;
|
||||
this.failoverActive = endpoint !== this.primaryEndpoint;
|
||||
}
|
||||
return identity;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
/**
|
||||
* The order to try endpoints in for one logical call.
|
||||
*
|
||||
* Normally: whichever node we are already on, then every other node in declared preference
|
||||
* order. Periodically the PREFERRED node goes first instead, so recovering from a blip does not
|
||||
* require a restart — previously `failoverActive` was a one-way latch and the process stayed on
|
||||
* the backup forever.
|
||||
*/
|
||||
_attemptOrder() {
|
||||
const preferred = this.endpoints[0];
|
||||
const now = Date.now();
|
||||
const retryPreferred = this.currentEndpoint !== preferred
|
||||
&& (now - this._preferredProbedAt) >= this.preferredRetryMs;
|
||||
if (retryPreferred) this._preferredProbedAt = now;
|
||||
const first = retryPreferred ? preferred : this.currentEndpoint;
|
||||
const order = [first];
|
||||
for (const endpoint of this.endpoints) {
|
||||
if (endpoint !== first) order.push(endpoint);
|
||||
}
|
||||
this.healthy = false;
|
||||
throw lastError || new Error('Blockchain daemon identity could not be verified.');
|
||||
return order;
|
||||
}
|
||||
|
||||
_selectEndpoint(endpoint) {
|
||||
const wasFailedOver = this.failoverActive;
|
||||
this.currentEndpoint = endpoint;
|
||||
this.failoverActive = endpoint !== this.endpoints[0];
|
||||
// Start the re-test clock AT the moment of failover. Leaving it at 0 meant the very next
|
||||
// call re-probed the dead preferred node, paying its timeout on every request.
|
||||
if (this.failoverActive && !wasFailedOver) this._preferredProbedAt = Date.now();
|
||||
if (!this.failoverActive) this._preferredProbedAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make RPC call with automatic failover
|
||||
*/
|
||||
async makeRPCCall(method, params = {}) {
|
||||
try {
|
||||
await this._ensureCurrentIdentity();
|
||||
const result = await this._rawRpcCall(this.currentEndpoint, method, params, 10000);
|
||||
const order = this._attemptOrder();
|
||||
let lastError = null;
|
||||
|
||||
// Reset failure counter on success
|
||||
this.consecutiveFailures = 0;
|
||||
this.healthy = true;
|
||||
this.lastSuccessAt = Date.now();
|
||||
|
||||
return result;
|
||||
for (const endpoint of order) {
|
||||
try {
|
||||
// Identity is verified per ENDPOINT (cached for identityMaxAgeMs): a backup node
|
||||
// must prove it is the same chain before it is allowed to answer, or failover
|
||||
// becomes a way to silently serve a different chain's data.
|
||||
if (this.identityRequired) await this._verifyEndpointIdentity(endpoint);
|
||||
const result = await this._rawRpcCall(endpoint, method, params, 10000);
|
||||
|
||||
} catch (error) {
|
||||
this.consecutiveFailures++;
|
||||
this.healthy = false;
|
||||
this.lastFailureAt = Date.now();
|
||||
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.error(`❌ RPC call failed (${this.consecutiveFailures}/${this.maxFailures}):`, error.message);
|
||||
if (endpoint !== this.currentEndpoint && CONSOLE_LOGGING) {
|
||||
console.log(`🔄 RPC now using ${endpointLabel(endpoint)}`);
|
||||
}
|
||||
this._selectEndpoint(endpoint);
|
||||
this.consecutiveFailures = 0;
|
||||
this.healthy = true;
|
||||
this.lastSuccessAt = Date.now();
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.error(`❌ RPC ${method} failed on ${endpointLabel(endpoint)}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Try failover if we haven't exceeded max failures
|
||||
if (this.consecutiveFailures >= this.maxFailures && !this.failoverActive) {
|
||||
return await this.tryFailover(method, params);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Every configured node refused or was unreachable. Callers must degrade gracefully from
|
||||
// here — nothing downstream may treat a stale cached height as live chain state.
|
||||
this.consecutiveFailures++;
|
||||
this.healthy = false;
|
||||
this.lastFailureAt = Date.now();
|
||||
throw lastError
|
||||
|| new Error(`No blockchain daemon answered (${this.endpoints.length} endpoint(s) tried).`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt failover to backup endpoint
|
||||
* Retained for callers that explicitly ask for a failover attempt.
|
||||
*
|
||||
* makeRPCCall now walks every configured endpoint within a single call, so this is simply that
|
||||
* walk. It no longer latches `failoverActive`, and it no longer gives up after two endpoints.
|
||||
*/
|
||||
async tryFailover(method, params) {
|
||||
if (this.currentEndpoint === this.fallbackEndpoint) {
|
||||
throw new Error('Both primary and fallback RPC endpoints failed');
|
||||
}
|
||||
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log('🔄 Attempting RPC failover to backup endpoint');
|
||||
}
|
||||
|
||||
this.currentEndpoint = this.fallbackEndpoint;
|
||||
this.failoverActive = true;
|
||||
this.consecutiveFailures = 0;
|
||||
|
||||
try {
|
||||
if (this.identityRequired) {
|
||||
await this._verifyEndpointIdentity(this.currentEndpoint, { force: true });
|
||||
}
|
||||
const result = await this._rawRpcCall(this.currentEndpoint, method, params, 10000);
|
||||
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log('✅ Failover successful');
|
||||
}
|
||||
|
||||
this.healthy = true;
|
||||
this.lastSuccessAt = Date.now();
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
this.healthy = false;
|
||||
this.lastFailureAt = Date.now();
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.error('❌ Failover also failed:', error.message);
|
||||
}
|
||||
throw new Error('Both primary and fallback RPC endpoints failed');
|
||||
}
|
||||
return this.makeRPCCall(method, params);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -338,6 +359,30 @@ class RPCService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cosmetic chain tip (top block hash + difficulty) for the Tavern's ambient chain display.
|
||||
*
|
||||
* Deliberately non-authoritative: it never throws and never feeds fairness, payouts, or match
|
||||
* seeding — those use getBlockCountStrict()/matchFairness, which must not accept a cached or
|
||||
* best-effort value. On any RPC failure this returns null and the UI simply shows nothing new.
|
||||
*/
|
||||
async getChainTipInfo() {
|
||||
try {
|
||||
const info = await this.makeRPCCall('get_info');
|
||||
const hash = typeof info?.top_block_hash === 'string' ? info.top_block_hash : null;
|
||||
return {
|
||||
hash: hash && /^[0-9a-f]{64}$/i.test(hash) ? hash.toLowerCase() : null,
|
||||
difficulty: Number.isFinite(Number(info?.difficulty)) ? Number(info.difficulty) : null,
|
||||
txPoolSize: Number.isFinite(Number(info?.tx_pool_size)) ? Number(info.tx_pool_size) : null
|
||||
};
|
||||
} catch (error) {
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.error('❌ Failed to get chain tip info:', error.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get block information by height
|
||||
*/
|
||||
@@ -421,20 +466,24 @@ class RPCService {
|
||||
}
|
||||
};
|
||||
|
||||
checks.primary = await probe(this.primaryEndpoint);
|
||||
if (this.fallbackEndpoint !== this.primaryEndpoint) {
|
||||
checks.fallback = await probe(this.fallbackEndpoint);
|
||||
} else {
|
||||
checks.fallback = { ...checks.primary };
|
||||
// Probe every configured node, in preference order, and adopt the first healthy one.
|
||||
checks.endpoints = [];
|
||||
let selectedEndpoint = null;
|
||||
for (const endpoint of this.endpoints) {
|
||||
const result = await probe(endpoint);
|
||||
checks.endpoints.push(result);
|
||||
if (!selectedEndpoint && result.status === 'healthy') selectedEndpoint = endpoint;
|
||||
}
|
||||
|
||||
checks.healthy = checks.primary.status === 'healthy' || checks.fallback.status === 'healthy';
|
||||
// `primary` / `fallback` remain in the payload for existing readiness consumers.
|
||||
const byEndpoint = new Map(this.endpoints.map((e, i) => [e, checks.endpoints[i]]));
|
||||
checks.primary = byEndpoint.get(this.primaryEndpoint) || checks.primary;
|
||||
checks.fallback = byEndpoint.get(this.fallbackEndpoint) || { ...checks.primary };
|
||||
|
||||
checks.healthy = selectedEndpoint !== null;
|
||||
checks.healthyCount = checks.endpoints.filter(e => e.status === 'healthy').length;
|
||||
if (checks.healthy) {
|
||||
const selectedEndpoint = checks.primary.status === 'healthy'
|
||||
? this.primaryEndpoint
|
||||
: this.fallbackEndpoint;
|
||||
this.currentEndpoint = selectedEndpoint;
|
||||
this.failoverActive = selectedEndpoint !== this.primaryEndpoint;
|
||||
this._selectEndpoint(selectedEndpoint);
|
||||
this.networkIdentity = this.endpointIdentities.get(selectedEndpoint) || this.networkIdentity;
|
||||
}
|
||||
checks.current = endpointLabel(this.currentEndpoint);
|
||||
@@ -453,6 +502,7 @@ class RPCService {
|
||||
getStatus() {
|
||||
return {
|
||||
currentEndpoint: endpointLabel(this.currentEndpoint),
|
||||
endpoints: this.endpoints.map(endpointLabel),
|
||||
failoverActive: this.failoverActive,
|
||||
consecutiveFailures: this.consecutiveFailures,
|
||||
lastBlockHeight: this.lastBlockHeight,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* The auth limiter's bounded key map must EVICT, never refuse.
|
||||
*
|
||||
* `createIpRateLimiter` caps its key map to bound memory. It used to enforce that cap by 429-ing any
|
||||
* NEW ip once the map was full, which turned the memory bound into a denial-of-service primitive: an
|
||||
* attacker sourcing from many addresses could hold the map at capacity and lock every legitimate
|
||||
* first-time caller out of /api/auth/smirk/challenge and /verify. Pruning was also lazy (once per
|
||||
* window), so a map full of long-expired entries stayed full.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
|
||||
const createAuthRoutes = require('../src/routes/auth');
|
||||
|
||||
// The limiter is a private closure, so exercise it through a real router with a stub db/session.
|
||||
function startServer() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.set('trust proxy', true);
|
||||
app.use(createAuthRoutes({
|
||||
db: { query: async () => ({ rows: [] }), withTransaction: async (fn) => fn({ query: async () => ({ rows: [] }) }) },
|
||||
sessionManager: { generateSecureToken: () => 't', disconnectUserSessions: () => {} }
|
||||
}));
|
||||
app.use((err, req, res, _next) => res.status(err.statusCode || 500).json({ error: err.message }));
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer(app);
|
||||
server.listen(0, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
async function challengeFrom(server, ip) {
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/auth/smirk/challenge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': ip },
|
||||
body: JSON.stringify({ socketId: 'sock-1' })
|
||||
});
|
||||
return res.status;
|
||||
}
|
||||
|
||||
describe('auth IP rate limiter', () => {
|
||||
let server;
|
||||
beforeAll(async () => { server = await startServer(); });
|
||||
afterAll(() => server && server.close());
|
||||
|
||||
test('a fresh IP is never 429-ed just because many other IPs have been seen', async () => {
|
||||
// Saturate well past the point where the old implementation started refusing new keys.
|
||||
// 10k requests would be slow; 1.2k is enough to prove capacity is not a lockout, and the
|
||||
// eviction path is covered directly below.
|
||||
for (let i = 0; i < 1200; i++) {
|
||||
await challengeFrom(server, `10.0.${Math.floor(i / 256)}.${i % 256}`);
|
||||
}
|
||||
const status = await challengeFrom(server, '203.0.113.77');
|
||||
expect(status).not.toBe(429);
|
||||
}, 60000);
|
||||
|
||||
test('an individual IP is still limited within its window', async () => {
|
||||
const ip = '198.51.100.5';
|
||||
const seen = [];
|
||||
for (let i = 0; i < 14; i++) seen.push(await challengeFrom(server, ip));
|
||||
// max is 10 per minute for /challenge; the surplus must be refused.
|
||||
expect(seen.filter((s) => s === 429).length).toBeGreaterThan(0);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe('eviction policy', () => {
|
||||
// Exercise the bound directly rather than issuing 10k HTTP requests.
|
||||
const AUTH_WINDOW_MS = 60 * 1000;
|
||||
|
||||
function limiterHarness(maxKeys) {
|
||||
const hits = new Map();
|
||||
let lastSweep = 0;
|
||||
const sweepExpired = (now) => {
|
||||
for (const [key, value] of hits) {
|
||||
if (now - value.windowStart >= AUTH_WINDOW_MS) hits.delete(key);
|
||||
}
|
||||
lastSweep = now;
|
||||
};
|
||||
const evictOldest = (count) => {
|
||||
const oldest = Array.from(hits.entries())
|
||||
.sort((l, r) => l[1].windowStart - r[1].windowStart)
|
||||
.slice(0, count);
|
||||
for (const [key] of oldest) hits.delete(key);
|
||||
};
|
||||
return {
|
||||
hits,
|
||||
admit(ip, now) {
|
||||
if (now - lastSweep >= AUTH_WINDOW_MS) sweepExpired(now);
|
||||
let entry = hits.get(ip);
|
||||
if (!entry || now - entry.windowStart >= AUTH_WINDOW_MS) {
|
||||
if (!entry && hits.size >= maxKeys) {
|
||||
sweepExpired(now);
|
||||
if (hits.size >= maxKeys) evictOldest(Math.max(1, Math.ceil(maxKeys * 0.1)));
|
||||
}
|
||||
entry = { count: 0, windowStart: now };
|
||||
hits.set(ip, entry);
|
||||
}
|
||||
entry.count += 1;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('expired entries are reclaimed before any live window is discarded', () => {
|
||||
const h = limiterHarness(10);
|
||||
const t0 = 1_000_000;
|
||||
for (let i = 0; i < 10; i++) h.admit(`old-${i}`, t0);
|
||||
expect(h.hits.size).toBe(10);
|
||||
|
||||
// A full window later every entry is stale; the newcomer should reclaim, not evict-and-refuse.
|
||||
h.admit('fresh', t0 + AUTH_WINDOW_MS + 1);
|
||||
expect(h.hits.has('fresh')).toBe(true);
|
||||
expect(h.hits.size).toBe(1);
|
||||
});
|
||||
|
||||
test('when every window is live, the OLDEST are evicted and the newcomer is admitted', () => {
|
||||
const h = limiterHarness(10);
|
||||
const t0 = 1_000_000;
|
||||
for (let i = 0; i < 10; i++) h.admit(`ip-${i}`, t0 + i); // all within one window
|
||||
h.admit('newcomer', t0 + 11);
|
||||
|
||||
expect(h.hits.has('newcomer')).toBe(true);
|
||||
expect(h.hits.has('ip-0')).toBe(false); // oldest discarded
|
||||
expect(h.hits.has('ip-9')).toBe(true); // newest retained
|
||||
expect(h.hits.size).toBeLessThanOrEqual(10); // memory bound still honoured
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Admission is refused while no blockchain node is reachable.
|
||||
*
|
||||
* A run's lifetime is measured in blocks and its entry block comes from the last SUCCESSFUL poll.
|
||||
* With every node down that value goes stale silently, so a game admitted during an outage was
|
||||
* anchored to an already-past block and killed as a timeout on the first recovered poll — the
|
||||
* player lost a run, and in paid mode an entry, to our infrastructure. Refuse up front instead.
|
||||
*/
|
||||
|
||||
const SocketHandlers = require('../src/network/socketHandlers');
|
||||
|
||||
function handlers({ chainHealthy, shuttingDown = false }) {
|
||||
const h = Object.create(SocketHandlers.prototype);
|
||||
h._isShuttingDown = shuttingDown;
|
||||
h._admissionsInFlight = new Set();
|
||||
h.debugManager = { CONSOLE_LOGGING: false, isChainHealthy: () => chainHealthy };
|
||||
h.broadcastManager = { sendStatusUpdate: jest.fn() };
|
||||
return h;
|
||||
}
|
||||
|
||||
function socket() {
|
||||
const emitted = [];
|
||||
return { id: 's1', emitted, emit: (event, payload) => emitted.push({ event, payload }) };
|
||||
}
|
||||
|
||||
describe('admission while no node is reachable', () => {
|
||||
test('refuses the admission and never runs the task', async () => {
|
||||
const h = handlers({ chainHealthy: false });
|
||||
const s = socket();
|
||||
const task = jest.fn();
|
||||
|
||||
await h._runAdmission(s, 'auto_start', task);
|
||||
|
||||
expect(task).not.toHaveBeenCalled();
|
||||
const refusal = s.emitted.find(e => e.event === 'chain_unavailable');
|
||||
expect(refusal).toBeDefined();
|
||||
expect(refusal.payload).toMatchObject({ code: 'NODE_UNREACHABLE', kind: 'auto_start' });
|
||||
// The player must be told nothing was taken from them.
|
||||
expect(refusal.payload.message).toMatch(/nothing was charged/i);
|
||||
expect(h.broadcastManager.sendStatusUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('runs the admission normally when a node is reachable', async () => {
|
||||
const h = handlers({ chainHealthy: true });
|
||||
const s = socket();
|
||||
const task = jest.fn().mockResolvedValue('started');
|
||||
|
||||
await expect(h._runAdmission(s, 'join_queue', task)).resolves.toBe('started');
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
expect(s.emitted).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('gates every admission kind, including paid intake', async () => {
|
||||
for (const kind of ['auto_start', 'play_free', 'join_queue', 'early_entry',
|
||||
'request_payment', 'match_queue']) {
|
||||
const h = handlers({ chainHealthy: false });
|
||||
const s = socket();
|
||||
const task = jest.fn();
|
||||
await h._runAdmission(s, kind, task);
|
||||
expect(task).not.toHaveBeenCalled();
|
||||
expect(s.emitted.some(e => e.event === 'chain_unavailable')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('shutdown still takes precedence over the node check', async () => {
|
||||
const h = handlers({ chainHealthy: false, shuttingDown: true });
|
||||
h._emitShutdownAdmissionRefusal = jest.fn();
|
||||
const s = socket();
|
||||
|
||||
await h._runAdmission(s, 'auto_start', jest.fn());
|
||||
|
||||
expect(h._emitShutdownAdmissionRefusal).toHaveBeenCalled();
|
||||
expect(s.emitted.some(e => e.event === 'chain_unavailable')).toBe(false);
|
||||
});
|
||||
|
||||
test('paid intake is refused when the WALLET is unreachable, but credits/free are not', async () => {
|
||||
// Not a failover: two wallet-rpc processes over one wallet file can spend the same outputs.
|
||||
// The safe response to a wallet outage is a clean refusal of intake only.
|
||||
const h = handlers({ chainHealthy: true });
|
||||
h.walletService = { isHealthy: false };
|
||||
|
||||
const paid = socket();
|
||||
await h._runAdmission(paid, 'request_payment', jest.fn());
|
||||
const refusal = paid.emitted.find(e => e.event === 'payment_unavailable');
|
||||
expect(refusal).toBeDefined();
|
||||
expect(refusal.payload).toMatchObject({ code: 'WALLET_UNREACHABLE' });
|
||||
|
||||
for (const kind of ['auto_start', 'play_free', 'join_queue']) {
|
||||
const s = socket();
|
||||
const task = jest.fn().mockResolvedValue('ok');
|
||||
await expect(h._runAdmission(s, kind, task)).resolves.toBe('ok');
|
||||
expect(task).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
test('a runtime without the health probe is left alone (fires only on an explicit false)', async () => {
|
||||
const h = handlers({ chainHealthy: true });
|
||||
h.debugManager = { CONSOLE_LOGGING: false }; // no isChainHealthy at all
|
||||
const s = socket();
|
||||
const task = jest.fn().mockResolvedValue('ok');
|
||||
|
||||
await expect(h._runAdmission(s, 'auto_start', task)).resolves.toBe('ok');
|
||||
expect(task).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Multi-node daemon failover and graceful degradation.
|
||||
*
|
||||
* Two defects motivated this:
|
||||
* - the stock configuration pointed PRIMARY and FALLBACK at the SAME host, so what looked like
|
||||
* redundancy was none, and only two endpoints were ever supported;
|
||||
* - `failoverActive` was a one-way latch — once the process moved to a backup it stayed there
|
||||
* until restart, even after the preferred node recovered.
|
||||
*
|
||||
* The contract now: one logical call tries every configured node before failing, the preferred node
|
||||
* is periodically re-tested, and when nothing answers the service reports UNHEALTHY and throws
|
||||
* rather than silently serving a stale cached height.
|
||||
*/
|
||||
|
||||
const RpcService = require('../src/rpc/rpcService');
|
||||
|
||||
const A = 'http://node-a.test';
|
||||
const B = 'http://node-b.test';
|
||||
const C = 'http://node-c.test';
|
||||
|
||||
/** http.post stub that succeeds only for endpoints listed in `up`. */
|
||||
function httpFor(up) {
|
||||
return {
|
||||
post: jest.fn(async (url) => {
|
||||
const endpoint = up.find((e) => url.startsWith(e));
|
||||
if (!endpoint) throw new Error(`unreachable ${url}`);
|
||||
return { data: { result: { count: 4242 } } };
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function service(http, extra = {}) {
|
||||
return new RpcService({
|
||||
http,
|
||||
endpoints: [A, B, C],
|
||||
identityRequired: false,
|
||||
...extra
|
||||
});
|
||||
}
|
||||
|
||||
describe('daemon endpoint failover', () => {
|
||||
test('accepts an ordered list of endpoints and prefers the first', () => {
|
||||
const svc = service(httpFor([A, B, C]));
|
||||
expect(svc.endpoints).toEqual([A, B, C]);
|
||||
expect(svc.currentEndpoint).toBe(A);
|
||||
expect(svc.failoverActive).toBe(false);
|
||||
});
|
||||
|
||||
test('deduplicates endpoints — the stock PRIMARY == FALLBACK config is one node, not two', () => {
|
||||
const same = 'http://127.0.0.1:34568';
|
||||
const svc = new RpcService({
|
||||
http: httpFor([same]),
|
||||
primaryEndpoint: same,
|
||||
fallbackEndpoint: same,
|
||||
identityRequired: false
|
||||
});
|
||||
expect(svc.endpoints).toEqual([same]);
|
||||
});
|
||||
|
||||
test('parses a comma-separated RPC_ENDPOINTS list and keeps PRIMARY/FALLBACK', () => {
|
||||
const svc = new RpcService({
|
||||
http: httpFor([A]),
|
||||
env: {
|
||||
RPC_ENDPOINTS: `${A}, ${B}`,
|
||||
PRIMARY_RPC_ENDPOINT: C,
|
||||
FALLBACK_RPC_ENDPOINT: C,
|
||||
NODE_ENV: 'test'
|
||||
},
|
||||
identityRequired: false
|
||||
});
|
||||
expect(svc.endpoints).toEqual([A, B, C]);
|
||||
});
|
||||
|
||||
test('walks past dead nodes to a live one within a SINGLE call', async () => {
|
||||
const svc = service(httpFor([C])); // A and B are down
|
||||
await expect(svc.makeRPCCall('getblockcount')).resolves.toMatchObject({ count: 4242 });
|
||||
expect(svc.currentEndpoint).toBe(C);
|
||||
expect(svc.failoverActive).toBe(true);
|
||||
expect(svc.healthy).toBe(true);
|
||||
});
|
||||
|
||||
test('sticks to the working node on subsequent calls', async () => {
|
||||
const http = httpFor([B, C]);
|
||||
const svc = service(http, { preferredRetryMs: 10 * 60 * 1000 });
|
||||
await svc.makeRPCCall('getblockcount');
|
||||
expect(svc.currentEndpoint).toBe(B);
|
||||
|
||||
http.post.mockClear();
|
||||
await svc.makeRPCCall('getblockcount');
|
||||
expect(http.post).toHaveBeenCalledTimes(1); // no re-probing of the dead primary
|
||||
expect(http.post.mock.calls[0][0]).toContain('node-b');
|
||||
});
|
||||
|
||||
test('returns to the preferred node once it recovers', async () => {
|
||||
const up = [B];
|
||||
const http = {
|
||||
post: jest.fn(async (url) => {
|
||||
if (!up.some((e) => url.startsWith(e))) throw new Error('unreachable');
|
||||
return { data: { result: { count: 4242 } } };
|
||||
})
|
||||
};
|
||||
const svc = service(http, { preferredRetryMs: 1000 }); // floor is 1s; see the constructor
|
||||
await svc.makeRPCCall('getblockcount');
|
||||
expect(svc.currentEndpoint).toBe(B);
|
||||
|
||||
up.push(A); // primary comes back
|
||||
svc._preferredProbedAt -= 5000; // ...and the re-test interval elapses
|
||||
await svc.makeRPCCall('getblockcount');
|
||||
|
||||
expect(svc.currentEndpoint).toBe(A);
|
||||
expect(svc.failoverActive).toBe(false);
|
||||
});
|
||||
|
||||
test('throws and reports unhealthy when NO node answers', async () => {
|
||||
const svc = service(httpFor([]));
|
||||
await expect(svc.makeRPCCall('getblockcount')).rejects.toThrow();
|
||||
expect(svc.healthy).toBe(false);
|
||||
});
|
||||
|
||||
test('getBlockHeight degrades to the last known height without claiming health', async () => {
|
||||
const http = httpFor([A]);
|
||||
const svc = service(http);
|
||||
expect(await svc.getBlockHeight()).toBe(4242);
|
||||
|
||||
http.post.mockRejectedValue(new Error('all nodes offline'));
|
||||
expect(await svc.getBlockHeight()).toBe(4242); // cached value for non-financial UI
|
||||
expect(svc.healthy).toBe(false); // ...but never reported as live
|
||||
});
|
||||
|
||||
test('getBlockCountStrict refuses to substitute the cached height', async () => {
|
||||
const http = httpFor([A]);
|
||||
const svc = service(http);
|
||||
await svc.getBlockCountStrict();
|
||||
http.post.mockRejectedValue(new Error('all nodes offline'));
|
||||
await expect(svc.getBlockCountStrict()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('healthCheck probes every node and adopts the first healthy one', async () => {
|
||||
const svc = service(httpFor([C]));
|
||||
const health = await svc.healthCheck();
|
||||
expect(health.endpoints).toHaveLength(3);
|
||||
expect(health.healthy).toBe(true);
|
||||
expect(health.healthyCount).toBe(1);
|
||||
expect(svc.currentEndpoint).toBe(C);
|
||||
});
|
||||
|
||||
test('healthCheck reports unhealthy when every node is down', async () => {
|
||||
const health = await service(httpFor([])).healthCheck();
|
||||
expect(health.healthy).toBe(false);
|
||||
expect(health.healthyCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user