Batch of LLM-assisted fixes/feature updates
This commit is contained in:
1 parent
7c6453160a
commit
05aaf394df
16 files changed
+5597
-912
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 - /);
|
||||
});
|
||||
Reference in new issue
Block a user