'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { JSDOM } = require('jsdom'); const GAME_HTML = fs.readFileSync(path.join(__dirname, '..', 'public', 'game.html'), 'utf8'); const MENU_HTML = fs.readFileSync(path.join(__dirname, '..', 'public', 'menu.html'), 'utf8'); const flush = async (rounds = 4) => { for (let i = 0; i < rounds; i += 1) { await new Promise((resolve) => setImmediate(resolve)); } }; function createFakeSocket() { return { handlers: new Map(), emitted: [], on(event, handler) { const list = this.handlers.get(event) || []; list.push(handler); this.handlers.set(event, list); return this; }, off(event, handler) { this.handlers.set( event, (this.handlers.get(event) || []).filter((fn) => fn !== handler) ); return this; }, emit(event, payload) { this.emitted.push({ event, payload }); }, trigger(event, payload) { for (const handler of this.handlers.get(event) || []) handler(payload); }, lastEmitted(event) { return [...this.emitted].reverse().find((entry) => entry.event === event); } }; } function makeState(playerId, overrides = {}) { return { roomName: 'testroom', inProgress: true, gameHost: playerId, gameSettings: { scoreLimit: 0, roundLimit: 15, timeLimit: 60, botCount: 0, startText: 'It was a dark and stormy night...' }, players: [ { player_id: playerId, name: 'Alice', score: 0, isBot: false, hasSubmitted: false, hasVoted: false }, { player_id: 'bob-2', name: 'Bob', score: 0, isBot: false, hasSubmitted: false, hasVoted: false } ], yarnStory: [{ player: 'Narrator', str: 'It was a dark and stormy night...' }], currentPhase: 'writing', currentRound: 1, roundLimit: 15, scoreLimit: 0, timeRemaining: 42, round_data: [], lastResults: null, gameWinner: null, ownEntryIndex: -1, ...overrides }; } function loadGame() { const socket = createFakeSocket(); const dom = new JSDOM(GAME_HTML, { url: 'http://localhost/game/testroom', runScripts: 'dangerously', beforeParse(window) { window.io = () => socket; window.alert = () => {}; window.confirm = () => true; window.document.cookie = 'yarn_player_name=Alice'; } }); socket.trigger('connect'); const join = socket.lastEmitted('joinRoom').payload; const playerId = join.playerUUID; const send = (state) => { const { ownEntryIndex, ...rest } = state; socket.trigger('gameState', { ...rest, ownEntryIndex }); }; return { dom, window: dom.window, document: dom.window.document, socket, playerId, send }; } // --------------------------------------------------------------------------- // Lobby & settings // --------------------------------------------------------------------------- test('scoreLimit 0 is sent as 0 instead of silently becoming 100', async (t) => { const { dom, document, socket, playerId, send } = loadGame(); t.after(() => dom.window.close()); send(makeState(playerId, { inProgress: false, currentPhase: 'lobby', round_data: [] })); const scoreInput = document.getElementById('score-limit'); assert.equal(scoreInput.value, '0'); document.getElementById('start-game-btn').click(); const start = socket.lastEmitted('startGame').payload; assert.equal(start.scoreLimit, 0, 'zero score limit must survive'); assert.equal(start.roundLimit, 15); assert.equal(start.timeLimit, 60); assert.equal(start.botCount, 0); assert.equal(start.startText, 'It was a dark and stormy night...'); }); test('non-host lobby sees a read-only settings summary and no start button', (t) => { const { dom, document, playerId, send } = loadGame(); t.after(() => dom.window.close()); send( makeState(playerId, { inProgress: false, currentPhase: 'lobby', gameHost: 'someone-else', gameSettings: { scoreLimit: 20, roundLimit: 10, timeLimit: 45, botCount: 2, startText: 'Start here.' } }) ); assert.equal(document.getElementById('start-game-btn'), null); const summary = document.querySelector('.settings-summary').textContent; assert.match(summary, /Rounds: 10/); assert.match(summary, /Score limit: 20/); assert.match(summary, /Time: 45s/); assert.match(summary, /Bots: 2/); }); // --------------------------------------------------------------------------- // Writing phase & input preservation // --------------------------------------------------------------------------- test('in-progress typing survives gameState and playersUpdate broadcasts', (t) => { const { dom, document, playerId, send, socket } = loadGame(); t.after(() => dom.window.close()); const state = makeState(playerId); send(state); const input = document.getElementById('entry-input'); assert.ok(input, 'writing input is rendered'); input.value = 'Half a sentence, still typing'; // Someone else submits (playersUpdate) and a player joins (gameState). const bobSubmitted = { ...state, players: state.players.map((player) => player.player_id === 'bob-2' ? { ...player, hasSubmitted: true } : player ) }; socket.trigger('playersUpdate', bobSubmitted.players); send(bobSubmitted); socket.trigger('timerUpdate', 41); const sameInput = document.getElementById('entry-input'); assert.equal(sameInput, input, 'the input node is not rebuilt'); assert.equal(sameInput.value, 'Half a sentence, still typing'); assert.equal(document.getElementById('timer').textContent, '41'); }); test('let someone join mid-typing without losing text', (t) => { const { dom, document, playerId, send, socket } = loadGame(); t.after(() => dom.window.close()); send(makeState(playerId)); const input = document.getElementById('entry-input'); input.value = 'Draft'; // Simulate a new player joining: server broadcasts a full gameState. socket.trigger('gameState', makeState(playerId, { players: [ ...makeState(playerId).players, { player_id: 'carol-3', name: 'Carol', score: 0, isBot: false, hasSubmitted: false, hasVoted: false } ] })); assert.equal(document.getElementById('entry-input').value, 'Draft'); }); test('refresh restores the submitted state from the server', (t) => { const { dom, document, playerId, send } = loadGame(); t.after(() => dom.window.close()); const state = makeState(playerId, { players: [ { player_id: playerId, name: 'Alice', score: 0, isBot: false, hasSubmitted: true, hasVoted: false }, { player_id: 'bob-2', name: 'Bob', score: 0, isBot: false, hasSubmitted: false, hasVoted: false } ] }); send(state); assert.equal(document.getElementById('entry-input'), null, 'no input after refresh'); assert.match(document.getElementById('game-bar').textContent, /Entry submitted/); }); test('the header shows round progress and the score target', (t) => { const { dom, document, playerId, send } = loadGame(); t.after(() => dom.window.close()); send(makeState(playerId, { currentRound: 3, roundLimit: 15, scoreLimit: 25 })); const meta = document.getElementById('room-meta').textContent; assert.match(meta, /Round 3\/15/); assert.match(meta, /Target: 25 pts/); }); // --------------------------------------------------------------------------- // Voting // --------------------------------------------------------------------------- test('your own entry cannot be voted for, and votes are confirmed by the server', (t) => { const { dom, document, playerId, send, socket } = loadGame(); t.after(() => dom.window.close()); send( makeState(playerId, { currentPhase: 'voting', round_data: [ { index: 0, entry_text: 'Mine.', voteCount: 0 }, { index: 1, entry_text: 'Theirs.', voteCount: 0 } ], timeRemaining: 20, ownEntryIndex: 0 }) ); const buttons = document.querySelectorAll('.vote-btn'); assert.equal(buttons.length, 2); assert.equal(buttons[0].disabled, true); assert.equal(buttons[0].textContent.trim(), 'Yours'); assert.equal(buttons[1].disabled, false); buttons[1].click(); assert.equal(socket.lastEmitted('submitVote').payload.entryIndex, 1); // The server confirms with playersUpdate; the bar switches to "Vote cast". socket.trigger('playersUpdate', [ { player_id: playerId, name: 'Alice', score: 0, isBot: false, hasSubmitted: true, hasVoted: true }, { player_id: 'bob-2', name: 'Bob', score: 0, isBot: false, hasSubmitted: true, hasVoted: false } ]); assert.match(document.getElementById('game-bar').textContent, /Vote cast/); }); test('refresh during voting restores the voted state', (t) => { const { dom, document, playerId, send } = loadGame(); t.after(() => dom.window.close()); send( makeState(playerId, { currentPhase: 'voting', round_data: [{ index: 0, entry_text: 'Mine.', voteCount: 0 }], timeRemaining: 20, ownEntryIndex: 0, players: [ { player_id: playerId, name: 'Alice', score: 0, isBot: false, hasSubmitted: true, hasVoted: true }, { player_id: 'bob-2', name: 'Bob', score: 0, isBot: false, hasSubmitted: true, hasVoted: true } ] }) ); assert.equal(document.querySelectorAll('.vote-btn').length, 0); assert.match(document.getElementById('game-bar').textContent, /Vote cast/); }); // --------------------------------------------------------------------------- // Results & game over // --------------------------------------------------------------------------- test('round results list every entry with author, votes and points', (t) => { const { dom, document, playerId, send, socket } = loadGame(); t.after(() => dom.window.close()); send(makeState(playerId, { currentPhase: 'voting', round_data: [] })); const winner = { index: 1, entry_text: 'The winning line.', player_id: 'bob-2', player_name: 'Bob', votes: 2, points: 5, bonus: 3, wasTie: false }; socket.trigger('roundResults', { winner, reason: 'winner', round: 1, players: makeState(playerId).players, story: [ { player: 'Narrator', str: 'It was a dark and stormy night...' }, { player: 'Bob', str: 'The winning line.' } ], results: [ { index: 0, entry_text: 'A losing line.', player_id: playerId, player_name: 'Alice', votes: 0, points: 0, bonus: 0 }, winner ] }); const rows = document.querySelectorAll('#results-table tbody tr'); assert.equal(rows.length, 2); assert.match(rows[0].textContent, /A losing line\./); assert.match(rows[0].textContent, /Alice/); assert.match(rows[1].textContent, /The winning line\./); assert.match(rows[1].textContent, /Bob/); assert.match(rows[1].textContent, /\+5/); assert.ok(rows[1].classList.contains('winner')); }); test('game over shows the final story with attribution and export controls', (t) => { const { dom, document, playerId, send, socket } = loadGame(); t.after(() => dom.window.close()); send(makeState(playerId, { currentPhase: 'voting', round_data: [] })); socket.trigger('gameOver', { winner: { player_id: playerId, name: 'Alice', score: 12 }, players: makeState(playerId).players, story: [ { player: 'Narrator', str: 'It was a dark and stormy night...' }, { player: 'Alice', str: 'A heroic ending.' } ] }); assert.match(document.getElementById('main-text').textContent, /Alice is victorious with 12 points/); const segment = [...document.querySelectorAll('.story-segment')].at(-1); assert.equal(segment.dataset.player, 'Alice'); assert.match(segment.textContent, /A heroic ending\./); assert.ok(document.getElementById('download-story-btn')); assert.ok(document.getElementById('copy-story-btn')); assert.ok(document.getElementById('play-again-btn')); document.getElementById('play-again-btn').click(); assert.ok(socket.lastEmitted('playAgain')); }); test('host controls: skip and kick are wired to the server', (t) => { const { dom, document, playerId, send, socket } = loadGame(); t.after(() => dom.window.close()); send(makeState(playerId)); document.getElementById('skip-btn').click(); assert.ok(socket.lastEmitted('skipPhase')); document.querySelector('[data-kick="bob-2"]').click(); assert.equal(socket.lastEmitted('kickPlayer').payload.playerId, 'bob-2'); }); test('being kicked shows a notice to the player', (t) => { const { dom, window, socket } = loadGame(); t.after(() => dom.window.close()); socket.trigger('kicked', { message: 'Removed by the host.' }); assert.match(window.document.getElementById('toast').textContent, /Removed by the host/); }); // --------------------------------------------------------------------------- // Menu // --------------------------------------------------------------------------- function loadMenu(fetchImpl) { const dom = new JSDOM(MENU_HTML, { url: 'http://localhost/', runScripts: 'dangerously', beforeParse(window) { window.fetch = fetchImpl(window); window.alert = () => {}; } }); return dom; } test('menu renders rooms and the past-stories archive', async (t) => { const requests = []; const dom = loadMenu((window) => async (url) => { requests.push(url); if (url === '/api/rooms') { return { ok: true, json: async () => ({ rooms: [ { name: 'lobby', playerCount: 2, inProgress: false, phase: 'lobby' }, { name: 'storytime', playerCount: 3, inProgress: true, phase: 'voting' } ] }) }; } if (url === '/api/stories') { return { ok: true, json: async () => ({ stories: ['storytime - 2026-10-30 12-00-00.txt'] }) }; } if (url.startsWith('/api/stories/')) { return { ok: true, text: async () => 'Once upon a time.' }; } void window; return { ok: false }; }); t.after(() => dom.window.close()); const { document } = dom.window; document.getElementById('find-btn').click(); await flush(); const rooms = document.querySelectorAll('.room-row'); assert.equal(rooms.length, 2); assert.match(rooms[1].textContent, /storytime/); assert.match(rooms[1].textContent, /voting/); assert.match(rooms[1].textContent, /3 players/); document.getElementById('stories-btn').click(); await flush(); const stories = document.querySelectorAll('.story-row'); assert.equal(stories.length, 1); assert.match(stories[0].textContent, /storytime - 2026-10-30/); stories[0].click(); await flush(); assert.equal(document.getElementById('story-viewer').textContent, 'Once upon a time.'); assert.ok(requests.includes('/api/rooms')); assert.ok(requests.includes('/api/stories')); // Stop the room-polling interval so the test process can exit. document.getElementById('find-btn').click(); });