Batch of LLM-assisted fixes/feature updates
This commit is contained in:
1 parent
7c6453160a
commit
05aaf394df
16 files changed
+5560
-875
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).
|
||||||
+57
-16
@@ -1,36 +1,77 @@
|
|||||||
const RandomBot = require('./RandomBot');
|
'use strict';
|
||||||
const LLMBot = require('./LLMBot');
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
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 {
|
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':
|
case 'random':
|
||||||
return new RandomBot();
|
return new RandomBot();
|
||||||
case 'llm':
|
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:
|
default:
|
||||||
throw new Error(`Unknown bot type: ${type}`);
|
throw new Error(`Unknown bot type: ${type}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static generateBotName() {
|
/**
|
||||||
const names = fs.readFileSync(path.join(__dirname, 'bot_names.txt'), 'utf8')
|
* Pick a random bot name, avoiding names already used in the room.
|
||||||
.split('\n')
|
*/
|
||||||
.map(name => name.trim())
|
static generateBotName(exclude = []) {
|
||||||
.filter(name => name.length > 0);
|
const names = readNames();
|
||||||
|
const taken = new Set(
|
||||||
|
(Array.isArray(exclude) ? exclude : [exclude])
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((name) => String(name).toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
if (names.length === 0) {
|
const available = names.filter((name) => !taken.has(name.toLowerCase()));
|
||||||
return 'DefaultBot'; // Fallback if no names are found
|
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);
|
// If every themed name is taken, disambiguate with a number.
|
||||||
return names[randomIndex];
|
if (taken.has(pick.toLowerCase())) {
|
||||||
|
let suffix = 2;
|
||||||
|
while (taken.has(`${pick}${suffix}`.toLowerCase())) suffix += 1;
|
||||||
|
return `${pick}${suffix}`;
|
||||||
|
}
|
||||||
|
return pick;
|
||||||
}
|
}
|
||||||
|
|
||||||
static getAvailableBotTypes() {
|
static getAvailableBotTypes() {
|
||||||
return ['random'];
|
return ['llm', 'random'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+124
-58
@@ -1,90 +1,156 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const OpenAI = require('openai');
|
const OpenAI = require('openai');
|
||||||
|
|
||||||
// read optional config file
|
const DEFAULT_MODEL = 'meta-llama/Llama-3.2-3B-Instruct-Turbo';
|
||||||
const configPath = path.join(__dirname, 'llm_config.json');
|
const CONFIG_PATH = path.join(__dirname, 'llm_config.json');
|
||||||
const llmOptions = fs.existsSync(configPath)
|
const PERSONALITY_PATH = path.join(__dirname, 'bot_personalities_dumb.txt');
|
||||||
? JSON.parse(fs.readFileSync(configPath, 'utf8'))
|
|
||||||
: {};
|
|
||||||
|
|
||||||
console.log(`Read config from ${configPath}`);
|
const FALLBACK_PERSONALITY = 'You are a friendly bot playing a collaborative story game';
|
||||||
|
|
||||||
function generateBotPersonality() {
|
// Read the optional config file once, tolerating missing/corrupt files.
|
||||||
const personalities = fs.readFileSync(path.join(__dirname, 'bot_personalities_dumb.txt'), 'utf8')
|
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')
|
.split('\n')
|
||||||
.map(personality => personality.trim())
|
.map((line) => line.trim())
|
||||||
.filter(personality => personality.length > 0);
|
.filter((line) => line.length > 0);
|
||||||
|
} catch (err) {
|
||||||
if (personalities.length === 0) {
|
console.warn(`[LLMBot] Could not read ${file}: ${err.message}`);
|
||||||
return 'You are a friendly bot'; // Fallback if no personalities are found
|
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 {
|
class LLMBot {
|
||||||
constructor() {
|
constructor(config) {
|
||||||
// Instantiate the OpenAI client with the config options we just read
|
const configPathExists = fs.existsSync(CONFIG_PATH);
|
||||||
this.llm = new OpenAI(llmOptions);
|
if (configPathExists) console.log(`[LLMBot] Read config from ${CONFIG_PATH}`);
|
||||||
|
|
||||||
// Prompt fragments we’ll reuse
|
this.options = config && typeof config === 'object' ? config : loadConfig();
|
||||||
this.writePrompt =
|
this.model = this.options.model || process.env.OPENAI_MODEL || DEFAULT_MODEL;
|
||||||
`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.votePrompt =
|
// Throws if OPENAI_API_KEY (or an apiKey in llm_config.json) is missing.
|
||||||
`You are playing a collaborative storytelling game.
|
this.llm = new OpenAI(this.options);
|
||||||
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.`;
|
|
||||||
|
|
||||||
this.banterPrompt =
|
this.writePrompt = [
|
||||||
`You are a playful, slightly sarcastic AI taking part in a writing-game chat.
|
'You are playing a collaborative storytelling game.',
|
||||||
Keep it short, fun, and on-topic.`;
|
'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) {
|
async complete(messages, { temperature = 0.8, maxTokens = 80 } = {}) {
|
||||||
const msg = `${this.personality}. ${this.writePrompt} ${this.limitPrompt}\n\nStory so far:\n${story_so_far}`;
|
|
||||||
const response = await this.llm.chat.completions.create({
|
const response = await this.llm.chat.completions.create({
|
||||||
model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
|
model: this.model,
|
||||||
messages: [{ role: 'user', content: msg }],
|
messages,
|
||||||
temperature: llmOptions.temperature ?? 0.8,
|
temperature,
|
||||||
max_tokens: llmOptions.max_tokens ?? 80,
|
max_tokens: maxTokens
|
||||||
});
|
});
|
||||||
//console.log(`Received response from LLM: ${response.choices[0].message.content}`);
|
return firstContent(response);
|
||||||
return response.choices[0].message.content.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async Vote(story_so_far, choices) {
|
async Write(storySoFar) {
|
||||||
const choicesBlock = choices.map((c, i) => `${i}: ${(c.entry_text)}`).join('\n');
|
const msg = `${this.personality}.\n${this.writePrompt}\n${this.limitPrompt}\n\nStory so far:\n${storySoFar || '(the story has not started yet)'}`;
|
||||||
const msg = `${this.personality}. ${this.votePrompt}\n\nStory:\n${story_so_far}\n\nChoices:\n${choicesBlock}`;
|
const text = await this.complete([{ role: 'user', content: msg }], {
|
||||||
//console.log(msg);
|
temperature: this.options.temperature ?? 0.8,
|
||||||
const response = await this.llm.chat.completions.create({
|
maxTokens: this.options.max_tokens ?? 80
|
||||||
model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
|
});
|
||||||
messages: [{ role: 'user', content: msg }],
|
// 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,
|
temperature: 0.2,
|
||||||
max_tokens: 5,
|
maxTokens: 8
|
||||||
});
|
});
|
||||||
const reply = response.choices[0].message.content.trim();
|
|
||||||
const match = reply.match(/\d+/);
|
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) {
|
async Banter(chatSoFar) {
|
||||||
const msg = `${this.banterPrompt}\n\nChat so far:\n${chat_so_far}`;
|
const msg = `${this.banterPrompt}\n\nRecent chat:\n${chatSoFar || '(nobody has spoken yet)'}`;
|
||||||
const response = await this.llm.chat.completions.create({
|
return this.complete([{ role: 'user', content: msg }], {
|
||||||
model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo',
|
temperature: this.options.temperature ?? 0.8,
|
||||||
messages: [{ role: 'user', content: msg }],
|
maxTokens: this.options.max_tokens ?? 60
|
||||||
temperature: llmOptions.temperature ?? 0.8,
|
|
||||||
max_tokens: llmOptions.max_tokens ?? 60,
|
|
||||||
});
|
});
|
||||||
return response.choices[0].message.content.trim();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LLMBot.DEFAULT_MODEL = DEFAULT_MODEL;
|
||||||
|
LLMBot.CONFIG_PATH = CONFIG_PATH;
|
||||||
|
LLMBot.PERSONALITY_PATH = PERSONALITY_PATH;
|
||||||
|
LLMBot.readPersonalities = readPersonalities;
|
||||||
|
LLMBot.generateBotPersonality = generateBotPersonality;
|
||||||
|
|
||||||
module.exports = LLMBot;
|
module.exports = LLMBot;
|
||||||
+23
-20
@@ -1,19 +1,15 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
class RandomBot {
|
class RandomBot {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.writePhrases = [
|
this.writePhrases = [
|
||||||
" Suddenly, a mysterious figure appeared from the shadows.",
|
'Suddenly, a mysterious figure appeared from the shadows.',
|
||||||
" The ground began to shake violently beneath their feet.",
|
'The ground began to shake violently beneath their feet.',
|
||||||
" In the distance, an eerie sound echoed through the valley.",
|
'In the distance, an eerie sound echoed through the valley.',
|
||||||
" A bright light flashed, blinding everyone momentarily.",
|
'A bright light flashed, blinding everyone momentarily.',
|
||||||
" The ancient artifact started glowing with an otherworldly energy."
|
'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.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."
|
|
||||||
];
|
];
|
||||||
|
|
||||||
this.banterPhrases = [
|
this.banterPhrases = [
|
||||||
@@ -24,24 +20,31 @@ class RandomBot {
|
|||||||
"Remember when we used to play simple games?",
|
"Remember when we used to play simple games?",
|
||||||
"What's the worst that could happen?",
|
"What's the worst that could happen?",
|
||||||
"I wonder if there are cookies in this game.",
|
"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?",
|
"Is this thing on?",
|
||||||
"I'm having a great time, how about you?"
|
"I'm having a great time, how about you?"
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
Write(story_so_far) {
|
Write() {
|
||||||
const randomIndex = Math.floor(Math.random() * this.writePhrases.length);
|
const randomIndex = Math.floor(Math.random() * this.writePhrases.length);
|
||||||
return this.writePhrases[randomIndex];
|
return this.writePhrases[randomIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
Vote(story_so_far, choices) {
|
/**
|
||||||
// Return a random choice number (0-indexed)
|
* Vote for one of the supplied choices. Choices are objects of the form
|
||||||
const randomChoice = Math.floor(Math.random() * choices.length);
|
* { index, entry_text } where `index` is the real entry index; return
|
||||||
return randomChoice;
|
* 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);
|
const randomIndex = Math.floor(Math.random() * this.banterPhrases.length);
|
||||||
return this.banterPhrases[randomIndex];
|
return this.banterPhrases[randomIndex];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
"name": "yarn",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "Browser remake of the Y.A.R.N. collaborative storytelling game, with optional LLM bots.",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "node --test tests/*.test.js",
|
||||||
"start": "node server.js"
|
"start": "node server.js"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@themaximalist/llm.js": "^1.0.3",
|
|
||||||
"cookie-parser": "^1.4.7",
|
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"node-fetch": "^3.3.2",
|
|
||||||
"openai": "^6.25.0",
|
"openai": "^6.25.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3"
|
||||||
"uuid": "^13.0.0"
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"jsdom": "^30.1.3",
|
||||||
|
"socket.io-client": "^4.8.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+683
-330
File diff suppressed because it is too large.
Load diff
+179
-47
@@ -7,7 +7,6 @@
|
|||||||
<meta property="og:description" content="A collaborative story-writing game. Remake of the 2000s GameSpy Arcade classic." />
|
<meta property="og:description" content="A collaborative story-writing game. Remake of the 2000s GameSpy Arcade classic." />
|
||||||
<title>Y.A.R.N. HTML5</title>
|
<title>Y.A.R.N. HTML5</title>
|
||||||
|
|
||||||
<!-- re-use the same CSS-variables and base look -->
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-main: #4b0082;
|
--bg-main: #4b0082;
|
||||||
@@ -18,12 +17,13 @@
|
|||||||
}
|
}
|
||||||
* { margin:0; padding:0; box-sizing:border-box; font-family:system-ui, sans-serif; }
|
* { margin:0; padding:0; box-sizing:border-box; font-family:system-ui, sans-serif; }
|
||||||
body {
|
body {
|
||||||
height:100vh;
|
min-height:100vh;
|
||||||
background:var(--bg-main);
|
background:var(--bg-main);
|
||||||
color:var(--text-beige);
|
color:var(--text-beige);
|
||||||
display:flex;
|
display:flex;
|
||||||
align-items:center;
|
align-items:center;
|
||||||
justify-content:center;
|
justify-content:center;
|
||||||
|
padding:1.5rem 0;
|
||||||
}
|
}
|
||||||
#splash {
|
#splash {
|
||||||
background:var(--box-bg);
|
background:var(--box-bg);
|
||||||
@@ -31,8 +31,11 @@
|
|||||||
border-radius:8px;
|
border-radius:8px;
|
||||||
padding:2rem 3rem;
|
padding:2rem 3rem;
|
||||||
min-width:320px;
|
min-width:320px;
|
||||||
|
max-width:560px;
|
||||||
|
width:100%;
|
||||||
text-align:center;
|
text-align:center;
|
||||||
}
|
}
|
||||||
|
#splash > img { max-width:100%; }
|
||||||
h1 { margin-bottom:1.5rem; letter-spacing:1px; }
|
h1 { margin-bottom:1.5rem; letter-spacing:1px; }
|
||||||
.section { margin:1.2rem 0; }
|
.section { margin:1.2rem 0; }
|
||||||
input[type=text], button {
|
input[type=text], button {
|
||||||
@@ -67,50 +70,101 @@
|
|||||||
.room-row {
|
.room-row {
|
||||||
display:flex;
|
display:flex;
|
||||||
justify-content:space-between;
|
justify-content:space-between;
|
||||||
|
align-items:center;
|
||||||
|
gap:.5rem;
|
||||||
padding:.4rem .6rem;
|
padding:.4rem .6rem;
|
||||||
cursor:pointer;
|
cursor:pointer;
|
||||||
border-radius:3px;
|
border-radius:3px;
|
||||||
}
|
}
|
||||||
.room-row:hover { background:#222; }
|
.room-row:hover { background:#222; }
|
||||||
.room-name { flex:1; }
|
.room-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
.room-count { color:var(--player-green); }
|
.room-count { color:var(--player-green); white-space:nowrap; }
|
||||||
.no-rooms { color:#888; font-style:italic; }
|
.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; }
|
||||||
|
|
||||||
/* About overlay */
|
/* Overlays */
|
||||||
#about-overlay {
|
.overlay {
|
||||||
position:fixed; inset:0;
|
position:fixed; inset:0;
|
||||||
background:rgba(0,0,0,.75);
|
background:rgba(0,0,0,.75);
|
||||||
display:flex; align-items:center; justify-content:center;
|
display:flex; align-items:center; justify-content:center;
|
||||||
padding:1rem;
|
padding:1rem;
|
||||||
|
z-index:10;
|
||||||
}
|
}
|
||||||
#about-box {
|
.overlay-box {
|
||||||
background:var(--box-bg);
|
background:var(--box-bg);
|
||||||
border:2px solid var(--accent-purple);
|
border:2px solid var(--accent-purple);
|
||||||
border-radius:8px;
|
border-radius:8px;
|
||||||
padding:2rem 2.5rem;
|
padding:2rem 2.5rem;
|
||||||
max-width:640px;
|
max-width:720px;
|
||||||
|
width:100%;
|
||||||
|
max-height:88vh;
|
||||||
|
overflow-y:auto;
|
||||||
color:var(--text-beige);
|
color:var(--text-beige);
|
||||||
line-height:1.45;
|
line-height:1.45;
|
||||||
}
|
}
|
||||||
#about-box h3 { margin-top:0; margin-bottom:1rem; }
|
.overlay-box h3 { margin-top:0; margin-bottom:1rem; }
|
||||||
#about-box p { margin:.6rem 0; }
|
.overlay-box p { margin:.6rem 0; }
|
||||||
#about-box ul { margin:.6rem 0 1rem 1.2rem; }
|
.overlay-box ul { margin:.6rem 0 1rem 1.2rem; }
|
||||||
#about-box button {
|
.overlay-box button {
|
||||||
margin-top:1rem; width:auto; padding:.6rem 1.2rem;
|
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; }
|
.hidden { display:none !important; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div id="splash">
|
<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>
|
<h4 style="font-style: italic; color:orangered">HTML5 Edition</h4>
|
||||||
|
|
||||||
<!-- ENTER GAME -->
|
<!-- ENTER GAME -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<input id="room-input" type="text" placeholder="Room name..." maxlength="30"/>
|
<input id="room-input" type="text" placeholder="Room name..." maxlength="30"/>
|
||||||
<button id="enter-btn">Enter Game</button>
|
<button id="enter-btn">Enter Game</button>
|
||||||
|
<div class="hint">Letters, numbers, dashes and underscores only.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FIND GAME -->
|
<!-- FIND GAME -->
|
||||||
@@ -119,76 +173,103 @@
|
|||||||
<div id="room-list"></div>
|
<div id="room-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- PAST STORIES -->
|
||||||
|
<div class="section">
|
||||||
|
<button id="stories-btn">Past Stories</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ABOUT -->
|
<!-- ABOUT -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<button id="about-btn">About Y.A.R.N.</button>
|
<button id="about-btn">About Y.A.R.N.</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ABOUT OVERLAY -->
|
<!-- ABOUT OVERLAY -->
|
||||||
<div id="about-overlay" class="hidden">
|
<div id="about-overlay" class="overlay hidden">
|
||||||
<div id="about-box">
|
<div class="overlay-box">
|
||||||
<h3>What is Y.A.R.N. HTML5?</h3>
|
<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>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>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>
|
<p><strong>The rules are simple:</strong></p>
|
||||||
<ul style="text-align: left;">
|
<ul>
|
||||||
<li>Players have a limited amount of time to write a sentence, selected by the host at game start.</li>
|
<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>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>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>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>
|
<li>The final round is worth double points.</li>
|
||||||
</ul>
|
<li>When a player reaches the point limit (or the round limit ends), they win the game.</li>
|
||||||
<p>Enjoy the story-writing!<br><br>
|
</ul>
|
||||||
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>
|
<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>
|
<button id="close-about">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
<script>
|
||||||
const roomInput = document.getElementById('room-input');
|
const roomInput = document.getElementById('room-input');
|
||||||
const enterBtn = document.getElementById('enter-btn');
|
const enterBtn = document.getElementById('enter-btn');
|
||||||
const findBtn = document.getElementById('find-btn');
|
const findBtn = document.getElementById('find-btn');
|
||||||
const roomListBox = document.getElementById('room-list');
|
const roomListBox = document.getElementById('room-list');
|
||||||
const aboutBtn = document.getElementById('about-btn');
|
const aboutBtn = document.getElementById('about-btn');
|
||||||
const aboutBox = document.getElementById('about-overlay');
|
const aboutBox = document.getElementById('about-overlay');
|
||||||
const closeAbout = document.getElementById('close-about');
|
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 findActive = false;
|
||||||
let pollId = null;
|
let pollId = null;
|
||||||
|
|
||||||
function toggleAbout(show){
|
function toggleAbout(show){
|
||||||
aboutBox.classList.toggle('hidden',!show);
|
aboutBox.classList.toggle('hidden', !show);
|
||||||
}
|
}
|
||||||
aboutBtn.addEventListener('click', () => toggleAbout(true));
|
aboutBtn.addEventListener('click', () => toggleAbout(true));
|
||||||
closeAbout.addEventListener('click', () => toggleAbout(false));
|
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 */
|
/* sanitize helper */
|
||||||
const esc = str => {
|
const esc = str => {
|
||||||
const d = document.createElement('div');
|
const d = document.createElement('div');
|
||||||
d.textContent = str;
|
d.textContent = str == null ? '' : String(str);
|
||||||
return d.innerHTML;
|
return d.innerHTML;
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ENTER GAME */
|
/* ENTER GAME */
|
||||||
function goToRoom() {
|
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; }
|
if (!name) { alert('Please enter a room name.'); return; }
|
||||||
window.location.href = '/game/' + encodeURIComponent(name);
|
window.location.href = '/game/' + encodeURIComponent(name);
|
||||||
}
|
}
|
||||||
enterBtn.addEventListener('click', goToRoom);
|
enterBtn.addEventListener('click', goToRoom);
|
||||||
roomInput.addEventListener('keydown', e => { if (e.key==='Enter') goToRoom(); });
|
roomInput.addEventListener('keydown', e => { if (e.key === 'Enter') goToRoom(); });
|
||||||
|
|
||||||
/* FIND GAME */
|
/* FIND GAME */
|
||||||
async function fetchRooms() {
|
async function fetchRooms() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/rooms');
|
const response = await fetch('/api/rooms');
|
||||||
if (!r.ok) throw new Error();
|
if (!response.ok) throw new Error('bad response');
|
||||||
const data = await r.json(); // {rooms:[{name,playerCount},...]}
|
const data = await response.json();
|
||||||
renderRooms(data.rooms || []);
|
renderRooms(data.rooms || []);
|
||||||
} catch {
|
} 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>';
|
roomListBox.innerHTML = '<div class="no-rooms">No open rooms right now.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
roomListBox.innerHTML = list.map(r => `
|
roomListBox.innerHTML = list.map(room => `
|
||||||
<div class="room-row" data-room="${esc(r.name)}">
|
<div class="room-row" data-room="${esc(room.name)}">
|
||||||
<span class="room-name">${esc(r.name)}</span>
|
<span class="room-name">${esc(room.name)}</span>
|
||||||
<span class="room-count">${r.playerCount} player${r.playerCount===1?'':'s'}</span>
|
<span class="room-status">${esc(room.phase || 'lobby')}</span>
|
||||||
|
<span class="room-count">${room.playerCount} player${room.playerCount === 1 ? '' : 's'}</span>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
|
|
||||||
roomListBox.querySelectorAll('.room-row').forEach(row => {
|
roomListBox.querySelectorAll('.room-row').forEach(row => {
|
||||||
@@ -217,12 +299,62 @@
|
|||||||
fetchRooms();
|
fetchRooms();
|
||||||
pollId = setInterval(fetchRooms, 2000);
|
pollId = setInterval(fetchRooms, 2000);
|
||||||
} else {
|
} else {
|
||||||
findBtn.textContent = 'Find Game';
|
findBtn.textContent = 'Find Games';
|
||||||
clearInterval(pollId);
|
clearInterval(pollId);
|
||||||
roomListBox.innerHTML = '';
|
roomListBox.innerHTML = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
findBtn.addEventListener('click', toggleFind);
|
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>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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