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

+101
View File
@@ -0,0 +1,101 @@
# Y.A.R.N. HTML5
A browser remake of **Y.A.R.N.** (Yet Another Random Narrative), the collaborative
story-writing parlor game from GameSpy Arcade, with optional LLM-driven bots.
Players take turns adding one sentence to a shared story, vote for their favourite
entry each round, and score points. The finished story is saved to disk.
## Quick start
```bash
npm install
npm start
# open http://localhost:3000
```
Create or pick a room on the menu, share the `/game/<room>` URL, and the host
can start once at least 3 players (humans + bots) are present.
## Configuration
| Variable | Default | Purpose |
| --- | --- | --- |
| `PORT` | `3000` | HTTP port |
| `YARN_STORIES_DIR` | `./stories` | Where finished stories are written |
| `OPENAI_API_KEY` | – | Enables LLM bots (any OpenAI-compatible endpoint) |
| `OPENAI_MODEL` | `meta-llama/Llama-3.2-3B-Instruct-Turbo` | Default model |
Bots can also be configured with `bots/llm_config.json` (same shape as the
OpenAI JS client's options, e.g. `{ "apiKey": "...", "baseURL": "...", "model": "..." }`).
Copy `bots/llm_config.example.json` to get started. This file is gitignored.
**If the LLM client cannot be created (no key/config), bots automatically fall
back to `RandomBot`**, so a game always starts. A bot request that fails or
times out degrades to a fallback sentence (or an empty entry) and the game
continues.
## Game rules
- The host picks the round limit, score limit, time per round, bot count and
starting text. Settings sync live to everyone in the lobby.
- Writing phase: each player submits one sentence (max 280 chars).
- Voting phase: 10 seconds per entry (minimum 15s). You cannot vote for your
own entry, and empty entries are not voteable.
- Scoring: 1 point per vote received. The single most-voted entry gets +3
bonus points. On a tie the longest tied entry gets +2 and the others +1.
**The final round is worth double points.**
- The game ends when a player reaches the score limit, or when the round limit
is reached (highest score wins). The story is saved automatically.
- The host can skip a stalled writing/voting phase and remove idle players.
If a player drops, the round advances immediately when everyone remaining
has submitted/voted.
## Project layout
```
server.js Express + Socket.IO server and the YarnGame state machine
bots/BotFactory.js Bot construction (LLM with RandomBot fallback) + unique names
bots/LLMBot.js OpenAI-compatible bot (write/vote/banter, optional config)
bots/RandomBot.js Deterministic offline bot
public/menu.html Room browser, past-story archive, about
public/game.html Game client (lobby, writing, voting, results, game over)
tests/ node:test unit + socket integration tests
stories/ Saved stories (gitignored, created on demand)
```
## Tests
```bash
npm test
```
The suite (`node --test tests/*.test.js`) covers:
- `tests/yarn-game.test.js` – scoring (incl. final-round doubling and tie
breaks), vote validation, phase transitions/timers, reconnection, story
assembly and persistence.
- `tests/bots.test.js` – factory fallback, unique names, concurrent bot
writing/voting, invalid bot votes, banter wiring.
- `tests/validation.test.js` – room/player-id sanitisation, settings clamping,
text cleaning.
- `tests/socket-flow.test.js` – full end-to-end game over real Socket.IO:
two humans + one bot, writing → voting → results → game over, story saved,
plus immediate phase advance when the last unsubmitted player disconnects.
- `tests/client-ui.test.js` – loads `game.html` and `menu.html` in jsdom and
drives the real client code: input preservation, score-limit `0`, refresh
resync, HUD, own-vote blocking, results table, game-over controls, host
skip/kick, room list and past-story archive.
### Manual verification checklist
The jsdom suite covers the client logic; a few visual/browser checks are still
worth doing by hand:
1. Open two browser windows on the same room and confirm the live chat, player
highlight colours and host ★ marker look right.
2. Hover a story segment and confirm the author name appears.
3. On game over, use Download/Copy Story and check the file/clipboard contents.
4. Confirm the results table is readable on a narrow (mobile) viewport.
5. Start a game with 2 humans and 4 bots and confirm the round flow feels right
(LLM latency, banter in chat, no stalled rounds).
+58 -17
View File
@@ -1,37 +1,78 @@
const RandomBot = require('./RandomBot');
const LLMBot = require('./LLMBot');
'use strict';
const fs = require('fs');
const path = require('path');
const RandomBot = require('./RandomBot');
const LLMBot = require('./LLMBot');
const NAMES_PATH = path.join(__dirname, 'bot_names.txt');
const FALLBACK_NAMES = ['DefaultBot', 'BackupBot', 'SpareBot', 'ExtraBot'];
function readNames() {
try {
const names = fs
.readFileSync(NAMES_PATH, 'utf8')
.split('\n')
.map((name) => name.trim())
.filter((name) => name.length > 0);
return names.length ? names : FALLBACK_NAMES;
} catch (err) {
console.warn(`[BotFactory] Could not read ${NAMES_PATH}: ${err.message}`);
return FALLBACK_NAMES;
}
}
class BotFactory {
static createBot(type) {
switch (type.toLowerCase()) {
/**
* Create a bot instance. LLM bots fall back to RandomBot when the LLM
* client cannot be constructed (missing API key/config) so a game can
* always start.
*/
static createBot(type = 'llm') {
switch (String(type).toLowerCase()) {
case 'random':
return new RandomBot();
case 'llm':
return new LLMBot();
try {
return new LLMBot();
} catch (err) {
console.warn(
`[BotFactory] LLM bot unavailable (${err.message}); using RandomBot instead.`
);
return new RandomBot();
}
default:
throw new Error(`Unknown bot type: ${type}`);
}
}
static generateBotName() {
const names = fs.readFileSync(path.join(__dirname, 'bot_names.txt'), 'utf8')
.split('\n')
.map(name => name.trim())
.filter(name => name.length > 0);
/**
* Pick a random bot name, avoiding names already used in the room.
*/
static generateBotName(exclude = []) {
const names = readNames();
const taken = new Set(
(Array.isArray(exclude) ? exclude : [exclude])
.filter(Boolean)
.map((name) => String(name).toLowerCase())
);
if (names.length === 0) {
return 'DefaultBot'; // Fallback if no names are found
}
const available = names.filter((name) => !taken.has(name.toLowerCase()));
const pool = available.length ? available : names;
const pick = pool[Math.floor(Math.random() * pool.length)] || FALLBACK_NAMES[0];
const randomIndex = Math.floor(Math.random() * names.length);
return names[randomIndex];
// If every themed name is taken, disambiguate with a number.
if (taken.has(pick.toLowerCase())) {
let suffix = 2;
while (taken.has(`${pick}${suffix}`.toLowerCase())) suffix += 1;
return `${pick}${suffix}`;
}
return pick;
}
static getAvailableBotTypes() {
return ['random'];
return ['llm', 'random'];
}
}
module.exports = BotFactory;
module.exports = BotFactory;
+125 -59
View File
@@ -1,90 +1,156 @@
'use strict';
const fs = require('fs');
const path = require('path');
const OpenAI = require('openai');
// read optional config file
const configPath = path.join(__dirname, 'llm_config.json');
const llmOptions = fs.existsSync(configPath)
? JSON.parse(fs.readFileSync(configPath, 'utf8'))
: {};
const DEFAULT_MODEL = 'meta-llama/Llama-3.2-3B-Instruct-Turbo';
const CONFIG_PATH = path.join(__dirname, 'llm_config.json');
const PERSONALITY_PATH = path.join(__dirname, 'bot_personalities_dumb.txt');
console.log(`Read config from ${configPath}`);
const FALLBACK_PERSONALITY = 'You are a friendly bot playing a collaborative story game';
function generateBotPersonality() {
const personalities = fs.readFileSync(path.join(__dirname, 'bot_personalities_dumb.txt'), 'utf8')
// Read the optional config file once, tolerating missing/corrupt files.
function loadConfig() {
if (!fs.existsSync(CONFIG_PATH)) return {};
try {
const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
console.warn(`[LLMBot] ${CONFIG_PATH} must contain a JSON object; ignoring it.`);
} catch (err) {
console.warn(`[LLMBot] Could not parse ${CONFIG_PATH}: ${err.message}`);
}
return {};
}
function readLines(file) {
try {
return fs
.readFileSync(file, 'utf8')
.split('\n')
.map(personality => personality.trim())
.filter(personality => personality.length > 0);
if (personalities.length === 0) {
return 'You are a friendly bot'; // Fallback if no personalities are found
.map((line) => line.trim())
.filter((line) => line.length > 0);
} catch (err) {
console.warn(`[LLMBot] Could not read ${file}: ${err.message}`);
return [];
}
const randomIndex = Math.floor(Math.random() * personalities.length);
return personalities[randomIndex];
}
function resolvePersonalitiesFile(configured) {
const requested = configured || process.env.YARN_BOT_PERSONALITIES;
if (!requested) return PERSONALITY_PATH;
return path.isAbsolute(requested) ? requested : path.join(__dirname, requested);
}
function readPersonalities(file = PERSONALITY_PATH) {
return readLines(file);
}
function generateBotPersonality(file = PERSONALITY_PATH) {
const personalities = readPersonalities(file);
if (!personalities.length) return FALLBACK_PERSONALITY;
return personalities[Math.floor(Math.random() * personalities.length)];
}
function firstContent(response) {
const content = response?.choices?.[0]?.message?.content;
return typeof content === 'string' ? content.trim() : '';
}
/**
* LLM-backed bot. The constructor throws when the client cannot be
* configured (for example, no API key); BotFactory catches that and
* falls back to RandomBot so a misconfigured server keeps working.
*/
class LLMBot {
constructor() {
// Instantiate the OpenAI client with the config options we just read
this.llm = new OpenAI(llmOptions);
constructor(config) {
const configPathExists = fs.existsSync(CONFIG_PATH);
if (configPathExists) console.log(`[LLMBot] Read config from ${CONFIG_PATH}`);
// Prompt fragments we’ll reuse
this.writePrompt =
`You are playing a collaborative storytelling game.
Continue the following story with exactly ONE short, creative sentence or sentence fragment.
Do NOT repeat what’s already written.`;
this.options = config && typeof config === 'object' ? config : loadConfig();
this.model = this.options.model || process.env.OPENAI_MODEL || DEFAULT_MODEL;
this.votePrompt =
`You are playing a collaborative storytelling game.
Below is the story so far, followed by a list of possible next sentences.
Reply with ONLY the number (0-indexed) of the sentence you like best.`;
// Throws if OPENAI_API_KEY (or an apiKey in llm_config.json) is missing.
this.llm = new OpenAI(this.options);
this.banterPrompt =
`You are a playful, slightly sarcastic AI taking part in a writing-game chat.
Keep it short, fun, and on-topic.`;
this.writePrompt = [
'You are playing a collaborative storytelling game.',
'Continue the following story with exactly ONE short, creative sentence or sentence fragment.',
'Do NOT repeat what is already written.',
'Reply with the sentence only, no quotes, no preamble.'
].join('\n');
this.limitPrompt = "Please do not exceed 25 words."
this.votePrompt = [
'You are playing a collaborative storytelling game.',
'Below is the story so far, followed by a list of possible next sentences.',
'Reply with ONLY the number of the sentence you like best. No other text.'
].join('\n');
this.personality = generateBotPersonality();
this.banterPrompt = [
'You are a playful, slightly sarcastic AI taking part in a writing-game chat.',
'Keep it short, fun, and on-topic (one sentence, under 20 words).'
].join('\n');
this.limitPrompt = 'Please do not exceed 25 words.';
// Defaults to bot_personalities_dumb.txt; override with `personalitiesFile`
// in the config or the YARN_BOT_PERSONALITIES environment variable.
this.personalitiesFile = resolvePersonalitiesFile(this.options.personalitiesFile);
this.personality = generateBotPersonality(this.personalitiesFile);
}
async Write(story_so_far) {
const msg = `${this.personality}. ${this.writePrompt} ${this.limitPrompt}\n\nStory so far:\n${story_so_far}`;
async complete(messages, { temperature = 0.8, maxTokens = 80 } = {}) {
const response = await this.llm.chat.completions.create({
model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
messages: [{ role: 'user', content: msg }],
temperature: llmOptions.temperature ?? 0.8,
max_tokens: llmOptions.max_tokens ?? 80,
model: this.model,
messages,
temperature,
max_tokens: maxTokens
});
//console.log(`Received response from LLM: ${response.choices[0].message.content}`);
return response.choices[0].message.content.trim();
return firstContent(response);
}
async Vote(story_so_far, choices) {
const choicesBlock = choices.map((c, i) => `${i}: ${(c.entry_text)}`).join('\n');
const msg = `${this.personality}. ${this.votePrompt}\n\nStory:\n${story_so_far}\n\nChoices:\n${choicesBlock}`;
//console.log(msg);
const response = await this.llm.chat.completions.create({
model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
messages: [{ role: 'user', content: msg }],
async Write(storySoFar) {
const msg = `${this.personality}.\n${this.writePrompt}\n${this.limitPrompt}\n\nStory so far:\n${storySoFar || '(the story has not started yet)'}`;
const text = await this.complete([{ role: 'user', content: msg }], {
temperature: this.options.temperature ?? 0.8,
maxTokens: this.options.max_tokens ?? 80
});
// Strip wrapping quotes and stray newlines so entries read naturally.
return text.replace(/^["'\s]+|["'\s]+$/g, '');
}
async Vote(storySoFar, choices) {
const list = Array.isArray(choices) ? choices : [];
if (!list.length) return null;
const choicesBlock = list
.map((choice, i) => `${choice.index ?? i}: ${choice.entry_text}`)
.join('\n');
const msg = `${this.personality}.\n${this.votePrompt}\n\nStory:\n${storySoFar}\n\nChoices:\n${choicesBlock}`;
const reply = await this.complete([{ role: 'user', content: msg }], {
temperature: 0.2,
max_tokens: 5,
maxTokens: 8
});
const reply = response.choices[0].message.content.trim();
const match = reply.match(/\d+/);
return match ? parseInt(match[0], 10) : 0;
if (!match) return null;
const parsed = Number.parseInt(match[0], 10);
return Number.isInteger(parsed) ? parsed : null;
}
async Banter(chat_so_far) {
const msg = `${this.banterPrompt}\n\nChat so far:\n${chat_so_far}`;
const response = await this.llm.chat.completions.create({
model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
messages: [{ role: 'user', content: msg }],
temperature: llmOptions.temperature ?? 0.8,
max_tokens: llmOptions.max_tokens ?? 60,
async Banter(chatSoFar) {
const msg = `${this.banterPrompt}\n\nRecent chat:\n${chatSoFar || '(nobody has spoken yet)'}`;
return this.complete([{ role: 'user', content: msg }], {
temperature: this.options.temperature ?? 0.8,
maxTokens: this.options.max_tokens ?? 60
});
return response.choices[0].message.content.trim();
}
}
module.exports = LLMBot;
LLMBot.DEFAULT_MODEL = DEFAULT_MODEL;
LLMBot.CONFIG_PATH = CONFIG_PATH;
LLMBot.PERSONALITY_PATH = PERSONALITY_PATH;
LLMBot.readPersonalities = readPersonalities;
LLMBot.generateBotPersonality = generateBotPersonality;
module.exports = LLMBot;
+24 -21
View File
@@ -1,19 +1,15 @@
'use strict';
class RandomBot {
constructor() {
this.writePhrases = [
" Suddenly, a mysterious figure appeared from the shadows.",
" The ground began to shake violently beneath their feet.",
" In the distance, an eerie sound echoed through the valley.",
" A bright light flashed, blinding everyone momentarily.",
" The ancient artifact started glowing with an otherworldly energy."
];
this.votePhrases = [
"I think we should proceed with caution.",
"Let's take the bold approach!",
"The safe path seems best right now.",
"We should explore further before deciding.",
"My instincts tell me to go left."
'Suddenly, a mysterious figure appeared from the shadows.',
'The ground began to shake violently beneath their feet.',
'In the distance, an eerie sound echoed through the valley.',
'A bright light flashed, blinding everyone momentarily.',
'The ancient artifact started glowing with an otherworldly energy.',
'A gust of wind carried whispers of a forgotten warning.',
'Somewhere nearby, a door creaked open on its own.'
];
this.banterPhrases = [
@@ -24,27 +20,34 @@ class RandomBot {
"Remember when we used to play simple games?",
"What's the worst that could happen?",
"I wonder if there are cookies in this game.",
"Do bots dream of electric sheep?",
'Do bots dream of electric sheep?',
"Is this thing on?",
"I'm having a great time, how about you?"
];
}
Write(story_so_far) {
Write() {
const randomIndex = Math.floor(Math.random() * this.writePhrases.length);
return this.writePhrases[randomIndex];
}
Vote(story_so_far, choices) {
// Return a random choice number (0-indexed)
const randomChoice = Math.floor(Math.random() * choices.length);
return randomChoice;
/**
* Vote for one of the supplied choices. Choices are objects of the form
* { index, entry_text } where `index` is the real entry index; return
* that index (never the position in the filtered list).
*/
Vote(storySoFar, choices) {
const list = Array.isArray(choices) ? choices : [];
if (!list.length) return null;
const pick = list[Math.floor(Math.random() * list.length)];
if (pick && Number.isInteger(pick.index)) return pick.index;
return Number.isInteger(pick) ? pick : 0;
}
Banter(chat_so_far) {
Banter() {
const randomIndex = Math.floor(Math.random() * this.banterPhrases.length);
return this.banterPhrases[randomIndex];
}
}
module.exports = RandomBot;
module.exports = RandomBot;
+7
View File
@@ -0,0 +1,7 @@
{
"apiKey": "your-api-key-here",
"baseURL": "https://api.openai.com/v1",
"model": "meta-llama/Llama-3.2-3B-Instruct-Turbo",
"temperature": 0.8,
"max_tokens": 80
}
+1695
View File
File diff suppressed because it is too large. Load diff
+7 -7
View File
@@ -1,22 +1,22 @@
{
"name": "yarn",
"version": "1.0.0",
"description": "",
"description": "Browser remake of the Y.A.R.N. collaborative storytelling game, with optional LLM bots.",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test": "node --test tests/*.test.js",
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@themaximalist/llm.js": "^1.0.3",
"cookie-parser": "^1.4.7",
"express": "^5.2.1",
"node-fetch": "^3.3.2",
"openai": "^6.25.0",
"socket.io": "^4.8.3",
"uuid": "^13.0.0"
"socket.io": "^4.8.3"
},
"devDependencies": {
"jsdom": "^30.1.3",
"socket.io-client": "^4.8.4"
}
}
+689 -336
View File
File diff suppressed because it is too large. Load diff
+181 -49
View File
@@ -7,7 +7,6 @@
<meta property="og:description" content="A collaborative story-writing game. Remake of the 2000s GameSpy Arcade classic." />
<title>Y.A.R.N. HTML5</title>
<!-- re-use the same CSS-variables and base look -->
<style>
:root {
--bg-main: #4b0082;
@@ -18,12 +17,13 @@
}
* { margin:0; padding:0; box-sizing:border-box; font-family:system-ui, sans-serif; }
body {
height:100vh;
min-height:100vh;
background:var(--bg-main);
color:var(--text-beige);
display:flex;
align-items:center;
justify-content:center;
padding:1.5rem 0;
}
#splash {
background:var(--box-bg);
@@ -31,8 +31,11 @@
border-radius:8px;
padding:2rem 3rem;
min-width:320px;
max-width:560px;
width:100%;
text-align:center;
}
#splash > img { max-width:100%; }
h1 { margin-bottom:1.5rem; letter-spacing:1px; }
.section { margin:1.2rem 0; }
input[type=text], button {
@@ -67,50 +70,101 @@
.room-row {
display:flex;
justify-content:space-between;
align-items:center;
gap:.5rem;
padding:.4rem .6rem;
cursor:pointer;
border-radius:3px;
}
.room-row:hover { background:#222; }
.room-name { flex:1; }
.room-count { color:var(--player-green); }
.no-rooms { color:#888; font-style:italic; }
/* About overlay */
#about-overlay {
.room-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.room-count { color:var(--player-green); white-space:nowrap; }
.room-status {
font-size:.7rem;
text-transform:uppercase;
padding:.1rem .35rem;
border-radius:3px;
background:#3a0066;
color:#e6d9ff;
white-space:nowrap;
}
.no-rooms, .hint { color:#888; font-style:italic; font-size:.85rem; }
/* Overlays */
.overlay {
position:fixed; inset:0;
background:rgba(0,0,0,.75);
display:flex; align-items:center; justify-content:center;
padding:1rem;
z-index:10;
}
#about-box {
.overlay-box {
background:var(--box-bg);
border:2px solid var(--accent-purple);
border-radius:8px;
padding:2rem 2.5rem;
max-width:640px;
max-width:720px;
width:100%;
max-height:88vh;
overflow-y:auto;
color:var(--text-beige);
line-height:1.45;
}
#about-box h3 { margin-top:0; margin-bottom:1rem; }
#about-box p { margin:.6rem 0; }
#about-box ul { margin:.6rem 0 1rem 1.2rem; }
#about-box button {
.overlay-box h3 { margin-top:0; margin-bottom:1rem; }
.overlay-box p { margin:.6rem 0; }
.overlay-box ul { margin:.6rem 0 1rem 1.2rem; }
.overlay-box button {
margin-top:1rem; width:auto; padding:.6rem 1.2rem;
}
.overlay-actions { display:flex; gap:.6rem; flex-wrap:wrap; justify-content:center; }
#stories-list {
display:flex;
flex-direction:column;
gap:.35rem;
max-height:45vh;
overflow-y:auto;
margin:1rem 0;
}
.story-row {
display:flex; justify-content:space-between; align-items:center; gap:.5rem;
padding:.45rem .6rem;
background:#151515;
border:1px solid #2a2a2a;
border-radius:4px;
cursor:pointer;
font-size:.9rem;
}
.story-row:hover { border-color:var(--accent-purple); }
.story-row .story-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
#story-viewer {
white-space:pre-wrap;
font-family:"Times New Roman", Times, serif;
font-size:1.05rem;
line-height:1.5;
background:#111;
border:1px solid #2a2a2a;
border-radius:4px;
padding:1rem;
max-height:50vh;
overflow-y:auto;
}
.hidden { display:none !important; }
</style>
</head>
<body>
<div id="splash">
<img src="images/yarnlogo.png" />
<img src="images/yarnlogo.png" alt="Y.A.R.N. logo" />
<h4 style="font-style: italic; color:orangered">HTML5 Edition</h4>
<!-- ENTER GAME -->
<div class="section">
<input id="room-input" type="text" placeholder="Room name..." maxlength="30"/>
<button id="enter-btn">Enter Game</button>
<div class="hint">Letters, numbers, dashes and underscores only.</div>
</div>
<!-- FIND GAME -->
@@ -119,76 +173,103 @@
<div id="room-list"></div>
</div>
<!-- PAST STORIES -->
<div class="section">
<button id="stories-btn">Past Stories</button>
</div>
<!-- ABOUT -->
<div class="section">
<button id="about-btn">About Y.A.R.N.</button>
</div>
</div>
<!-- ABOUT OVERLAY -->
<div id="about-overlay" class="hidden">
<div id="about-box">
<h3>What is Y.A.R.N. HTML5?</h3>
<p>Y.A.R.N. (Yet Another Random Narrative) was a collaborative story-writing "parlor game" originally included in GameSpy Arcade in the early 2000s, but removed shortly thereafter due to security issues that were never resolved.</p>
<p><strong>Y.A.R.N. HTML5</strong> is a modern remake of (what I can remember from) the original, designed to run in-browser, and with extra features such as (optional) LLM-based bots.</p>
<p style="text-align: left;"><strong>The rules are simple:</strong></p>
<ul style="text-align: left;">
<li>Players have a limited amount of time to write a sentence, selected by the host at game start.</li>
<li>Once all players have submitted their sentences, players have 30 seconds to vote for their favorite.</li>
<li>All players receive one point for each vote their sentence receives. The player whose sentence receives the most votes is awarded 3 bonus points.</li>
<li>In the case of a tie, the longest sentence receives two extra points and the other sentences tied receive 1 point each.</li>
<li>When a player reaches the point limit, they win the game.</li>
</ul>
<p>Enjoy the story-writing!<br><br>
Report any bugs/comments/questions/concerns in the <a href="https://discord.gg/nvP92Mc">Juke Gaming Network Discord</a> or message the author <strong>@juke420</strong>.</p>
<!-- ABOUT OVERLAY -->
<div id="about-overlay" class="overlay hidden">
<div class="overlay-box">
<h3>What is Y.A.R.N. HTML5?</h3>
<p>Y.A.R.N. (Yet Another Random Narrative) was a collaborative story-writing "parlor game" originally included in GameSpy Arcade in the early 2000s, but removed shortly thereafter due to security issues that were never resolved.</p>
<p><strong>Y.A.R.N. HTML5</strong> is a modern remake of (what I can remember from) the original, designed to run in-browser, and with extra features such as (optional) LLM-based bots.</p>
<p><strong>The rules are simple:</strong></p>
<ul>
<li>Players have a limited amount of time to write a sentence, selected by the host at game start.</li>
<li>Once all players have submitted their sentences, players vote for their favorite (you cannot vote for your own).</li>
<li>All players receive one point for each vote their sentence receives. The player whose sentence receives the most votes is awarded 3 bonus points.</li>
<li>In the case of a tie, the longest sentence receives two extra points and the other sentences tied receive 1 point each.</li>
<li>The final round is worth double points.</li>
<li>When a player reaches the point limit (or the round limit ends), they win the game.</li>
</ul>
<p>The finished story is saved automatically and can be found under <strong>Past Stories</strong>.<br><br>
Report any bugs/comments/questions/concerns in the <a href="https://discord.gg/nvP92Mc">Juke Gaming Network Discord</a> or message the author <strong>@juke420</strong>.</p>
<div class="overlay-actions">
<button id="close-about">Close</button>
</div>
</div>
</div>
<!-- STORIES OVERLAY -->
<div id="stories-overlay" class="overlay hidden">
<div class="overlay-box">
<h3>Past Stories</h3>
<div id="stories-list"><div class="no-rooms">Loading…</div></div>
<div id="story-viewer" class="hidden"></div>
<div class="overlay-actions">
<button id="stories-refresh">Refresh</button>
<button id="close-stories">Close</button>
</div>
</div>
</div>
<script>
const roomInput = document.getElementById('room-input');
const enterBtn = document.getElementById('enter-btn');
const findBtn = document.getElementById('find-btn');
const roomListBox = document.getElementById('room-list');
const aboutBtn = document.getElementById('about-btn');
const aboutBox = document.getElementById('about-overlay');
const closeAbout = document.getElementById('close-about');
const aboutBtn = document.getElementById('about-btn');
const aboutBox = document.getElementById('about-overlay');
const closeAbout = document.getElementById('close-about');
const storiesBtn = document.getElementById('stories-btn');
const storiesBox = document.getElementById('stories-overlay');
const closeStories = document.getElementById('close-stories');
const storiesRefresh = document.getElementById('stories-refresh');
const storiesList = document.getElementById('stories-list');
const storyViewer = document.getElementById('story-viewer');
let findActive = false;
let pollId = null;
function toggleAbout(show){
aboutBox.classList.toggle('hidden',!show);
aboutBox.classList.toggle('hidden', !show);
}
aboutBtn.addEventListener('click', () => toggleAbout(true));
closeAbout.addEventListener('click', () => toggleAbout(false));
aboutBox.addEventListener('click', e => { if (e.target===aboutBox) toggleAbout(false); });
aboutBox.addEventListener('click', e => { if (e.target === aboutBox) toggleAbout(false); });
/* sanitize helper */
const esc = str => {
const d = document.createElement('div');
d.textContent = str;
d.textContent = str == null ? '' : String(str);
return d.innerHTML;
};
/* ENTER GAME */
function goToRoom() {
const name = roomInput.value.trim().toLowerCase().replace(/[^a-z0-9-_]/g,'');
const name = roomInput.value.trim().toLowerCase().replace(/[^a-z0-9-_]/g, '');
if (!name) { alert('Please enter a room name.'); return; }
window.location.href = '/game/' + encodeURIComponent(name);
}
enterBtn.addEventListener('click', goToRoom);
roomInput.addEventListener('keydown', e => { if (e.key==='Enter') goToRoom(); });
roomInput.addEventListener('keydown', e => { if (e.key === 'Enter') goToRoom(); });
/* FIND GAME */
async function fetchRooms() {
try {
const r = await fetch('/api/rooms');
if (!r.ok) throw new Error();
const data = await r.json(); // {rooms:[{name,playerCount},...]}
const response = await fetch('/api/rooms');
if (!response.ok) throw new Error('bad response');
const data = await response.json();
renderRooms(data.rooms || []);
} catch {
renderRooms([]);
roomListBox.innerHTML = '<div class="no-rooms">Could not load rooms.</div>';
}
}
@@ -197,10 +278,11 @@
roomListBox.innerHTML = '<div class="no-rooms">No open rooms right now.</div>';
return;
}
roomListBox.innerHTML = list.map(r => `
<div class="room-row" data-room="${esc(r.name)}">
<span class="room-name">${esc(r.name)}</span>
<span class="room-count">${r.playerCount} player${r.playerCount===1?'':'s'}</span>
roomListBox.innerHTML = list.map(room => `
<div class="room-row" data-room="${esc(room.name)}">
<span class="room-name">${esc(room.name)}</span>
<span class="room-status">${esc(room.phase || 'lobby')}</span>
<span class="room-count">${room.playerCount} player${room.playerCount === 1 ? '' : 's'}</span>
</div>`).join('');
roomListBox.querySelectorAll('.room-row').forEach(row => {
@@ -217,13 +299,63 @@
fetchRooms();
pollId = setInterval(fetchRooms, 2000);
} else {
findBtn.textContent = 'Find Game';
findBtn.textContent = 'Find Games';
clearInterval(pollId);
roomListBox.innerHTML = '';
}
}
findBtn.addEventListener('click', toggleFind);
/* PAST STORIES */
async function fetchStories() {
storiesList.innerHTML = '<div class="no-rooms">Loading…</div>';
storyViewer.classList.add('hidden');
try {
const response = await fetch('/api/stories');
if (!response.ok) throw new Error('bad response');
const data = await response.json();
renderStories(data.stories || []);
} catch {
storiesList.innerHTML = '<div class="no-rooms">Could not load stories.</div>';
}
}
function renderStories(stories) {
if (!stories.length) {
storiesList.innerHTML = '<div class="no-rooms">No stories have been saved yet.</div>';
return;
}
storiesList.innerHTML = stories.map(name => `
<div class="story-row" data-story="${esc(name)}">
<span class="story-name">${esc(name)}</span>
<span>open</span>
</div>`).join('');
storiesList.querySelectorAll('.story-row').forEach(row => {
row.addEventListener('click', () => openStory(row.dataset.story));
});
}
async function openStory(name) {
storyViewer.classList.remove('hidden');
storyViewer.textContent = 'Loading…';
try {
const response = await fetch('/api/stories/' + encodeURIComponent(name));
if (!response.ok) throw new Error('bad response');
storyViewer.textContent = await response.text();
} catch {
storyViewer.textContent = 'Could not load that story.';
}
}
storiesBtn.addEventListener('click', () => {
storiesBox.classList.remove('hidden');
fetchStories();
});
closeStories.addEventListener('click', () => storiesBox.classList.add('hidden'));
storiesBox.addEventListener('click', e => { if (e.target === storiesBox) storiesBox.classList.add('hidden'); });
storiesRefresh.addEventListener('click', fetchStories);
</script>
</body>
</html>
</html>
+1107 -423
View File
File diff suppressed because it is too large. Load diff
+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 - /);
});