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,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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
Generated
+1695
File diff suppressed because it is too large.
Load diff
+7
-7
@@ -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
File diff suppressed because it is too large.
Load diff
+181
-49
@@ -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>
|
||||
@@ -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