Files
wownerogue/src/network/tavernManager.js
T
jwinterm dacbf356ed fix: close pre-launch review findings and light the premium renderers
Adversarial multi-agent review of the launch candidate produced 22 confirmed
findings (12 more were refuted). This closes the critical, high, and the
mediums/lows that are cheap and real, then does the iso/3D/Tavern art pass.

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

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

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

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

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

126 suites / 1005 tests pass.
2026-07-26 07:29:47 -04:00

294 lines
12 KiB
JavaScript

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)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* TavernManager — transport and lifecycle for the Tavern mode.
*
* Owns a shared Room, a server-tick timer, and Socket.IO room broadcasts. Structured like
* SpectatorManager. Disabled unless TAVERN_ENABLED=true: when disabled, no timer runs and
* join requests are refused, so this is inert on instances that don't opt in.
*
* Movement is server-authoritative. Clients send a direction; the Room validates it. State
* 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, chatModeration = null } = {}) {
this.io = io;
this.debugManager = debugManager;
this.enabled = process.env.TAVERN_ENABLED === 'true';
this.roomId = roomId;
// Snapshot broadcast cadence. Default 100ms (10 Hz); override with TAVERN_TICK_MS.
const envTick = parseInt(process.env.TAVERN_TICK_MS, 10);
this.tickMs = tickMs || (Number.isFinite(envTick) && envTick > 0 ? envTick : 100);
this.moveCooldownMs = 60; // per-occupant flood guard
this._lastMoveAt = new Map(); // socketId -> timestamp
this.chatCooldownMs = 900; // per-occupant chat flood guard
this._lastChatAt = new Map(); // socketId -> timestamp
this._tickInterval = null;
this.entitlementProvider = entitlementProvider;
this.room = new Room({ id: this.roomId, type: 'tavern', roomData: roomData, roomUrl: roomUrl });
// Chat: prefer the shared GLOBAL chat provider (persistent history + cross-page/relay
// fan-out) so the tavern participates in one global chat with backlog. When it isn't
// 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() {
return `tavern:${this.roomId}`;
}
/** Start the server tick. No-op (and no timer) unless enabled. */
initialize() {
if (!this.enabled) return;
this._tickInterval = setInterval(() => this._tick(), this.tickMs);
if (this.debugManager?.CONSOLE_LOGGING) {
console.log(`Tavern enabled (room '${this.roomId}', tick ${this.tickMs}ms)`);
}
}
_tick() {
this.room.tick();
if (this.room.size > 0) {
this.io.to(this.channel).emit('tavern_update', this.room.snapshot());
}
}
_sanitizeName(name) {
if (typeof name !== 'string') return null;
// Keep letters/numbers/space and a few separators; cap length.
const cleaned = name.replace(/[^\p{L}\p{N} _.-]/gu, '').trim().slice(0, 16);
return cleaned.length ? cleaned : null;
}
_validateAvatar(avatar) {
return Appearance.isValidAvatar(avatar) ? avatar : 'default';
}
async _entitlementsFor(socket) {
if (typeof this.entitlementProvider !== 'function') return { premium: false, totalCreditsPurchased: 0 };
try {
return await this.entitlementProvider(socket);
} catch (_) {
return { premium: false, totalCreditsPurchased: 0 };
}
}
_normalizeAppearance(data = {}, entitlements = {}) {
const raw = data.appearance || { avatar: data.avatar };
return Entitlements.normalizeAppearance(raw, entitlements);
}
_normalizeDir(data) {
if (data && typeof data.dir === 'string') {
switch (data.dir) {
case 'up': return { dx: 0, dy: -1 };
case 'down': return { dx: 0, dy: 1 };
case 'left': return { dx: -1, dy: 0 };
case 'right': return { dx: 1, dy: 0 };
default: return { dx: 0, dy: 0 };
}
}
return {
dx: Math.sign(Number(data && data.dx) || 0),
dy: Math.sign(Number(data && data.dy) || 0)
};
}
async join(socket, data = {}) {
if (!this.enabled) {
socket.emit('tavern_error', { message: 'Tavern is not enabled on this server.' });
return { success: false };
}
const name = this._sanitizeName(data.name);
const entitlements = await this._entitlementsFor(socket);
const appearance = this._normalizeAppearance(data, entitlements);
const avatar = appearance.avatar;
const occ = this.room.addOccupant(socket.id, { name, avatar, appearance });
if (!occ) {
socket.emit('tavern_error', { message: 'The tavern is full.' });
return { success: false };
}
socket.join(this.channel);
// Full state (map + occupants) to the joiner; the next tick shows the arrival to others.
socket.emit('tavern_joined', { you: socket.id, state: this.room.fullState() });
// Send recent GLOBAL chat backlog so the tavern shows history on arrival (unified chat).
if (this.globalChatProvider && typeof this.globalChatProvider.getHistory === 'function') {
this.globalChatProvider.getHistory({ scope: 'global', limit: 50 })
.then(messages => { if (messages && messages.length) socket.emit('chat_history', { messages }); })
.catch(err => { if (this.debugManager?.CONSOLE_LOGGING) console.error('[Tavern] history load failed:', err.message); });
}
if (this.debugManager?.CONSOLE_LOGGING) {
console.log(`[Tavern] +${String(socket.id).slice(0, 6)} (${this.room.size} present)`);
}
return { success: true };
}
move(socket, data = {}) {
if (!this.enabled) return;
if (!this.room.getOccupant(socket.id)) return;
const now = Date.now();
if (now - (this._lastMoveAt.get(socket.id) || 0) < this.moveCooldownMs) return;
this._lastMoveAt.set(socket.id, now);
const { dx, dy } = this._normalizeDir(data);
if (dx === 0 && dy === 0) return;
this.room.moveOccupant(socket.id, dx, dy); // next tick broadcasts the result
}
/**
* 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
let text = (data && typeof data.text === 'string') ? data.text.trim() : '';
if (!text) return;
if (text.length > TavernManager.MAX_CHAT_LENGTH) text = text.slice(0, TavernManager.MAX_CHAT_LENGTH);
const now = Date.now();
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".
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);
// Escape here (delivery is trusted-escaped, rendered as HTML on the client). Route to the
// global chat (persisted + broadcast to everyone + relayed over nostr when enabled) so the
// tavern shares one global conversation; fall back to tavern-scoped if no global provider.
const provider = this.globalChatProvider || this.chatProvider;
const scope = this.globalChatProvider ? 'global' : this.channel;
provider.publish({
scope,
username,
text: escapeHtml(text),
ts: now,
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);
}
handleDisconnect(socketId, socket = null) {
const existed = this.room.removeOccupant(socketId);
this._lastMoveAt.delete(socketId);
this._lastChatAt.delete(socketId);
if (existed) {
const sock = socket || (this.io.sockets?.sockets?.get?.(socketId));
if (sock) sock.leave(this.channel);
if (this.debugManager?.CONSOLE_LOGGING) {
console.log(`[Tavern] -${String(socketId).slice(0, 6)} (${this.room.size} present)`);
}
}
}
getStats() {
return { enabled: this.enabled, roomId: this.roomId, occupants: this.room.size };
}
shutdown() {
if (this._tickInterval) {
clearInterval(this._tickInterval);
this._tickInterval = null;
}
this._lastMoveAt.clear();
this._lastChatAt.clear();
}
}
// Cosmetic avatar ids the server will accept. Availability/unlocks are enforced via
// the Entitlements policy during join.
TavernManager.AVATARS = Appearance.avatarIds();
TavernManager.PREMIUM_AVATARS = Appearance.premiumAvatarIds();
TavernManager.MAX_CHAT_LENGTH = 200;
module.exports = TavernManager;