Files
yarn-ng/tests/yarn-game.test.js
T

480 lines
17 KiB
JavaScript

'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 - /);
});