Batch of LLM-assisted fixes/feature updates

This commit is contained in:
Cam Spry committed 2026-10-30 23:02:45 -04:00
1 parent 7c6453160a
commit 05aaf394df
16 files changed
+5597 -912

No files matched your search

+252
View File
@@ -0,0 +1,252 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const BotFactory = require('../bots/BotFactory');
const RandomBot = require('../bots/RandomBot');
const LLMBot = require('../bots/LLMBot');
const { makeGame, addPlayers } = require('./helpers');
const LLM_CONFIG_PATH = path.join(__dirname, '..', 'bots', 'llm_config.json');
const HAS_LLM_CONFIG = fs.existsSync(LLM_CONFIG_PATH) || Boolean(process.env.OPENAI_API_KEY);
const settle = async (predicate, { attempts = 50 } = {}) => {
for (let i = 0; i < attempts; i += 1) {
if (predicate()) return true;
await new Promise((resolve) => setImmediate(resolve));
}
return predicate();
};
function stubFactory(bots, { onRandom = null } = {}) {
let created = 0;
return {
createBot(type) {
if (String(type).toLowerCase() === 'llm') {
const bot = bots[Math.min(created, bots.length - 1)];
created += 1;
return bot;
}
if (onRandom) return onRandom();
return {
Write: () => 'A fallback sentence.',
Vote: () => 0,
Banter: () => 'fallback banter'
};
},
generateBotName(exclude = []) {
return `Bot${exclude.length + 1}`;
}
};
}
// ---------------------------------------------------------------------------
// BotFactory
// ---------------------------------------------------------------------------
test('BotFactory creates random bots and rejects unknown types', () => {
assert.ok(BotFactory.createBot('random') instanceof RandomBot);
assert.throws(() => BotFactory.createBot('definitely-not-a-bot'));
assert.deepEqual(BotFactory.getAvailableBotTypes(), ['llm', 'random']);
});
test('BotFactory falls back to RandomBot when the LLM client cannot be built', {
skip: HAS_LLM_CONFIG ? 'an LLM config/API key is present in this environment' : false
}, () => {
const bot = BotFactory.createBot('llm');
assert.ok(bot instanceof RandomBot, 'missing API key must not crash bot creation');
});
test('bot names are unique within a room', () => {
const { game } = makeGame({ botFactory: BotFactory, botType: 'random' });
addPlayers(game, 2);
game.startBots(8);
const names = game.players.filter((player) => player.isBot).map((player) => player.name);
assert.equal(names.length, 8);
assert.equal(new Set(names).size, names.length, 'no duplicate bot names');
});
test('bot creation failures fall back to a working bot', () => {
const fallback = {
Write: () => 'fallback',
Vote: () => null,
Banter: () => 'fallback'
};
const factory = {
createBot(type) {
if (type === 'llm') throw new Error('no api key');
return fallback;
},
generateBotName: () => 'FallbackBot'
};
const { game } = makeGame({ botFactory: factory });
const bot = game.createBotInstance();
assert.equal(bot, fallback);
});
test('RandomBot vote maps to the real entry index, not the filtered position', () => {
const bot = new RandomBot();
const choices = [
{ index: 3, entry_text: 'Third.' },
{ index: 7, entry_text: 'Seventh.' }
];
for (let i = 0; i < 20; i += 1) {
assert.ok([3, 7].includes(bot.Vote('story', choices)));
}
assert.equal(bot.Vote('story', []), null);
});
// ---------------------------------------------------------------------------
// Gameplay integration
// ---------------------------------------------------------------------------
test('a bot that fails to write still submits (empty entry) and the round moves on', async () => {
const failingBot = {
Write: () => {
throw new Error('LLM exploded');
},
Vote: () => null,
Banter: () => ''
};
const factory = stubFactory([failingBot], {
onRandom: () => ({
Write: () => {
throw new Error('fallback exploded too');
},
Vote: () => null,
Banter: () => ''
})
});
const { game } = makeGame({ botFactory: factory, botType: 'llm' });
addPlayers(game, 2);
await game.startGame({ botCount: 1, timeLimit: 60 });
await settle(() => game.submittedPlayers.has(game.bots[0].bot_id));
assert.equal(game.submittedPlayers.has(game.bots[0].bot_id), true);
assert.equal(game.eligibleEntries().length, 0);
assert.equal(game.currentPhase, 'writing', 'humans still owe an entry');
});
test('bots write concurrently and reveal themselves as submitted', async () => {
const writes = [];
const slowBot = {
Write: async () => {
writes.push('start');
await new Promise((resolve) => setTimeout(resolve, 5));
writes.push('end');
return 'A slow but steady sentence.';
},
Vote: () => null,
Banter: () => ''
};
const factory = stubFactory([slowBot, slowBot]);
const { game } = makeGame({ botFactory: factory, botType: 'llm' });
addPlayers(game, 2);
await game.startGame({ botCount: 2, timeLimit: 60 });
await settle(() => game.submittedPlayers.size >= 2);
assert.equal(game.eligibleEntries().length, 2);
assert.deepEqual(writes.slice(0, 2), ['start', 'start'], 'writes overlap (concurrent)');
});
test('a bot vote for an invalid index falls back to a valid entry and never self-votes', async () => {
const capturedChoices = [];
const cheatingBot = {
Write: () => 'Bot sentence.',
Vote: (story, choices) => {
capturedChoices.push(choices);
return 999; // invalid
},
Banter: () => ''
};
const factory = stubFactory([cheatingBot]);
const { game } = makeGame({ botFactory: factory, botType: 'llm' });
const [p1, p2] = addPlayers(game, 2);
await game.startGame({ botCount: 1, timeLimit: 60 });
await settle(() => game.submittedPlayers.has(game.bots[0].bot_id));
game.submitEntry(p1, 'Human one.');
game.submitEntry(p2, 'Human two.');
game.beginVotingPhase();
await settle(() => game.votedPlayers.has(game.bots[0].bot_id));
const botId = game.bots[0].bot_id;
assert.equal(game.votedPlayers.has(botId), true, 'bot still casts a valid vote');
const captured = capturedChoices[0];
assert.ok(captured.length > 0);
assert.ok(
captured.every((choice) => choice.player_id !== botId),
'bots are never offered their own entry'
);
const votedFor = game.round_data.findIndex((entry) => entry.votes.includes(botId));
assert.ok(votedFor >= 0);
assert.notEqual(game.round_data[votedFor].player_id, botId);
});
test('bot banter is posted to chat with recent chat context', async () => {
const seen = [];
const chatter = {
Write: () => 'Bot sentence.',
Vote: () => null,
Banter: (chatSoFar) => {
seen.push(chatSoFar);
return ' I think the duck did it. ';
}
};
const factory = stubFactory([chatter]);
const { game, emitted } = makeGame({ botFactory: factory, botType: 'llm' });
addPlayers(game, 2);
await game.startGame({ botCount: 1, timeLimit: 60 });
game.addChatMessage('p1', 'Hello there.');
// Make the banter roll deterministic.
const originalRandom = Math.random;
Math.random = () => 0;
try {
await game.botBanter();
} finally {
Math.random = originalRandom;
}
const messages = emitted('newChatMessage');
assert.equal(messages.length, 1);
assert.equal(messages[0].payload.msg, 'I think the duck did it.');
assert.equal(messages[0].payload.name, game.players.find((p) => p.isBot).name);
assert.match(seen[0], /Hello there\./);
});
test('chat messages are length-capped and empty ones ignored', () => {
const { game } = makeGame();
addPlayers(game, 1);
assert.equal(game.addChatMessage('p1', ' '), null);
const message = game.addChatMessage('p1', 'x'.repeat(1000));
assert.equal(message.msg.length, 300);
});
test('personality files are selectable and default to the bundled list', () => {
const defaultPersonalities = LLMBot.readPersonalities();
assert.ok(defaultPersonalities.length > 0);
const customPath = path.join(
fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'yarn-personalities-')),
'custom.txt'
);
fs.writeFileSync(customPath, 'Pirate bot\n\n Noir bot \n', 'utf8');
assert.deepEqual(LLMBot.readPersonalities(customPath), ['Pirate bot', 'Noir bot']);
assert.equal(LLMBot.generateBotPersonality(customPath).includes('bot'), true);
assert.equal(LLMBot.readPersonalities(path.join(customPath, 'missing.txt')).length, 0);
});
+466
View File
@@ -0,0 +1,466 @@
'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();
});
+116
View File
@@ -0,0 +1,116 @@
'use strict';
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { YarnGame } = require('../server');
/**
* Deterministic timer double so gameplay phases can be advanced in tests
* without real waiting.
*/
function createFakeTimers() {
let nextId = 1;
const intervals = new Map();
const timeouts = new Map();
return {
intervals,
timeouts,
setInterval(fn, ms) {
const id = nextId += 1;
intervals.set(id, { fn, ms });
return id;
},
clearInterval(id) {
intervals.delete(id);
},
setTimeout(fn, ms) {
const id = nextId += 1;
timeouts.set(id, { fn, ms });
return id;
},
clearTimeout(id) {
timeouts.delete(id);
},
/** Fire every live interval `times` times (snapshotting per pass). */
runIntervals(times = 1) {
for (let pass = 0; pass < times; pass += 1) {
for (const [id, { fn }] of [...intervals.entries()]) {
if (!intervals.has(id)) continue;
fn();
}
}
},
/** Fire every pending timeout once. */
runTimeouts() {
const pending = [...timeouts.values()];
timeouts.clear();
for (const { fn } of pending) fn();
}
};
}
function tempStoriesDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'yarn-test-'));
}
function makeGame(overrides = {}) {
const timers = overrides.timers || createFakeTimers();
const events = [];
const game = new YarnGame(overrides.roomName || 'test-room', {
timers,
broadcast: (event, payload) => events.push({ event, payload }),
botFactory: overrides.botFactory,
botType: overrides.botType || 'random',
storiesDir: overrides.storiesDir || tempStoriesDir()
});
const emitted = (name) => events.filter((entry) => entry.event === name);
return { game, timers, events, emitted };
}
function addPlayers(game, count) {
const ids = [];
for (let i = 0; i < count; i += 1) {
const id = `p${i + 1}`;
game.addPlayer(id, `Player ${i + 1}`, `socket-${i + 1}`);
ids.push(id);
}
return ids;
}
/** Start a game with `count` human players and no bots. */
async function startGame(game, count, settings = {}) {
const ids = addPlayers(game, count);
await game.startGame({ botCount: 0, timeLimit: 60, roundLimit: 15, ...settings });
return ids;
}
/** Submit one entry per player, in order. */
function submitAll(game, entries) {
entries.forEach(([playerId, text], index) => {
const result = game.submitEntry(playerId, text);
if (!result.ok) throw new Error(`submit failed for ${playerId}: ${result.error}`);
void index;
});
}
/** Cast the supplied votes and let the game close voting if complete. */
function voteAll(game, votes) {
for (const [playerId, entryIndex] of Object.entries(votes)) {
const result = game.submitVote(playerId, entryIndex);
if (!result.ok) throw new Error(`vote failed for ${playerId}: ${result.error}`);
}
game.checkVotesComplete();
}
module.exports = {
createFakeTimers,
tempStoriesDir,
makeGame,
addPlayers,
startGame,
submitAll,
voteAll
};
+186
View File
@@ -0,0 +1,186 @@
'use strict';
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
// Must be set before ../server is required.
const STORIES_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'yarn-socket-stories-'));
process.env.YARN_STORIES_DIR = STORIES_DIR;
const test = require('node:test');
const assert = require('node:assert/strict');
const { io: createClient } = require('socket.io-client');
const { server, games } = require('../server');
function waitForEvent(socket, event, { timeout = 8000 } = {}) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
socket.off(event, onEvent);
reject(new Error(`timed out waiting for "${event}"`));
}, timeout);
function onEvent(payload) {
clearTimeout(timer);
socket.off(event, onEvent);
resolve(payload);
}
socket.on(event, onEvent);
});
}
test('two humans and a bot can play a complete game end to end', async (t) => {
await new Promise((resolve) => server.listen(0, resolve));
const { port } = server.address();
const url = `http://127.0.0.1:${port}`;
const roomName = 'smoke-room';
const sockets = [];
t.after(async () => {
for (const socket of sockets) socket.disconnect();
games[roomName]?.destroy();
delete games[roomName];
await new Promise((resolve) => server.close(resolve));
});
const connect = async (uuid, name) => {
const socket = createClient(url, {
transports: ['websocket'],
forceNew: true,
reconnection: false
});
sockets.push(socket);
const joined = waitForEvent(socket, 'gameState');
socket.on('connect', () => {
socket.emit('joinRoom', { roomName, playerUUID: uuid, playerName: name });
});
await joined;
return socket;
};
const alice = await connect('alice-0001', 'Alice');
const bob = await connect('bob-0002', 'Bob');
// The first player is the host.
const lobbyState = await new Promise((resolve) => {
alice.emit('setName', { name: 'Alice' });
alice.once('gameState', resolve);
});
assert.equal(lobbyState.gameHost, 'alice-0001');
const roundStarted = waitForEvent(alice, 'newRound');
alice.emit('startGame', {
botCount: 1,
timeLimit: 30,
roundLimit: 1,
scoreLimit: 0,
startText: 'It was a dark and stormy night...'
});
const roundState = await roundStarted;
assert.equal(roundState.currentPhase, 'writing');
assert.equal(roundState.players.length, 3, 'two humans plus one bot');
const votingForAlice = waitForEvent(alice, 'votingPhase');
const votingForBob = waitForEvent(bob, 'votingPhase');
alice.emit('submitEntry', { entry: 'Alice bravely opened the door.' });
bob.emit('submitEntry', { entry: 'Bob hid behind the sofa.' });
const aliceVote = await votingForAlice;
const bobVote = await votingForBob;
assert.equal(aliceVote.entries.length, 3, 'bots submit entries too');
const aliceOwn = aliceVote.entries.find(
(entry) => entry.index === aliceVote.ownEntryIndex
);
const bobOwn = bobVote.entries.find(
(entry) => entry.index === bobVote.ownEntryIndex
);
assert.equal(aliceOwn.entry_text, 'Alice bravely opened the door.');
assert.equal(bobOwn.entry_text, 'Bob hid behind the sofa.');
const roundResults = waitForEvent(alice, 'roundResults');
const gameOver = waitForEvent(alice, 'gameOver');
// Cross-vote so no one votes for themselves and the bot cannot win alone.
alice.emit('submitVote', { entryIndex: bobVote.ownEntryIndex });
bob.emit('submitVote', { entryIndex: aliceVote.ownEntryIndex });
const round = await roundResults;
assert.equal(round.results.length, 3, 'every entry is scored');
assert.ok(round.winner);
assert.ok(round.winner.points > 0);
assert.equal(round.results.reduce((sum, entry) => sum + entry.votes, 0), 3);
const result = await gameOver;
assert.ok(result.winner);
assert.equal(typeof result.winner.score, 'number');
assert.ok(
['alice-0001', 'bob-0002'].includes(result.winner.player_id),
'a human wins when the humans vote for each other'
);
assert.ok(result.winner.score > 0);
assert.equal(result.story.length, 2, 'start text plus the winning entry');
assert.equal(result.story[0].player, 'Narrator');
// The story is persisted when the game finishes.
const saved = fs.readdirSync(STORIES_DIR).filter((name) => name.endsWith('.txt'));
assert.equal(saved.length, 1);
const savedText = fs.readFileSync(path.join(STORIES_DIR, saved[0]), 'utf8');
assert.ok(savedText.includes(round.winner.entry_text), 'saved story contains the winning line');
assert.ok(savedText.startsWith('It was a dark and stormy night...'));
// And the room is listed for the lobby browser.
const state = games[roomName].getState();
assert.equal(state.currentPhase, 'gameOver');
assert.equal(state.inProgress, false);
});
test('when the last unsubmitted player leaves, voting starts immediately', async (t) => {
await new Promise((resolve) => server.listen(0, resolve));
const { port } = server.address();
const url = `http://127.0.0.1:${port}`;
const roomName = 'disconnect-room';
const sockets = [];
t.after(async () => {
for (const socket of sockets) socket.disconnect();
games[roomName]?.destroy();
delete games[roomName];
await new Promise((resolve) => server.close(resolve));
});
const connect = async (uuid, name) => {
const socket = createClient(url, {
transports: ['websocket'],
forceNew: true,
reconnection: false
});
sockets.push(socket);
const joined = waitForEvent(socket, 'gameState');
socket.on('connect', () => {
socket.emit('joinRoom', { roomName, playerUUID: uuid, playerName: name });
});
await joined;
return socket;
};
const alice = await connect('alice-1001', 'Alice');
const bob = await connect('bob-1002', 'Bob');
const carla = await connect('carla-1003', 'Carla');
alice.emit('startGame', { botCount: 0, timeLimit: 60, roundLimit: 5, scoreLimit: 0 });
await waitForEvent(alice, 'newRound');
alice.emit('submitEntry', { entry: 'Alice wrote this.' });
bob.emit('submitEntry', { entry: 'Bob wrote this.' });
const voting = waitForEvent(alice, 'votingPhase');
carla.disconnect();
const payload = await voting;
assert.equal(payload.entries.length, 2, 'empty/absent entries are excluded');
assert.equal(games[roomName].currentPhase, 'voting');
});
+104
View File
@@ -0,0 +1,104 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
LIMITS,
sanitizeRoomName,
sanitizePlayerId,
normalizeSettings,
cleanText,
clampInt,
games,
getOrCreateGame,
removeRoomIfEmpty
} = require('../server');
test('room names are restricted to a safe charset and length', () => {
assert.equal(sanitizeRoomName('Lobby'), 'lobby');
assert.equal(sanitizeRoomName(' story-time_2 '), 'story-time_2');
assert.equal(sanitizeRoomName('../../etc'), null);
assert.equal(sanitizeRoomName('my room'), null);
assert.equal(sanitizeRoomName('a'.repeat(LIMITS.roomName + 1)), null);
assert.equal(sanitizeRoomName(''), null);
assert.equal(sanitizeRoomName(null), null);
assert.equal(sanitizeRoomName(42), null);
});
test('player ids are restricted to a safe charset and length', () => {
assert.equal(sanitizePlayerId('abc-123_XYZ'), 'abc-123_XYZ');
assert.equal(sanitizePlayerId('has space'), null);
assert.equal(sanitizePlayerId('x'.repeat(65)), null);
assert.equal(sanitizePlayerId(undefined), null);
});
test('settings are clamped and scoreLimit 0 is preserved', () => {
const defaults = {
scoreLimit: 0,
roundLimit: 15,
timeLimit: 60,
botCount: 0,
startText: 'Once upon a time...'
};
assert.deepEqual(normalizeSettings(defaults, {}), defaults);
const clamped = normalizeSettings(defaults, {
scoreLimit: '0',
roundLimit: 9999,
timeLimit: 1,
botCount: 99,
startText: ' '
});
assert.equal(clamped.scoreLimit, 0);
assert.equal(clamped.roundLimit, LIMITS.settings.roundLimit[1]);
assert.equal(clamped.timeLimit, LIMITS.settings.timeLimit[0]);
assert.equal(clamped.botCount, LIMITS.settings.botCount[1]);
assert.equal(clamped.startText, defaults.startText);
const negative = normalizeSettings(defaults, { scoreLimit: -50, timeLimit: -10 });
assert.equal(negative.scoreLimit, 0);
assert.equal(negative.timeLimit, LIMITS.settings.timeLimit[0]);
const junk = normalizeSettings(defaults, { roundLimit: 'abc', startText: 42 });
assert.equal(junk.roundLimit, 15);
assert.equal(junk.startText, defaults.startText);
});
test('text cleaning collapses whitespace, trims and caps', () => {
assert.equal(cleanText(' hello \n world ', 100), 'hello world');
assert.equal(cleanText('x'.repeat(500), 10), 'xxxxxxxxxx');
assert.equal(cleanText('', 10), null);
assert.equal(cleanText(' ', 10), null);
assert.equal(cleanText(null, 10), null);
assert.equal(cleanText({}, 10), null);
});
test('clampInt handles junk input', () => {
assert.equal(clampInt('5', 0, 10, 7), 5);
assert.equal(clampInt(50, 0, 10, 7), 10);
assert.equal(clampInt(-5, 0, 10, 7), 0);
assert.equal(clampInt('abc', 0, 10, 7), 7);
assert.equal(clampInt(undefined, 0, 10, 7), 7);
assert.equal(clampInt(1.9, 0, 10, 7), 1);
});
test('rooms are only removed once every human has left', () => {
const game = getOrCreateGame('lifecycle-room');
assert.ok(game);
game.addPlayer('p1', 'Alice', 's1');
removeRoomIfEmpty('lifecycle-room');
assert.ok(games['lifecycle-room'], 'a room with humans stays');
game.removePlayer('p1');
removeRoomIfEmpty('lifecycle-room');
assert.equal(games['lifecycle-room'], undefined, 'empty rooms are cleaned up');
});
test('getOrCreateGame rejects unsafe room names', () => {
assert.equal(getOrCreateGame('../evil'), null);
assert.equal(games['../evil'], undefined);
});
+479
View File
@@ -0,0 +1,479 @@
'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 - /);
});