480 lines
17 KiB
JavaScript
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 - /);
|
|
});
|