diff --git a/src/network/chatHandler.js b/src/network/chatHandler.js index d581f1c..83c770d 100644 --- a/src/network/chatHandler.js +++ b/src/network/chatHandler.js @@ -25,6 +25,7 @@ class ChatHandler { this.chatHistory = new ChatHistoryManager({ db: db || (gameModeManager?.db), debugManager: debugManager, + gameModeManager, maxHistoryMessages: 50 }); @@ -453,4 +454,4 @@ class ChatHandler { } } -module.exports = ChatHandler; \ No newline at end of file +module.exports = ChatHandler; diff --git a/src/network/chatHistoryManager.js b/src/network/chatHistoryManager.js index eb89b4b..7177e35 100644 --- a/src/network/chatHistoryManager.js +++ b/src/network/chatHistoryManager.js @@ -3,10 +3,16 @@ * Handles persistent storage and retrieval of chat messages */ +const { isWowPaymentsPaused } = require('../config/operatedProductProfiles'); + +const LEGACY_PAYOUT_TIP = 'Escape the dungeon to win. Collect treasure for bonus payout!'; +const NO_PAYOUT_TIP = 'Escape the dungeon to win. Treasure can improve your score.'; + class ChatHistoryManager { - constructor({ db, debugManager, maxHistoryMessages = 50, inMemoryFallbackSize = 100 }) { + constructor({ db, debugManager, gameModeManager = null, maxHistoryMessages = 50, inMemoryFallbackSize = 100 }) { this.db = db; this.debugManager = debugManager; + this.gameModeManager = gameModeManager; this.maxHistoryMessages = maxHistoryMessages; // In-memory fallback for when DB is not available @@ -23,6 +29,25 @@ class ChatHistoryManager { this._maxMessageAge = 7 * 24 * 60 * 60 * 1000; // 7 days in ms } + _presentMessage(message) { + // Migration 011 seeded this one system tip before free/prestige-only policy existed. + // Keep the stored row intact and leave player-authored copies untouched. + if (message.socketId !== 'system' || message.username !== 'SERVER' + || message.type !== 'system' || message.message !== LEGACY_PAYOUT_TIP) return message; + const manager = this.gameModeManager; + if (!manager || typeof manager.isPayoutEnabledForMode !== 'function') return message; + let payoutAvailable = false; + try { + payoutAvailable = !isWowPaymentsPaused(process.env) && manager.paymentsEnabled && ( + (manager.directModeEnabled && manager.isPayoutEnabledForMode('PAID_SINGLE')) + || (manager.creditsModeEnabled && manager.isPayoutEnabledForMode('PAID_CREDITS')) + ); + } catch (_) { + // A failed policy read cannot justify advertising an old payout promise. + } + return payoutAvailable ? message : { ...message, message: NO_PAYOUT_TIP }; + } + /** * Initialize the manager - load recent messages into cache */ @@ -206,7 +231,7 @@ class ChatHistoryManager { LIMIT $1 `, [maxCount, minCount]); - return result.rows.map(row => ({ + return result.rows.map(row => this._presentMessage({ id: row.id, socketId: row.socket_id, username: row.username, @@ -225,13 +250,13 @@ class ChatHistoryManager { if (this._cacheInitialized && this._recentMessagesCache.length > 0) { const activeMessages = this._recentMessagesCache.filter(m => !m.deletedAt); - return activeMessages.slice(-limit); + return activeMessages.slice(-limit).map(message => this._presentMessage(message)); } // Final fallback to in-memory if (this._inMemoryHistory.length > 0) { const activeMessages = this._inMemoryHistory.filter(m => !m.deletedAt); - return activeMessages.slice(-limit); + return activeMessages.slice(-limit).map(message => this._presentMessage(message)); } return []; diff --git a/test/chatHistorySeedPresentation.test.js b/test/chatHistorySeedPresentation.test.js new file mode 100644 index 0000000..c8ee57d --- /dev/null +++ b/test/chatHistorySeedPresentation.test.js @@ -0,0 +1,88 @@ +const ChatHistoryManager = require('../src/network/chatHistoryManager'); + +const seededTip = 'Escape the dungeon to win. Collect treasure for bonus payout!'; +const seededRow = Object.freeze({ + id: 1, socket_id: 'system', username: 'SERVER', player_id: 'SERVER', + message_type: 'system', message: seededTip, created_at: new Date() +}); +const userRow = Object.freeze({ + ...seededRow, id: 2, socket_id: 'real-player-socket', username: 'Player', + player_id: 'Player', message_type: 'chat' +}); + +function manager({ payoutsAvailable, db }) { + const gameModeManager = { + paymentsEnabled: true, + directModeEnabled: true, + creditsModeEnabled: false, + isPayoutEnabledForMode: () => payoutsAvailable + }; + return new ChatHistoryManager({ db, gameModeManager }); +} + +describe('historical system chat tip presentation', () => { + test('the read path corrects only the seeded system tip when paid solo payouts are unavailable', async () => { + const rows = [seededRow, userRow]; + const db = { query: jest.fn().mockResolvedValue({ rows }) }; + const history = manager({ payoutsAvailable: false, db }); + + const messages = await history.getRecentMessages(); + + expect(messages[0].message).not.toMatch(/bonus payout/i); + expect(messages[0].message).toMatch(/score/i); + expect(messages[1].message).toBe(seededTip); + expect(rows[0].message).toBe(seededTip); + expect(db.query).toHaveBeenCalledTimes(1); + expect(db.query.mock.calls[0][0]).toMatch(/SELECT/); + }); + + test('cache fallback makes the same correction without changing cached bytes', async () => { + const db = { query: jest.fn() + .mockResolvedValueOnce({ rows: [userRow, seededRow] }) + .mockRejectedValueOnce(new Error('disposable database unavailable')) }; + const history = manager({ payoutsAvailable: false, db }); + const errorLog = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + await history._refreshCache(); + history._cacheInitialized = true; + const messages = await history.getRecentMessages(); + expect(messages[0].message).not.toMatch(/bonus payout/i); + expect(messages[1].message).toBe(seededTip); + expect(history._recentMessagesCache[0].message).toBe(seededTip); + } finally { + errorLog.mockRestore(); + } + }); + + test('the original system tip remains when a paid solo payout is actually available', async () => { + const db = { query: jest.fn().mockResolvedValue({ rows: [seededRow] }) }; + const history = manager({ payoutsAvailable: true, db }); + expect((await history.getRecentMessages())[0].message).toBe(seededTip); + }); + + test('the official WOW pause suppresses the old promise even with stale manager flags', async () => { + const previous = { + OPERATED_PRODUCT_PROFILE: process.env.OPERATED_PRODUCT_PROFILE, + PAYMENTS_ENABLED: process.env.PAYMENTS_ENABLED + }; + try { + process.env.OPERATED_PRODUCT_PROFILE = 'such-play-wow-prestige'; + process.env.PAYMENTS_ENABLED = 'false'; + const db = { query: jest.fn().mockResolvedValue({ rows: [seededRow] }) }; + const history = manager({ payoutsAvailable: true, db }); + expect((await history.getRecentMessages())[0].message).not.toMatch(/bonus payout/i); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); + + test('a system-looking row without the seed provenance is not rewritten', async () => { + const row = { ...seededRow, username: 'Player' }; + const db = { query: jest.fn().mockResolvedValue({ rows: [row] }) }; + const history = manager({ payoutsAvailable: false, db }); + expect((await history.getRecentMessages())[0].message).toBe(seededTip); + }); +});