From ebf14560ddddc8a0762f92c3d9af0869a37bfa46 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Fri, 10 Jul 2026 19:54:38 -0400 Subject: [PATCH] feat(tavern): unified global chat with persistent history in the tavern The tavern used ephemeral, room-scoped chat with no backlog, so entering it showed a blank log. Wire it to the shared global chat provider instead: - TavernManager takes a globalChatProvider (the lobby's chat provider) and publishes tavern messages to the 'global' scope, so tavern + lobby share ONE conversation that persists to Postgres and (when NOSTR_CHAT_ENABLED) fans out over relay.smirk.cash. Falls back to the old tavern-scoped ephemeral provider when none is injected. - On join, the tavern sends the recent global backlog (chat_history) to the joiner so history shows immediately. - tavern.html: render chat_history on join and accept global-scope broadcasts (not just this room's), so lobby<->tavern messages flow both ways. Tests: +3 (join sends backlog, chat routes to global scope, legacy fallback stays tavern-scoped). Suite 55 suites / 394 tests green. --- html/tavern.html | 15 +++++++++++- src/network/socketHandlers.js | 5 +++- src/network/tavernManager.js | 25 ++++++++++++++----- test/tavernManager.test.js | 46 +++++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 8 deletions(-) diff --git a/html/tavern.html b/html/tavern.html index c96827f..9353214 100644 --- a/html/tavern.html +++ b/html/tavern.html @@ -323,7 +323,10 @@ // ---- Chat -------------------------------------------------------------------- socket.on('chat_broadcast', function (m) { - if (!m || !state.channel || m.scope !== state.channel) return; + if (!m) return; + // Accept the unified GLOBAL chat (no scope / 'global') as well as this tavern's room. + 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) { @@ -335,6 +338,16 @@ socket.on('tavern_notice', function (n) { if (n && n.message) addSystemLine(n.message); }); + // Global chat backlog sent on join — render it so the tavern shows history, not a blank log. + socket.on('chat_history', function (payload) { + var msgs = payload && payload.messages; + if (!msgs || !msgs.length) return; + for (var i = 0; i < msgs.length; i++) { + var mm = msgs[i] || {}; + var uname = mm.username || mm.playerId || (mm.socketId ? String(mm.socketId).slice(0, 6) : '???'); + addChatLine(uname, mm.message || ''); + } + }); function addSystemLine(text) { var line = document.createElement('div'); line.className = 'cline csys'; diff --git a/src/network/socketHandlers.js b/src/network/socketHandlers.js index e17b993..68dd749 100644 --- a/src/network/socketHandlers.js +++ b/src/network/socketHandlers.js @@ -209,7 +209,10 @@ class SocketHandlers { debugManager: this.debugManager, roomData: tavernRoomData, roomUrl: tavernRoomData ? tavernRoomUrl : null, - entitlementProvider: async (socket) => this._entitlementsForSocket(socket) + 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 }); this.tavernManager.initialize(); // Initialize match mode queue, scheduler, and manager. Inert unless MATCH_ENABLED=true. diff --git a/src/network/tavernManager.js b/src/network/tavernManager.js index 59b865b..fd6b649 100644 --- a/src/network/tavernManager.js +++ b/src/network/tavernManager.js @@ -23,7 +23,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 } = {}) { + constructor({ io, debugManager, roomId = 'main', tickMs = null, roomData = null, roomUrl = null, entitlementProvider = null, globalChatProvider = null } = {}) { this.io = io; this.debugManager = debugManager; this.enabled = process.env.TAVERN_ENABLED === 'true'; @@ -42,8 +42,10 @@ class TavernManager { this.room = new Room({ id: this.roomId, type: 'tavern', roomData: roomData, roomUrl: roomUrl }); - // Tavern chat is ephemeral (no history) and delivered only to the tavern room, via the - // same ChatProvider seam the global chat uses. + // 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 }); } @@ -125,6 +127,13 @@ class TavernManager { 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)`); } @@ -165,9 +174,13 @@ class TavernManager { 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). - this.chatProvider.publish({ - scope: this.channel, + // 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, diff --git a/test/tavernManager.test.js b/test/tavernManager.test.js index 7b8480e..1111300 100644 --- a/test/tavernManager.test.js +++ b/test/tavernManager.test.js @@ -227,5 +227,51 @@ describe('TavernManager', () => { mgr.shutdown(); expect(mgr._tickInterval).toBeNull(); }); + + describe('global chat wiring', () => { + function makeGlobalProvider() { + return { + published: [], + publish(msg) { this.published.push(msg); return Promise.resolve(); }, + getHistory() { return Promise.resolve([{ username: 'bob', message: 'gm all' }]); } + }; + } + + test('join sends the global chat backlog to the joiner', async () => { + const io = makeFakeIo(); + const gcp = makeGlobalProvider(); + const mgr = new TavernManager({ io, globalChatProvider: gcp }); + const s = makeFakeSocket('a', io); + await mgr.join(s, { name: 'Alice' }); + await new Promise(r => setImmediate(r)); // let the history promise resolve + const hist = s.emitted.find(e => e.event === 'chat_history'); + expect(hist).toBeDefined(); + expect(hist.payload.messages[0].message).toBe('gm all'); + }); + + test('chat routes to the GLOBAL scope through the shared provider', async () => { + const io = makeFakeIo(); + const gcp = makeGlobalProvider(); + const mgr = new TavernManager({ io, globalChatProvider: gcp }); + const s = makeFakeSocket('a', io); + await mgr.join(s, { name: 'Alice' }); + 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'); + }); + + test('without a global provider, chat stays tavern-scoped (legacy behavior)', async () => { + const io = makeFakeIo(); + const mgr = new TavernManager({ io }); // no globalChatProvider + const s = makeFakeSocket('a', io); + await mgr.join(s, { name: 'Alice' }); + io.emitted.length = 0; + 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 + }); + }); }); });