mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 14:51:26 +02:00
Adversarial multi-agent review of the launch candidate produced 22 confirmed
findings (12 more were refuted). This closes the critical, high, and the
mediums/lows that are cheap and real, then does the iso/3D/Tavern art pass.
CRITICAL — unauthenticated remote kill switch
Socket.IO does not wrap listener invocation, and this process treats
uncaughtException/unhandledRejection as fatal, so ONE unguarded throw in ANY
listener exits the server. `socket.emit('debug_ping')` with no argument did
exactly that, and systemd restarts straight back into it. Every registration
in handleConnection now goes through a safe-dispatch wrapper that contains
sync throws and rejected promises to the offending socket; handleDebugPing
also stops trusting its payload.
HIGH
- NIP-98 login now binds the signed event to this deployment's host. The path
check alone let an event signed for any other origin authenticate here, and
linking ADOPTS the account owning that pubkey — full account takeover.
- Fog of war is enforced server-side. getState() shipped absolute
entrance/exit/treasure coordinates and the monster's exact position in every
frame, with concealment implemented only in the browser. Discovery is sticky;
the monster is reported only while in view.
- NODE_ENV must now be explicit and recognized. Unset, every production gate
read false while DebugManager reported production and silenced logging.
- Tavern chat applies the lobby's ban list, rate limiter and user_id
attribution before entering global chat.
- userRegistry is deleted by socket.id (it was keyed by socket.id and deleted
by socket.client.id, so nothing was ever freed).
MEDIUM / LOW
- Payouts: a legitimately split transfer_split kept its transaction hashes
instead of quarantining already-broadcast funds with tx_hash NULL, and
provably pre-broadcast failures return to 'pending' rather than needs_review.
- RateLimiter key parsing fixed — cleanup() freed nothing and expired windows
never reset, because ids contain colons.
- Enter no longer submits a paid entry from behind a modal.
- Lobby chat no longer double-escapes server-escaped text.
- Rate limits for identity:get / address:prompt / early_entry.
- Spectators released when a game ends by disconnect, not just settlement.
- movementManager / addressManager per-socket maps cleaned on disconnect.
Renderers
Iso: opaque shading instead of translucent fog, additive torch/lantern/beacon
light pools, crevice AO, embers, cold memory wash, and it stops reallocating
the whole canvas every animation frame.
3D: instanced level geometry (3 draw calls), procedural stone/flagstone
materials, torch lights + shadows, real meshes for stairs/chest/monster
(they were humanoid capsules), dust motes, wall cutaway, and lighting
rebalanced now that fog is measured from the camera.
Tavern
Block height and hash fragments drift around the stage edge and ripple when a
block lands, fed by a cosmetic chain tip added to the blockheight broadcast.
Speech bubbles work again (they keyed off a socketId the server stopped
sending).
126 suites / 1005 tests pass.
218 lines
10 KiB
JavaScript
218 lines
10 KiB
JavaScript
// 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);
|