'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { YarnGame } = require('../server'); const { makeGame, addPlayers, startGame, voteAll, submitAll } = require('./helpers'); // --------------------------------------------------------------------------- // Player lifecycle // --------------------------------------------------------------------------- test('first player becomes host and reconnect does not duplicate players', () => { const { game } = makeGame(); game.addPlayer('p1', 'Alice', 'socket-1'); game.addPlayer('p2', 'Bob', 'socket-2'); assert.equal(game.gameHost, 'p1'); assert.equal(game.players.length, 2); game.removePlayer('p2'); assert.equal(game.players.length, 1); assert.equal(game.disconnectedPlayers.length, 1); const rejoined = game.addPlayer('p2', 'Bobby', 'socket-3'); assert.equal(rejoined.name, 'Bobby'); assert.equal(game.players.length, 2); assert.equal(game.disconnectedPlayers.length, 0, 'reconnect must splice the stale entry'); assert.equal(game.getPlayer('p2').socketId, 'socket-3'); }); test('host is reassigned when the host leaves', () => { const { game } = makeGame(); addPlayers(game, 3); assert.equal(game.gameHost, 'p1'); game.removePlayer('p1'); assert.equal(game.gameHost, 'p2'); game.removePlayer('p2'); game.removePlayer('p3'); assert.equal(game.gameHost, null); }); test('names are trimmed, collapsed and length-capped', () => { const { game } = makeGame(); game.addPlayer('p1', ' Ada Lovelace ', 's1'); assert.equal(game.getPlayer('p1').name, 'Ada Lovelace'); game.addPlayer('p2', 'x'.repeat(50), 's2'); assert.equal(game.getPlayer('p2').name.length, 20); game.addPlayer('p3', ' ', 's3'); assert.equal(game.getPlayer('p3'), undefined, 'blank names are rejected'); }); test('bots are never stored as disconnected players when purged', () => { const { game } = makeGame(); addPlayers(game, 2); game.startBots(2); assert.equal(game.bots.length, 2); game.purgeBots(); assert.equal(game.players.length, 2); assert.equal(game.disconnectedPlayers.length, 0); assert.equal(game.bots.length, 0); }); test('resetGame returns to an empty lobby and clears results', async () => { const { game } = makeGame(); await startGame(game, 3, { botCount: 0 }); submitAll(game, [ ['p1', 'One.'], ['p2', 'Two.'], ['p3', 'Three.'] ]); game.beginVotingPhase({ force: true }); voteAll(game, { p1: 1, p2: 2, p3: 0 }); assert.equal(game.currentPhase, 'results'); game.resetGame(); const state = game.getState(); assert.equal(state.currentPhase, 'lobby'); assert.equal(state.inProgress, false); assert.deepEqual(state.yarnStory, []); assert.equal(state.lastResults, null); assert.equal(state.gameWinner, null); }); // --------------------------------------------------------------------------- // Story assembly // --------------------------------------------------------------------------- test('story segments are joined with spaces', () => { const { game } = makeGame(); game.yarnStory = [ { player: 'Narrator', str: 'It was a dark and stormy night...' }, { player: 'Alice', str: 'A frog appeared.' } ]; assert.equal(game.getStoryText(), 'It was a dark and stormy night... A frog appeared.'); }); // --------------------------------------------------------------------------- // Scoring // --------------------------------------------------------------------------- test('final round awards double points', () => { const { game } = makeGame(); addPlayers(game, 3); game.gameSettings.roundLimit = 5; game.roundCount = 4; // playing the 5th (final) round game.round_data = [ { entry_text: 'Short entry.', player_id: 'p1', votes: ['p2', 'p3'] }, { entry_text: 'Another entry.', player_id: 'p2', votes: ['p1'] } ]; const result = game.tallyVotes(); // p1: (2 votes + 3 winner bonus) * 2 assert.equal(game.getPlayer('p1').score, 10); // p2: (1 vote + 0 bonus) * 2 assert.equal(game.getPlayer('p2').score, 2); assert.equal(result.winner.player_id, 'p1'); assert.equal(result.winner.points, 10); assert.equal(result.winner.wasTie, false); }); test('single winner gets three bonus points and is appended to the story', () => { const { game } = makeGame(); addPlayers(game, 3); game.round_data = [ { entry_text: 'The winner.', player_id: 'p1', votes: ['p2', 'p3'] }, { entry_text: 'A loser.', player_id: 'p2', votes: [] } ]; const result = game.tallyVotes(); assert.equal(game.getPlayer('p1').score, 5); // 2 votes + 3 bonus assert.equal(game.getPlayer('p2').score, 0); assert.equal(result.reason, 'winner'); assert.equal(result.winner.player_name, 'Player 1'); assert.equal(game.yarnStory.at(-1).str, 'The winner.'); }); test('ties award the longest entry +2 and the others +1 and report the tie-break winner', () => { const { game } = makeGame(); addPlayers(game, 4); game.round_data = [ { entry_text: 'Short.', player_id: 'p1', votes: ['p3', 'p4'] }, { entry_text: 'A much longer entry.', player_id: 'p2', votes: ['p1', 'p4'] } ]; // p4 cannot vote twice; rebuild votes explicitly for clarity. game.round_data[0].votes = ['p3', 'p1']; game.round_data[1].votes = ['p4', 'p1']; const result = game.tallyVotes(); assert.equal(result.reason, 'tie'); assert.equal(result.winner.player_id, 'p2', 'longest tied entry wins the tie-break'); assert.equal(result.winner.wasTie, true); assert.equal(game.getPlayer('p1').score, 3); // 2 votes + 1 tie bonus assert.equal(game.getPlayer('p2').score, 4); // 2 votes + 2 tie bonus assert.equal(game.yarnStory.at(-1).str, 'A much longer entry.'); }); test('zero-vote rounds award nothing and do not extend the story', () => { const { game } = makeGame(); addPlayers(game, 2); game.yarnStory = [{ player: 'Narrator', str: 'Start.' }]; game.round_data = [ { entry_text: 'Nobody liked this.', player_id: 'p1', votes: [] }, { entry_text: 'Or this.', player_id: 'p2', votes: [] } ]; const result = game.tallyVotes(); assert.equal(result.reason, 'no-votes'); assert.equal(result.winner, null); assert.equal(game.getPlayer('p1').score, 0); assert.equal(game.yarnStory.length, 1); assert.equal(game.roundCount, 1); }); test('empty entries never earn points or extend the story', () => { const { game } = makeGame(); addPlayers(game, 2); game.yarnStory = [{ player: 'Narrator', str: 'Start.' }]; game.round_data = [ { entry_text: '', player_id: 'p1', votes: ['p2'] }, { entry_text: ' ', player_id: 'p2', votes: ['p1'] } ]; const result = game.tallyVotes(); assert.equal(result.reason, 'no-entries'); assert.deepEqual(result.results, []); assert.equal(game.getPlayer('p1').score, 0); assert.equal(game.getPlayer('p2').score, 0); assert.equal(game.yarnStory.length, 1); }); // --------------------------------------------------------------------------- // Vote integrity // --------------------------------------------------------------------------- test('invalid votes are rejected without throwing', () => { const { game } = makeGame(); addPlayers(game, 3); game.currentPhase = 'voting'; game.round_data = [ { entry_text: 'Entry one.', player_id: 'p1', votes: [] }, { entry_text: 'Entry two.', player_id: 'p2', votes: [] }, { entry_text: '', player_id: 'p3', votes: [] } ]; assert.equal(game.submitVote('p3', 99).ok, false, 'out of range'); assert.equal(game.submitVote('p3', -1).ok, false, 'negative'); assert.equal(game.submitVote('p3', 'abc').ok, false, 'not a number'); assert.equal(game.submitVote('p3', 2).ok, false, 'empty entry'); assert.equal(game.submitVote('p1', 0).ok, false, 'own entry'); assert.equal(game.submitVote('ghost', 0).ok, false, 'unknown player'); const accepted = game.submitVote('p3', 0); assert.equal(accepted.ok, true); assert.equal(game.submitVote('p3', 1).ok, false, 'duplicate vote'); assert.equal(game.round_data[0].votes.length, 1); }); test('voting is rejected outside the voting phase', () => { const { game } = makeGame(); addPlayers(game, 2); game.round_data = [{ entry_text: 'Entry.', player_id: 'p1', votes: [] }]; game.currentPhase = 'writing'; assert.equal(game.submitVote('p2', 0).ok, false); }); // --------------------------------------------------------------------------- // Submissions // --------------------------------------------------------------------------- test('manual empty entries are rejected while forced ones are accepted', () => { const { game } = makeGame(); addPlayers(game, 2); game.currentPhase = 'writing'; assert.equal(game.submitEntry('p1', ' ').ok, false); assert.equal(game.submitEntry('p1', 'x'.repeat(400)).ok, true); assert.equal(game.round_data[0].entry_text.length, 280, 'entry is capped'); assert.equal(game.submitEntry('p2', 'hello').ok, true); assert.equal(game.submitEntry('p2', 'again').ok, false, 'duplicate submission'); game.submittedPlayers.delete('p2'); assert.equal(game.submitEntry('p2', '', { forced: true }).ok, true); assert.equal(game.round_data.at(-1).player_id, 'p2'); assert.equal(game.round_data.at(-1).entry_text, ''); }); test('allPlayersSubmitted uses every() so a departed player cannot complete the round', () => { const { game } = makeGame(); addPlayers(game, 3); game.currentPhase = 'writing'; game.submitEntry('p1', 'One.'); game.submitEntry('p2', 'Two.'); game.removePlayer('p2'); // submittedPlayers has p1+p2 (size 2) and players now has p1+p3 (length 2). assert.equal(game.allPlayersSubmitted(), false); game.submitEntry('p3', 'Three.'); assert.equal(game.allPlayersSubmitted(), true); }); // --------------------------------------------------------------------------- // Phase orchestration // --------------------------------------------------------------------------- test('finishing a writing round moves through voting to results exactly once', async () => { const { game, emitted } = makeGame(); await startGame(game, 2); game.submitEntry('p1', 'Once upon a time.'); game.submitEntry('p2', 'There was a duck.'); game.beginVotingPhase(); assert.equal(game.currentPhase, 'voting'); assert.equal(game.timeRemaining, 20, '10 seconds per entry'); game.endVotingPhase(); game.endVotingPhase(); // second call must be a no-op assert.equal(emitted('roundResults').length, 1); assert.equal(game.currentPhase, 'results'); assert.equal(game.roundCount, 1); assert.ok(game.getState().lastResults); }); test('a round with no usable entries skips straight to results', async () => { const { game, emitted } = makeGame(); await startGame(game, 2); game.beginVotingPhase({ force: true }); assert.equal(game.currentPhase, 'results'); assert.equal(emitted('roundResults').length, 1); assert.equal(emitted('roundResults')[0].payload.reason, 'no-entries'); assert.equal(game.roundCount, 1); }); test('writing timer expiry forces empty submissions and opens voting', async () => { const { game, timers } = makeGame(); await startGame(game, 2, { timeLimit: 15 }); game.submitEntry('p1', 'Only one entry.'); assert.equal(game.currentPhase, 'writing'); timers.runIntervals(15); assert.equal(game.currentPhase, 'voting'); assert.equal(game.submittedPlayers.has('p2'), true, 'timeout forces a submission'); assert.equal(game.publicEntries().length, 1); }); test('the narrator moral is appended exactly once before the final round', async () => { const { game, timers } = makeGame(); await startGame(game, 3, { roundLimit: 2 }); // Round 1 game.submitEntry('p1', 'First entry.'); game.submitEntry('p2', 'Second entry.'); game.submitEntry('p3', 'Third entry.'); game.beginVotingPhase(); voteAll(game, { p1: 1, p2: 2, p3: 0 }); assert.equal(game.currentPhase, 'results'); assert.equal(game.roundCount, 1); timers.runTimeouts(); // next round begins assert.equal(game.currentPhase, 'writing'); assert.ok( game.yarnStory.some((entry) => entry.str.startsWith('The moral of this story:')), 'moral prompt should be part of the story' ); // Round 2 (final) completes the game. game.submitEntry('p1', 'Final one.'); game.submitEntry('p2', 'Final two.'); game.submitEntry('p3', 'Final three.'); game.beginVotingPhase(); voteAll(game, { p1: 1, p2: 2, p3: 0 }); assert.equal(game.currentPhase, 'gameOver'); const morals = game.yarnStory.filter((entry) => entry.str.startsWith('The moral of this story:') ); assert.equal(morals.length, 1, 'moral must not be duplicated'); }); test('reaching the score limit ends the game and saves the story', async () => { const { game, emitted } = makeGame(); await startGame(game, 3, { scoreLimit: 3 }); game.submitEntry('p1', 'A winning line.'); game.submitEntry('p2', 'Another line.'); game.submitEntry('p3', 'Third line.'); game.beginVotingPhase(); voteAll(game, { p1: 1, p2: 2, p3: 0 }); assert.equal(emitted('gameOver').length, 1); assert.equal(game.currentPhase, 'gameOver'); assert.equal(game.inProgress, false); const winner = emitted('gameOver')[0].payload.winner; assert.equal(winner.player_id, 'p1'); assert.ok(winner.score >= 3); const stories = fs.readdirSync(game.storiesDir).filter((name) => name.endsWith('.txt')); assert.equal(stories.length, 1, 'story is saved when the game ends'); assert.match(fs.readFileSync(path.join(game.storiesDir, stories[0]), 'utf8'), /A winning line\./); }); test('getState hides voting entries until the voting phase', async () => { const { game } = makeGame(); await startGame(game, 2); game.submitEntry('p1', 'Hidden until voting.'); game.submitEntry('p2', 'Also hidden.'); assert.deepEqual(game.getState().round_data, []); game.beginVotingPhase(); const state = game.getState(); assert.equal(state.round_data.length, 2); assert.equal(state.round_data[0].voteCount, 0); assert.equal(state.round_data[0].player_id, undefined, 'authorship is not leaked'); assert.equal(state.currentRound, 1); assert.equal(state.roundLimit, 15); }); test('voting order is shuffled while entry indexes stay stable', async () => { const { game } = makeGame(); await startGame(game, 3); game.submitEntry('p1', 'First.'); game.submitEntry('p2', 'Second.'); game.submitEntry('p3', 'Third.'); game.beginVotingPhase(); assert.deepEqual([...game.votingOrder].sort(), [0, 1, 2]); const entries = game.getState().round_data; assert.equal(entries.length, 3); for (const entry of entries) { assert.equal(entry.entry_text, game.round_data[entry.index].entry_text); } // Voting still accepts the real index even though display order is shuffled. const own = game.getOwnEntryIndex('p3'); const target = entries.find((entry) => entry.index !== own); assert.equal(game.submitVote('p3', target.index).ok, true); }); test('getStateFor reports the caller own entry index', async () => { const { game } = makeGame(); await startGame(game, 2); game.submitEntry('p1', 'Mine.'); game.submitEntry('p2', 'Theirs.'); assert.equal(game.getOwnEntryIndex('p2'), 1); assert.equal(game.getStateFor('p2').ownEntryIndex, 1); assert.equal(game.getStateFor('nobody').ownEntryIndex, -1); }); test('disconnecting a player emits state and never double-advances the phase', async () => { const { game, emitted } = makeGame(); await startGame(game, 3); game.submitEntry('p1', 'One.'); game.submitEntry('p2', 'Two.'); game.removePlayer('p3'); assert.equal(game.currentPhase, 'writing'); assert.equal(game.allPlayersSubmitted(), true); game.checkSubmissionsComplete(); assert.equal(game.currentPhase, 'voting'); assert.equal(emitted('votingPhase').length, 1); }); test('startGame refuses to run twice and resets round state', async () => { const { game } = makeGame(); await startGame(game, 3); assert.equal(await game.startGame({ botCount: 0 }), false); }); test('startGame clamps settings and keeps scoreLimit 0 (unlimited)', async () => { const { game } = makeGame(); await startGame(game, 3, { scoreLimit: 0, roundLimit: 500, timeLimit: 5, botCount: 99, startText: '' }); assert.equal(game.gameSettings.scoreLimit, 0); assert.equal(game.gameSettings.roundLimit, 100); assert.equal(game.gameSettings.timeLimit, 15); assert.equal(game.gameSettings.botCount, 8, 'bot count is clamped'); assert.equal(game.bots.length, 8); assert.equal(game.gameSettings.startText, 'It was a dark and stormy night...'); }); test('saveStory cannot escape the stories directory', () => { const { game } = makeGame({ roomName: '../../etc/passwd' }); const fileName = game.saveStory(); assert.equal(path.basename(fileName), fileName); const filePath = path.join(game.storiesDir, fileName); assert.ok(fs.existsSync(filePath)); assert.equal(path.dirname(fs.realpathSync(filePath)), fs.realpathSync(game.storiesDir)); assert.match(fileName, /^room - /); });