diff --git a/README.md b/README.md new file mode 100644 index 0000000..5cf725a --- /dev/null +++ b/README.md @@ -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/` 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). diff --git a/bots/BotFactory.js b/bots/BotFactory.js index 9e7b341..60db95b 100644 --- a/bots/BotFactory.js +++ b/bots/BotFactory.js @@ -1,37 +1,78 @@ -const RandomBot = require('./RandomBot'); -const LLMBot = require('./LLMBot'); +'use strict'; + const fs = require('fs'); const path = require('path'); +const RandomBot = require('./RandomBot'); +const LLMBot = require('./LLMBot'); + +const NAMES_PATH = path.join(__dirname, 'bot_names.txt'); +const FALLBACK_NAMES = ['DefaultBot', 'BackupBot', 'SpareBot', 'ExtraBot']; + +function readNames() { + try { + const names = fs + .readFileSync(NAMES_PATH, 'utf8') + .split('\n') + .map((name) => name.trim()) + .filter((name) => name.length > 0); + return names.length ? names : FALLBACK_NAMES; + } catch (err) { + console.warn(`[BotFactory] Could not read ${NAMES_PATH}: ${err.message}`); + return FALLBACK_NAMES; + } +} class BotFactory { - static createBot(type) { - switch (type.toLowerCase()) { + /** + * Create a bot instance. LLM bots fall back to RandomBot when the LLM + * client cannot be constructed (missing API key/config) so a game can + * always start. + */ + static createBot(type = 'llm') { + switch (String(type).toLowerCase()) { case 'random': return new RandomBot(); case 'llm': - return new LLMBot(); + try { + return new LLMBot(); + } catch (err) { + console.warn( + `[BotFactory] LLM bot unavailable (${err.message}); using RandomBot instead.` + ); + return new RandomBot(); + } default: throw new Error(`Unknown bot type: ${type}`); } } - static generateBotName() { - const names = fs.readFileSync(path.join(__dirname, 'bot_names.txt'), 'utf8') - .split('\n') - .map(name => name.trim()) - .filter(name => name.length > 0); + /** + * Pick a random bot name, avoiding names already used in the room. + */ + static generateBotName(exclude = []) { + const names = readNames(); + const taken = new Set( + (Array.isArray(exclude) ? exclude : [exclude]) + .filter(Boolean) + .map((name) => String(name).toLowerCase()) + ); - if (names.length === 0) { - return 'DefaultBot'; // Fallback if no names are found - } + const available = names.filter((name) => !taken.has(name.toLowerCase())); + const pool = available.length ? available : names; + const pick = pool[Math.floor(Math.random() * pool.length)] || FALLBACK_NAMES[0]; -const randomIndex = Math.floor(Math.random() * names.length); -return names[randomIndex]; + // If every themed name is taken, disambiguate with a number. + if (taken.has(pick.toLowerCase())) { + let suffix = 2; + while (taken.has(`${pick}${suffix}`.toLowerCase())) suffix += 1; + return `${pick}${suffix}`; + } + return pick; } static getAvailableBotTypes() { - return ['random']; + return ['llm', 'random']; } } -module.exports = BotFactory; \ No newline at end of file +module.exports = BotFactory; diff --git a/bots/LLMBot.js b/bots/LLMBot.js index 59f1d3f..4eeb2f5 100644 --- a/bots/LLMBot.js +++ b/bots/LLMBot.js @@ -1,90 +1,156 @@ +'use strict'; + const fs = require('fs'); const path = require('path'); const OpenAI = require('openai'); -// read optional config file -const configPath = path.join(__dirname, 'llm_config.json'); -const llmOptions = fs.existsSync(configPath) - ? JSON.parse(fs.readFileSync(configPath, 'utf8')) - : {}; +const DEFAULT_MODEL = 'meta-llama/Llama-3.2-3B-Instruct-Turbo'; +const CONFIG_PATH = path.join(__dirname, 'llm_config.json'); +const PERSONALITY_PATH = path.join(__dirname, 'bot_personalities_dumb.txt'); -console.log(`Read config from ${configPath}`); +const FALLBACK_PERSONALITY = 'You are a friendly bot playing a collaborative story game'; -function generateBotPersonality() { - const personalities = fs.readFileSync(path.join(__dirname, 'bot_personalities_dumb.txt'), 'utf8') +// Read the optional config file once, tolerating missing/corrupt files. +function loadConfig() { + if (!fs.existsSync(CONFIG_PATH)) return {}; + try { + const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed; + console.warn(`[LLMBot] ${CONFIG_PATH} must contain a JSON object; ignoring it.`); + } catch (err) { + console.warn(`[LLMBot] Could not parse ${CONFIG_PATH}: ${err.message}`); + } + return {}; +} + +function readLines(file) { + try { + return fs + .readFileSync(file, 'utf8') .split('\n') - .map(personality => personality.trim()) - .filter(personality => personality.length > 0); - - if (personalities.length === 0) { - return 'You are a friendly bot'; // Fallback if no personalities are found + .map((line) => line.trim()) + .filter((line) => line.length > 0); + } catch (err) { + console.warn(`[LLMBot] Could not read ${file}: ${err.message}`); + return []; } - const randomIndex = Math.floor(Math.random() * personalities.length); - return personalities[randomIndex]; } +function resolvePersonalitiesFile(configured) { + const requested = configured || process.env.YARN_BOT_PERSONALITIES; + if (!requested) return PERSONALITY_PATH; + return path.isAbsolute(requested) ? requested : path.join(__dirname, requested); +} + +function readPersonalities(file = PERSONALITY_PATH) { + return readLines(file); +} + +function generateBotPersonality(file = PERSONALITY_PATH) { + const personalities = readPersonalities(file); + if (!personalities.length) return FALLBACK_PERSONALITY; + return personalities[Math.floor(Math.random() * personalities.length)]; +} + +function firstContent(response) { + const content = response?.choices?.[0]?.message?.content; + return typeof content === 'string' ? content.trim() : ''; +} + +/** + * LLM-backed bot. The constructor throws when the client cannot be + * configured (for example, no API key); BotFactory catches that and + * falls back to RandomBot so a misconfigured server keeps working. + */ class LLMBot { - constructor() { - // Instantiate the OpenAI client with the config options we just read - this.llm = new OpenAI(llmOptions); + constructor(config) { + const configPathExists = fs.existsSync(CONFIG_PATH); + if (configPathExists) console.log(`[LLMBot] Read config from ${CONFIG_PATH}`); - // Prompt fragments we’ll reuse - this.writePrompt = - `You are playing a collaborative storytelling game. -Continue the following story with exactly ONE short, creative sentence or sentence fragment. -Do NOT repeat what’s already written.`; + this.options = config && typeof config === 'object' ? config : loadConfig(); + this.model = this.options.model || process.env.OPENAI_MODEL || DEFAULT_MODEL; - this.votePrompt = - `You are playing a collaborative storytelling game. -Below is the story so far, followed by a list of possible next sentences. -Reply with ONLY the number (0-indexed) of the sentence you like best.`; + // Throws if OPENAI_API_KEY (or an apiKey in llm_config.json) is missing. + this.llm = new OpenAI(this.options); - this.banterPrompt = - `You are a playful, slightly sarcastic AI taking part in a writing-game chat. -Keep it short, fun, and on-topic.`; + this.writePrompt = [ + 'You are playing a collaborative storytelling game.', + 'Continue the following story with exactly ONE short, creative sentence or sentence fragment.', + 'Do NOT repeat what is already written.', + 'Reply with the sentence only, no quotes, no preamble.' + ].join('\n'); - this.limitPrompt = "Please do not exceed 25 words." + this.votePrompt = [ + 'You are playing a collaborative storytelling game.', + 'Below is the story so far, followed by a list of possible next sentences.', + 'Reply with ONLY the number of the sentence you like best. No other text.' + ].join('\n'); - this.personality = generateBotPersonality(); + this.banterPrompt = [ + 'You are a playful, slightly sarcastic AI taking part in a writing-game chat.', + 'Keep it short, fun, and on-topic (one sentence, under 20 words).' + ].join('\n'); + + this.limitPrompt = 'Please do not exceed 25 words.'; + + // Defaults to bot_personalities_dumb.txt; override with `personalitiesFile` + // in the config or the YARN_BOT_PERSONALITIES environment variable. + this.personalitiesFile = resolvePersonalitiesFile(this.options.personalitiesFile); + this.personality = generateBotPersonality(this.personalitiesFile); } - async Write(story_so_far) { - const msg = `${this.personality}. ${this.writePrompt} ${this.limitPrompt}\n\nStory so far:\n${story_so_far}`; + async complete(messages, { temperature = 0.8, maxTokens = 80 } = {}) { const response = await this.llm.chat.completions.create({ - model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo', - messages: [{ role: 'user', content: msg }], - temperature: llmOptions.temperature ?? 0.8, - max_tokens: llmOptions.max_tokens ?? 80, + model: this.model, + messages, + temperature, + max_tokens: maxTokens }); - //console.log(`Received response from LLM: ${response.choices[0].message.content}`); - return response.choices[0].message.content.trim(); + return firstContent(response); } - async Vote(story_so_far, choices) { - const choicesBlock = choices.map((c, i) => `${i}: ${(c.entry_text)}`).join('\n'); - const msg = `${this.personality}. ${this.votePrompt}\n\nStory:\n${story_so_far}\n\nChoices:\n${choicesBlock}`; - //console.log(msg); - const response = await this.llm.chat.completions.create({ - model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo', - messages: [{ role: 'user', content: msg }], + async Write(storySoFar) { + const msg = `${this.personality}.\n${this.writePrompt}\n${this.limitPrompt}\n\nStory so far:\n${storySoFar || '(the story has not started yet)'}`; + const text = await this.complete([{ role: 'user', content: msg }], { + temperature: this.options.temperature ?? 0.8, + maxTokens: this.options.max_tokens ?? 80 + }); + // Strip wrapping quotes and stray newlines so entries read naturally. + return text.replace(/^["'\s]+|["'\s]+$/g, ''); + } + + async Vote(storySoFar, choices) { + const list = Array.isArray(choices) ? choices : []; + if (!list.length) return null; + + const choicesBlock = list + .map((choice, i) => `${choice.index ?? i}: ${choice.entry_text}`) + .join('\n'); + const msg = `${this.personality}.\n${this.votePrompt}\n\nStory:\n${storySoFar}\n\nChoices:\n${choicesBlock}`; + + const reply = await this.complete([{ role: 'user', content: msg }], { temperature: 0.2, - max_tokens: 5, + maxTokens: 8 }); - const reply = response.choices[0].message.content.trim(); const match = reply.match(/\d+/); - return match ? parseInt(match[0], 10) : 0; + if (!match) return null; + const parsed = Number.parseInt(match[0], 10); + return Number.isInteger(parsed) ? parsed : null; } - async Banter(chat_so_far) { - const msg = `${this.banterPrompt}\n\nChat so far:\n${chat_so_far}`; - const response = await this.llm.chat.completions.create({ - model: llmOptions.model || 'meta-llama/Llama-3.2-3B-Instruct-Turbo', - messages: [{ role: 'user', content: msg }], - temperature: llmOptions.temperature ?? 0.8, - max_tokens: llmOptions.max_tokens ?? 60, + async Banter(chatSoFar) { + const msg = `${this.banterPrompt}\n\nRecent chat:\n${chatSoFar || '(nobody has spoken yet)'}`; + return this.complete([{ role: 'user', content: msg }], { + temperature: this.options.temperature ?? 0.8, + maxTokens: this.options.max_tokens ?? 60 }); - return response.choices[0].message.content.trim(); } } -module.exports = LLMBot; \ No newline at end of file +LLMBot.DEFAULT_MODEL = DEFAULT_MODEL; +LLMBot.CONFIG_PATH = CONFIG_PATH; +LLMBot.PERSONALITY_PATH = PERSONALITY_PATH; +LLMBot.readPersonalities = readPersonalities; +LLMBot.generateBotPersonality = generateBotPersonality; + +module.exports = LLMBot; diff --git a/bots/RandomBot.js b/bots/RandomBot.js index 955d5df..1542622 100644 --- a/bots/RandomBot.js +++ b/bots/RandomBot.js @@ -1,19 +1,15 @@ +'use strict'; + class RandomBot { constructor() { this.writePhrases = [ - " Suddenly, a mysterious figure appeared from the shadows.", - " The ground began to shake violently beneath their feet.", - " In the distance, an eerie sound echoed through the valley.", - " A bright light flashed, blinding everyone momentarily.", - " The ancient artifact started glowing with an otherworldly energy." - ]; - - this.votePhrases = [ - "I think we should proceed with caution.", - "Let's take the bold approach!", - "The safe path seems best right now.", - "We should explore further before deciding.", - "My instincts tell me to go left." + 'Suddenly, a mysterious figure appeared from the shadows.', + 'The ground began to shake violently beneath their feet.', + 'In the distance, an eerie sound echoed through the valley.', + 'A bright light flashed, blinding everyone momentarily.', + 'The ancient artifact started glowing with an otherworldly energy.', + 'A gust of wind carried whispers of a forgotten warning.', + 'Somewhere nearby, a door creaked open on its own.' ]; this.banterPhrases = [ @@ -24,27 +20,34 @@ class RandomBot { "Remember when we used to play simple games?", "What's the worst that could happen?", "I wonder if there are cookies in this game.", - "Do bots dream of electric sheep?", + 'Do bots dream of electric sheep?', "Is this thing on?", "I'm having a great time, how about you?" ]; } - Write(story_so_far) { + Write() { const randomIndex = Math.floor(Math.random() * this.writePhrases.length); return this.writePhrases[randomIndex]; } - Vote(story_so_far, choices) { - // Return a random choice number (0-indexed) - const randomChoice = Math.floor(Math.random() * choices.length); - return randomChoice; + /** + * Vote for one of the supplied choices. Choices are objects of the form + * { index, entry_text } where `index` is the real entry index; return + * that index (never the position in the filtered list). + */ + Vote(storySoFar, choices) { + const list = Array.isArray(choices) ? choices : []; + if (!list.length) return null; + const pick = list[Math.floor(Math.random() * list.length)]; + if (pick && Number.isInteger(pick.index)) return pick.index; + return Number.isInteger(pick) ? pick : 0; } - Banter(chat_so_far) { + Banter() { const randomIndex = Math.floor(Math.random() * this.banterPhrases.length); return this.banterPhrases[randomIndex]; } } -module.exports = RandomBot; \ No newline at end of file +module.exports = RandomBot; diff --git a/bots/llm_config.example.json b/bots/llm_config.example.json new file mode 100644 index 0000000..3e536ad --- /dev/null +++ b/bots/llm_config.example.json @@ -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 +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a4df6c1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1695 @@ +{ + "name": "yarn", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yarn", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "express": "^5.2.1", + "openai": "^6.25.0", + "socket.io": "^4.8.3" + }, + "devDependencies": { + "jsdom": "^30.1.3", + "socket.io-client": "^4.8.4" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.9.tgz", + "integrity": "sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.3", + "@csstools/css-color-parser": "^4.2.6", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.5.tgz", + "integrity": "sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.7.tgz", + "integrity": "sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz", + "integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "26.6.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/body-parser": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.1.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.3", + "on-finished": "^2.4.1", + "qs": "^6.16.1", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.2" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz", + "integrity": "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/engine.io": { + "version": "6.6.11", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.11.tgz", + "integrity": "sha512-z34mGpLoY/82ZbkX1LqGMIaf3wjVMPL6gVC681QDqnmzJ26synI8qAE32P+a7UazmpJZ8UGrFIeAavgRfXzYrg==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-client": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.7.tgz", + "integrity": "sha512-GyAvmPA7gnRCpDQbYACAy79Lg4ewevrmaPzySZz0OVvgyZc3gEfLLqPTYk8wVBScjOEpM9ZO4KryyFpVItjGEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz", + "integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^10.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", + "css-tree": "^3.2.1", + "data-urls": "^8.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.12.1", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.2.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz", + "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "6.49.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.49.0.tgz", + "integrity": "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.17.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz", + "integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/socket.io": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.4.tgz", + "integrity": "sha512-9ZV9zg8vN8hO3Y25DhR7nUkJ1VDnBhq5O/JrXhFT9s6+F0gqytSASTHVfCFc2mJXaTfARroErplXlzFWwETZqQ==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-client": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.4.tgz", + "integrity": "sha512-7wjKAvVXYOJsbmUgkWbnhPD4bFpkqrUR0++XOjpGFmqDqsqV1CI93VTraeExLxpjxEqrWgFoSRZu1ihjLWfkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/tldts": { + "version": "7.4.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz", + "integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.20", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz", + "integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/undici": { + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz", + "integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + } + } +} diff --git a/package.json b/package.json index 586dc91..43c087f 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,22 @@ { "name": "yarn", "version": "1.0.0", - "description": "", + "description": "Browser remake of the Y.A.R.N. collaborative storytelling game, with optional LLM bots.", "main": "server.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "node --test tests/*.test.js", "start": "node server.js" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { - "@themaximalist/llm.js": "^1.0.3", - "cookie-parser": "^1.4.7", "express": "^5.2.1", - "node-fetch": "^3.3.2", "openai": "^6.25.0", - "socket.io": "^4.8.3", - "uuid": "^13.0.0" + "socket.io": "^4.8.3" + }, + "devDependencies": { + "jsdom": "^30.1.3", + "socket.io-client": "^4.8.4" } } diff --git a/public/game.html b/public/game.html index cf0364e..6bb0948 100644 --- a/public/game.html +++ b/public/game.html @@ -45,12 +45,39 @@ display: flex; align-items: center; justify-content: center; + gap: .4rem; border-bottom: 2px solid var(--accent-purple); letter-spacing: 1px; + padding: 0 1rem; } + #header img { height: 70%; } + + #room-meta { + font-size: .8rem; + font-weight: normal; + letter-spacing: 0; + color: #d9c7ff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + #header button { + font-size: .9rem; + padding: .3rem .6rem; + border: none; + border-radius: 4px; + background: var(--accent-purple); + color: #fff; + font-weight: bold; + cursor: pointer; + } + + #header button:hover { background: #8a1dbd; } + #main-text { - flex: 0 0 50%; + flex: 0 0 46%; background: var(--box-bg); margin: 1.2rem 2rem 0 2rem; border-radius: 8px; @@ -67,22 +94,27 @@ display: inline; } - .story-segment[data-player]::before { + .story-segment[data-player]:hover::before { content: attr(data-player) ": "; + font-family: system-ui, sans-serif; + font-size: .7em; font-weight: bold; - color: var(--accent-purple); - display: none; + color: #b98cff; + vertical-align: super; } + .story-segment.narrator { font-style: italic; color: #cbb8e8; } + #game-bar { - flex: 0 0 10%; + flex: 0 0 12%; background: var(--bg-main); padding: 0.8rem 2rem; display: flex; align-items: center; justify-content: center; - gap: 1.4rem; + gap: 1rem; flex-wrap: wrap; + overflow-y: auto; } #game-bar > * { @@ -113,15 +145,21 @@ transition: background 0.2s; } - #game-bar button:hover { - background: #8a1dbd; - } + #game-bar button:hover { background: #8a1dbd; } #game-bar button:disabled { background: #444; cursor: not-allowed; } + #game-bar button.secondary { + background: #333; + font-size: .85rem; + padding: .4rem .8rem; + } + + #game-bar button.secondary:hover { background: #555; } + .setting-group { display: flex; align-items: center; @@ -132,8 +170,15 @@ font-size: 0.9rem; } - .setting-group input[type="number"] { - width: 80px; + .setting-group input[type="number"] { width: 80px; } + + .setting-group input#start-text { width: 240px; } + + .settings-summary { + font-size: .95rem; + font-weight: normal; + color: #e6d9ff; + text-align: center; } #timer { @@ -179,14 +224,16 @@ border: 1px solid var(--accent-purple); } + .vote-entry.own { border-color: #555; opacity: .75; } + .vote-entry-text { flex: 1; font-size: 0.95rem; } .vote-btn { - background: var(--player-green); - color: #000; + background: var(--player-green) !important; + color: #000 !important; border: none; padding: 0.4rem 1rem; border-radius: 4px; @@ -195,11 +242,40 @@ } .vote-btn:disabled { - background: #444; - color: #888; + background: #444 !important; + color: #888 !important; cursor: not-allowed; } + #results-table { + width: 100%; + max-width: 860px; + border-collapse: collapse; + font-size: .95rem; + font-weight: normal; + } + + #results-table th, + #results-table td { + padding: .35rem .6rem; + text-align: left; + border-bottom: 1px solid #2a2a2a; + } + + #results-table th { color: #b98cff; font-size: .8rem; text-transform: uppercase; } + + #results-table tr.winner td { color: var(--player-green); font-weight: bold; } + + #results-table td.pts { color: var(--player-yellow); white-space: nowrap; } + + .results-heading { + color: var(--player-green); + font-size: 1.2rem; + margin-bottom: .4rem; + } + + .results-note { color: #aaa; font-size: .9rem; font-weight: normal; } + #chat-area { flex: 1; display: flex; @@ -209,14 +285,16 @@ overflow: hidden; color: var(--chat-text); border: 2px solid var(--accent-purple); + min-height: 0; } #players { - width: 260px; + width: 280px; background: #111; padding: 1rem 0.8rem; overflow-y: auto; border-right: 1px solid #333; + flex-shrink: 0; } #players-header { @@ -229,21 +307,17 @@ .player-row { display: flex; - justify-content: space-between; - padding: 0.15rem 1rem; + align-items: center; + gap: .35rem; + padding: 0.15rem 0.4rem 0.15rem 1rem; font-family: Arial, Helvetica, sans-serif; font-size: 1.05rem; line-height: 1.2; transition: color 0.3s; } - .player-row.submitted { - color: var(--player-green); - } - - .player-row.pending { - color: var(--player-yellow); - } + .player-row.submitted { color: var(--player-green); } + .player-row.pending { color: var(--player-yellow); } .player-row.host::after { content: " ★"; @@ -260,14 +334,27 @@ .player-score { text-align: right; - min-width: 70px; + min-width: 60px; padding-left: 1rem; } + .kick-btn { + background: none; + border: none; + color: #ff6666; + cursor: pointer; + font-size: .85rem; + padding: 0 .2rem; + opacity: .5; + } + + .kick-btn:hover { opacity: 1; } + #chat-and-input { flex: 1; display: flex; flex-direction: column; + min-width: 0; } #chat-messages { @@ -276,15 +363,11 @@ overflow-y: auto; font-size: 1.1rem; line-height: 1.4; + min-height: 0; } - .chat-message { - margin-bottom: 0.3rem; - } - - .chat-message strong { - color: var(--player-green); - } + .chat-message { margin-bottom: 0.3rem; overflow-wrap: anywhere; } + .chat-message strong { color: var(--player-green); } .system-message { color: #888; @@ -303,28 +386,31 @@ font-family: Arial, Helvetica, sans-serif; } - #chat-input:focus { - background: #222; + #chat-input:focus { background: #222; } + + .waiting-message { color: #aaa; font-style: italic; } + .game-over-message { font-size: 1.5rem; color: gold; text-align: center; } + + .results-display { text-align: center; } + .round-winner { color: var(--player-green); } + + #toast { + position: fixed; + top: 1rem; + left: 50%; + transform: translateX(-50%); + background: #2a0d3f; + border: 2px solid #ff6666; + color: #ffe4e4; + padding: .7rem 1.2rem; + border-radius: 6px; + font-weight: bold; + z-index: 50; + max-width: 80vw; + transition: opacity .3s; } - .waiting-message { - color: #aaa; - font-style: italic; - } - - .game-over-message { - font-size: 1.5rem; - color: gold; - text-align: center; - } - - .results-display { - text-align: center; - } - - .round-winner { - color: var(--player-green); - } + .hidden { display: none !important; } @@ -332,9 +418,11 @@
@@ -366,71 +454,79 @@
+ + - \ No newline at end of file + diff --git a/server.js b/server.js index 0c21469..28cacf8 100644 --- a/server.js +++ b/server.js @@ -1,27 +1,141 @@ +'use strict'; + const express = require('express'); const http = require('http'); const { Server } = require('socket.io'); const path = require('path'); const fs = require('fs'); -const cookieParser = require('cookie-parser'); -//const { v4: uuidv4 } = require('uuid'); const { randomUUID } = require('node:crypto'); const BotFactory = require('./bots/BotFactory'); -const app = express(); -const server = http.createServer(app); -const io = new Server(server); +// --------------------------------------------------------------------------- +// Limits & small validation helpers +// --------------------------------------------------------------------------- -app.use(cookieParser()); -app.use(express.static(path.join(__dirname, 'public'))); +const LIMITS = { + roomName: 30, + playerName: 20, + entry: 280, + chat: 300, + startText: 300, + maxRooms: 200, + maxPlayersPerRoom: 16, + maxDisconnectedPlayers: 64, + settings: { + roundLimit: [0, 100], + scoreLimit: [0, 9999], + timeLimit: [15, 600], + botCount: [0, 8] + } +}; -// Store all game rooms -const games = {}; +const ROOM_NAME_RE = /^[a-z0-9_-]+$/; +const PLAYER_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; +const DEFAULT_START_TEXT = 'It was a dark and stormy night...'; + +function sanitizeRoomName(raw) { + if (typeof raw !== 'string') return null; + const name = raw.trim().toLowerCase(); + if (!name || name.length > LIMITS.roomName) return null; + if (!ROOM_NAME_RE.test(name)) return null; + return name; +} + +function sanitizePlayerId(raw) { + if (typeof raw !== 'string') return null; + const id = raw.trim(); + return PLAYER_ID_RE.test(id) ? id : null; +} + +function shuffle(items) { + const shuffled = [...items]; + for (let i = shuffled.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; +} + +function clampInt(value, min, max, fallback) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(max, Math.max(min, parsed)); +} + +// Collapse whitespace, trim, cap length. Returns null for empty input. +function cleanText(value, maxLength) { + if (typeof value !== 'string') return null; + const text = value.replace(/\s+/g, ' ').trim(); + if (!text) return null; + return text.slice(0, maxLength); +} + +function normalizeSettings(current, raw) { + const input = raw && typeof raw === 'object' ? raw : {}; + const next = { ...current }; + + if ('scoreLimit' in input) { + next.scoreLimit = clampInt( + input.scoreLimit, + LIMITS.settings.scoreLimit[0], + LIMITS.settings.scoreLimit[1], + current.scoreLimit + ); + } + if ('roundLimit' in input) { + next.roundLimit = clampInt( + input.roundLimit, + LIMITS.settings.roundLimit[0], + LIMITS.settings.roundLimit[1], + current.roundLimit + ); + } + if ('timeLimit' in input) { + next.timeLimit = clampInt( + input.timeLimit, + LIMITS.settings.timeLimit[0], + LIMITS.settings.timeLimit[1], + current.timeLimit + ); + } + if ('botCount' in input) { + next.botCount = clampInt( + input.botCount, + LIMITS.settings.botCount[0], + LIMITS.settings.botCount[1], + current.botCount + ); + } + if ('startText' in input) { + next.startText = cleanText(input.startText, LIMITS.startText) || current.startText; + } + return next; +} + +const storiesDir = process.env.YARN_STORIES_DIR + ? path.resolve(process.env.YARN_STORIES_DIR) + : path.join(__dirname, 'stories'); + +const defaultTimers = { + setInterval: (fn, ms) => setInterval(fn, ms), + clearInterval: (handle) => clearInterval(handle), + setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: (handle) => clearTimeout(handle) +}; + +// --------------------------------------------------------------------------- +// YarnGame +// --------------------------------------------------------------------------- -// YarnGame class class YarnGame { - constructor(roomName) { + constructor(roomName, options = {}) { this.roomName = roomName; + this.broadcast = options.broadcast || (() => {}); + this.botFactory = options.botFactory || BotFactory; + this.timers = options.timers || defaultTimers; + this.storiesDir = options.storiesDir || storiesDir; + this.botType = options.botType || 'llm'; + this.inProgress = false; this.gameHost = null; this.gameSettings = { @@ -29,318 +143,796 @@ class YarnGame { roundLimit: 15, timeLimit: 60, botCount: 0, - startText: "It was a dark and stormy night..." + startText: DEFAULT_START_TEXT }; - this.bots = []; - this.players = []; // { player_id, name, score, socketId } - this.disconnectedPlayers = []; // So people can keep their score when they reconnect - this.yarnStory = []; // { player: "", str: "" } + + this.bots = []; // { bot_id, bot } + this.players = []; // { player_id, name, score, socketId, isBot } + this.disconnectedPlayers = []; + this.yarnStory = []; // { player, str } this.chat = []; // { id, name, msg } this.round_data = []; // { entry_text, player_id, votes: [] } - this.roundCount = 0; // current round - this.currentPhase = 'lobby'; // lobby, writing, voting, gameOver + + this.roundCount = 0; // completed rounds + this.currentPhase = 'lobby'; // lobby | writing | voting | results | gameOver this.timer = null; + this.banterTimer = null; + this.nextRoundTimer = null; this.timeRemaining = 0; + this.submittedPlayers = new Set(); this.votedPlayers = new Set(); + this.lastResults = null; + this.gameWinner = null; + this.moralAppended = false; + this.endingVotes = false; + this.transitioning = false; + this.votingOrder = []; } - addPlayer(playerId, name, socketId) { - const existingPlayer = this.players.find(p => p.player_id === playerId); - if (existingPlayer) { - existingPlayer.socketId = socketId; - existingPlayer.name = name; - console.log(`Existing player ${name} reconnected to game ${this.roomName}.`) - return existingPlayer; - } else { - console.log(`New player ${name} joined game ${this.roomName}.`) + emit(event, payload) { + try { + this.broadcast(event, payload); + } catch (err) { + console.error(`[${this.roomName}] broadcast of "${event}" failed:`, err); + } + } + + // --- players ------------------------------------------------------------- + + addPlayer(playerId, rawName, socketId) { + if (!playerId) return null; + const name = cleanText(rawName, LIMITS.playerName); + if (!name) return null; + + const existing = this.players.find((p) => p.player_id === playerId); + if (existing) { + existing.socketId = socketId; + existing.name = name; + return existing; } - const existingDisconnectedPlayer = this.disconnectedPlayers.find(p => p.player_id === playerId); - if (existingDisconnectedPlayer) { - existingDisconnectedPlayer.socketId = socketId; - existingDisconnectedPlayer.name = name; - this.players.push(existingDisconnectedPlayer); - console.log(`Existing recently-disconnected player ${name} reconnected to game ${this.roomName}.`) - return existingDisconnectedPlayer; + const reconnectIndex = this.disconnectedPlayers.findIndex( + (p) => p.player_id === playerId + ); + if (reconnectIndex !== -1) { + const [reconnected] = this.disconnectedPlayers.splice(reconnectIndex, 1); + reconnected.socketId = socketId; + reconnected.name = name; + this.players.push(reconnected); + console.log(`[${this.roomName}] ${name} reconnected.`); + return reconnected; } - + + if (this.players.length >= LIMITS.maxPlayersPerRoom) return null; + const player = { player_id: playerId, - name: name, + name, score: 0, - socketId: socketId + socketId, + isBot: false }; this.players.push(player); - - // First player becomes host - if (this.players.length === 1) { - this.gameHost = playerId; - } - + if (!this.gameHost) this.gameHost = playerId; + console.log(`[${this.roomName}] ${name} joined.`); return player; } - removePlayer(playerId) { - const index = this.players.findIndex(p => p.player_id === playerId); - if (index !== -1) { - console.log(`Player ${this.players[index].name} dropped.`) - const removed = this.players.splice(index, 1); - this.disconnectedPlayers.push(removed[0]); - - // Assign new host if host left - if (this.gameHost === playerId && this.getHumanPlayers().length > 0) { - const human_players = this.getHumanPlayers(); - this.gameHost = human_players[0].player_id; + removePlayer(playerId, { disconnected = true } = {}) { + const index = this.players.findIndex((p) => p.player_id === playerId); + if (index === -1) return this.players.length; + + const [removed] = this.players.splice(index, 1); + console.log(`[${this.roomName}] ${removed.name} left.`); + + if (disconnected && !removed.isBot) { + this.disconnectedPlayers = this.disconnectedPlayers.filter( + (p) => p.player_id !== playerId + ); + this.disconnectedPlayers.push({ ...removed, socketId: null }); + if (this.disconnectedPlayers.length > LIMITS.maxDisconnectedPlayers) { + this.disconnectedPlayers.shift(); } } + + if (this.gameHost === playerId) { + const humans = this.getHumanPlayers(); + this.gameHost = humans.length ? humans[0].player_id : null; + } return this.players.length; } getHumanPlayers() { - return this.players.filter(p => !this.bots.some(b => b.bot_id === p.player_id)); + return this.players.filter((p) => !p.isBot); } getPlayer(playerId) { - return this.players.find(p => p.player_id === playerId); + return this.players.find((p) => p.player_id === playerId); } getPlayerBySocket(socketId) { - return this.players.find(p => p.socketId === socketId); + return this.players.find((p) => p.socketId === socketId); } updatePlayerName(playerId, name) { + const clean = cleanText(name, LIMITS.playerName); const player = this.getPlayer(playerId); - if (player) { - player.name = name; + if (player && clean) player.name = clean; + return Boolean(player && clean); + } + + // --- bots ---------------------------------------------------------------- + + createBotInstance() { + try { + return this.botFactory.createBot(this.botType); + } catch (err) { + console.warn( + `[${this.roomName}] Bot creation failed (${err.message}); using RandomBot.` + ); + return this.botFactory.createBot('random'); } } startBots(botCount) { - console.log("Creating bots...") - for (let i = 0; i < botCount; i++) { - const bot_id = randomUUID() - const bot = BotFactory.createBot("llm"); + const count = clampInt( + botCount, + LIMITS.settings.botCount[0], + LIMITS.settings.botCount[1], + 0 + ); + const usedNames = this.players.map((p) => p.name); + + for (let i = 0; i < count; i += 1) { + const bot_id = randomUUID(); + const bot = this.createBotInstance(); + const name = this.botFactory.generateBotName(usedNames); + usedNames.push(name); + this.bots.push({ bot_id, bot }); - const bot_player = { - player_id: bot_id, - name: BotFactory.generateBotName(), - score: 0, - socketId: null - }; - this.players.push(bot_player); - console.log(`Created bot with UUID ${bot_id}`) + this.players.push({ + player_id: bot_id, + name, + score: 0, + socketId: null, + isBot: true + }); + console.log(`[${this.roomName}] Created bot ${name} (${bot_id})`); } } - async startGame(settings) { - this.gameSettings = { ...this.gameSettings, ...settings }; - this.roundCount = 0; - this.startBots(this.gameSettings.botCount); - this.inProgress = true; - this.yarnStory = [{ player: "Narrator", str: this.gameSettings.startText }]; - this.players.forEach(p => p.score = 0); - await this.startWritingPhase(); + withBotTimeout(promise, ms = 30000) { + let handle = null; + const timeout = new Promise((resolve, reject) => { + handle = this.timers.setTimeout( + () => reject(new Error('bot request timed out')), + ms + ); + if (handle && typeof handle.unref === 'function') handle.unref(); + }); + return Promise.race([Promise.resolve(promise), timeout]).finally(() => { + if (handle && typeof this.timers.clearTimeout === 'function') { + this.timers.clearTimeout(handle); + } + }); + } + + async safeBotWrite(botObj, storySoFar) { + try { + const text = await this.withBotTimeout(botObj.bot.Write(storySoFar)); + const clean = cleanText(text, LIMITS.entry); + if (clean) return clean; + } catch (err) { + console.warn( + `[${this.roomName}] Bot ${botObj.bot_id} failed to write:`, + err?.message || err + ); + } + + // Degrade gracefully: try the fallback bot, then submit an empty entry. + try { + const fallback = this.botFactory.createBot('random'); + return cleanText(fallback.Write(storySoFar), LIMITS.entry) || ''; + } catch (err) { + return ''; + } } async processBotWriting() { - for (const botObj of this.bots) { - try { - const botText = await botObj.bot.Write(this.getStoryText()); - console.log(`Bot text: ${botText}`); - this.submitEntry(botObj.bot_id, botText); - } catch (err) { - console.warn(`Bot ${botObj.bot_id} failed to write:`, err.message || err); - } + const bots = [...this.bots]; + if (!bots.length || this.currentPhase !== 'writing') return; + + const storySoFar = this.getStoryText(); + + await Promise.allSettled( + bots.map(async (botObj) => { + const text = await this.safeBotWrite(botObj, storySoFar); + if (this.currentPhase !== 'writing') return; + this.submitEntry(botObj.bot_id, text, { forced: !text }); + this.checkSubmissionsComplete(); + }) + ); + + this.checkSubmissionsComplete(); + } + + checkSubmissionsComplete() { + if (this.currentPhase === 'writing' && this.allPlayersSubmitted()) { + this.beginVotingPhase(); } } - async startWritingPhase() { - this.currentPhase = 'writing'; - this.round_data = []; - this.submittedPlayers = new Set(); - this.timeRemaining = this.gameSettings.timeLimit; - this.processBotWriting() - } - - getStoryText() { - return this.yarnStory.map(entry => entry.str).join(''); - } - - submitEntry(playerId, entryText) { - if (this.currentPhase !== 'writing') return false; - if (this.submittedPlayers.has(playerId)) return false; - - this.round_data.push({ - entry_text: entryText.trim(), - player_id: playerId, - votes: [] - }); - this.submittedPlayers.add(playerId); - - return true; - } - - allPlayersSubmitted() { - return this.submittedPlayers.size >= this.players.length; - } - async processBotVoting() { - for (const botObj of this.bots) { - try { - const botVote = await botObj.bot.Vote(this.getStoryText(), this.round_data); - this.submitVote(botObj.bot_id, botVote); - } catch (err) { - // One bot failing to vote is not critical; keep going - console.warn(`Bot ${botObj.bot_id} failed to vote:`, err.message || err); - } + const bots = [...this.bots]; + if (!bots.length || this.currentPhase !== 'voting') return; + + const storySoFar = this.getStoryText(); + + // Build per-bot choice lists (bots never vote for their own entry). + await Promise.allSettled( + bots.map(async (botObj) => { + const options = this.round_data + .map((entry, index) => ({ + index, + entry_text: entry.entry_text, + player_id: entry.player_id + })) + .filter( + (choice) => + choice.entry_text.trim().length > 0 && + choice.player_id !== botObj.bot_id + ); + if (!options.length) return; + + let choice = null; + try { + choice = await this.withBotTimeout( + botObj.bot.Vote(storySoFar, options) + ); + } catch (err) { + console.warn( + `[${this.roomName}] Bot ${botObj.bot_id} failed to vote:`, + err?.message || err + ); + } + + let index = Number.parseInt(choice, 10); + if (!Number.isInteger(index) || !options.some((o) => o.index === index)) { + index = options[Math.floor(Math.random() * options.length)].index; + } + + this.submitVote(botObj.bot_id, index); + this.checkVotesComplete(); + }) + ); + + this.checkVotesComplete(); + } + + checkVotesComplete() { + if (this.currentPhase === 'voting' && this.allPlayersVoted()) { + this.endVotingPhase(); } } - async startVotingPhase() { - this.currentPhase = 'voting'; - this.votedPlayers = new Set(); - const entriesCount = this.round_data.length - this.timeRemaining = 10 * entriesCount; // 10 seconds * num of entries for voting - this.processBotVoting() + async botBanter() { + if (!this.inProgress || !this.bots.length) return; + + const chatSoFar = + this.chat + .slice(-8) + .map((message) => `${message.name}: ${message.msg}`) + .join('\n') || '(nobody has spoken yet)'; + + const talkers = this.bots.filter(() => Math.random() < 0.35); + if (!talkers.length) return; + + await Promise.allSettled( + talkers.map(async (botObj) => { + try { + const reply = await this.withBotTimeout(botObj.bot.Banter(chatSoFar)); + const text = cleanText(reply, LIMITS.chat); + if (!text) return; + const chatMsg = this.addChatMessage(botObj.bot_id, text); + this.emit('newChatMessage', chatMsg); + } catch (err) { + console.warn( + `[${this.roomName}] Bot ${botObj.bot_id} failed to banter:`, + err?.message || err + ); + } + }) + ); } - submitVote(playerId, entryIndex) { - if (this.currentPhase !== 'voting') return false; - if (this.votedPlayers.has(playerId)) return false; - - - const entry = this.round_data[entryIndex]; - // Can't vote for own entry - //if (!entry || entry.player_id === playerId) return false; - - entry.votes.push(playerId); - this.votedPlayers.add(playerId); - + startBanterTimer() { + this.stopBanterTimer(); + if (!this.bots.length) return; + this.banterTimer = this.timers.setInterval(() => { + this.botBanter(); + }, 20000); + } + + stopBanterTimer() { + if (this.banterTimer) { + this.timers.clearInterval(this.banterTimer); + this.banterTimer = null; + } + } + + // --- game lifecycle ------------------------------------------------------ + + async startGame(settings = {}) { + if (this.inProgress) return false; + + this.gameSettings = normalizeSettings(this.gameSettings, settings); + this.roundCount = 0; + this.moralAppended = false; + this.gameWinner = null; + this.lastResults = null; + this.endingVotes = false; + this.transitioning = false; + this.round_data = []; + this.submittedPlayers = new Set(); + this.votedPlayers = new Set(); + + this.startBots(this.gameSettings.botCount); + this.inProgress = true; + this.yarnStory = [ + { player: 'Narrator', str: this.gameSettings.startText || DEFAULT_START_TEXT } + ]; + this.players.forEach((player) => { + player.score = 0; + }); + + this.startBanterTimer(); + this.startWritingPhase(); return true; } + startWritingPhase() { + if (!this.inProgress) return; + + this.currentPhase = 'writing'; + this.endingVotes = false; + this.votingOrder = []; + this.round_data = []; + this.submittedPlayers = new Set(); + this.votedPlayers = new Set(); + this.timeRemaining = this.gameSettings.timeLimit; + + this.emit('newRound', this.getState()); + this.startWritingTimer(); + this.processBotWriting(); + } + + startWritingTimer() { + this.stopTimer(); + this.timer = this.timers.setInterval(() => this.tick(), 1000); + } + + startVotingTimer() { + this.stopTimer(); + this.timer = this.timers.setInterval(() => this.tick(), 1000); + } + + stopTimer() { + if (this.timer) { + this.timers.clearInterval(this.timer); + this.timer = null; + } + } + + tick() { + if (this.currentPhase !== 'writing' && this.currentPhase !== 'voting') { + this.stopTimer(); + return; + } + + this.timeRemaining = Math.max(0, this.timeRemaining - 1); + this.emit('timerUpdate', this.timeRemaining); + + if (this.timeRemaining > 0) return; + + this.stopTimer(); + if (this.currentPhase === 'writing') { + this.beginVotingPhase({ force: true }); + } else if (this.currentPhase === 'voting') { + this.endVotingPhase(); + } + } + + getStoryText() { + return this.yarnStory + .map((entry) => (typeof entry.str === 'string' ? entry.str.trim() : '')) + .filter(Boolean) + .join(' '); + } + + submitEntry(playerId, entryText, { forced = false } = {}) { + if (this.currentPhase !== 'writing') { + return { ok: false, error: 'Submissions are closed right now.' }; + } + if (this.submittedPlayers.has(playerId)) { + return { ok: false, error: 'You already submitted an entry this round.' }; + } + if (!this.getPlayer(playerId)) { + return { ok: false, error: 'Unknown player.' }; + } + + let text = ''; + if (!forced) { + const clean = cleanText(entryText, LIMITS.entry); + if (!clean) return { ok: false, error: 'Your entry cannot be empty.' }; + text = clean; + } + + this.round_data.push({ entry_text: text, player_id: playerId, votes: [] }); + this.submittedPlayers.add(playerId); + this.emit('playersUpdate', this.getState().players); + return { ok: true }; + } + + allPlayersSubmitted() { + return ( + this.players.length > 0 && + this.players.every((player) => this.submittedPlayers.has(player.player_id)) + ); + } + + eligibleEntries() { + return this.round_data.filter( + (entry) => typeof entry.entry_text === 'string' && entry.entry_text.trim() + ); + } + + publicEntries() { + const entries = this.round_data + .map((entry, index) => ({ + index, + entry_text: entry.entry_text, + voteCount: entry.votes.length + })) + .filter((entry) => entry.entry_text.trim().length > 0); + + if (!this.votingOrder.length) return entries; + + // Show entries in a shuffled order so the submission order does not + // reveal who wrote which line. `index` stays the real round_data index, + // so votes still map correctly. + const byIndex = new Map(entries.map((entry) => [entry.index, entry])); + const ordered = this.votingOrder + .map((index) => byIndex.get(index)) + .filter(Boolean); + const leftovers = entries.filter((entry) => !this.votingOrder.includes(entry.index)); + return [...ordered, ...leftovers]; + } + + getOwnEntryIndex(playerId) { + return this.round_data.findIndex( + (entry) => entry.player_id === playerId && entry.entry_text.trim() + ); + } + + beginVotingPhase({ force = false } = {}) { + if (this.currentPhase !== 'writing' || this.transitioning) return; + this.transitioning = true; + + try { + if (force) { + for (const player of [...this.players]) { + if (!this.submittedPlayers.has(player.player_id)) { + this.submitEntry(player.player_id, '', { forced: true }); + } + } + } + + this.stopTimer(); + this.currentPhase = 'voting'; + this.votedPlayers = new Set(); + + const eligibleIndexes = this.round_data + .map((entry, index) => ({ entry, index })) + .filter(({ entry }) => entry.entry_text.trim().length > 0) + .map(({ index }) => index); + this.votingOrder = shuffle(eligibleIndexes); + + const entries = this.publicEntries(); + this.timeRemaining = Math.max(15, 10 * entries.length); + + if (!entries.length) { + // Nothing to vote on: close the round immediately. + this.emit('votingPhase', { entries: [], timeRemaining: 0 }); + this.endVotingPhase(); + return; + } + + this.emit('votingPhase', { entries, timeRemaining: this.timeRemaining }); + this.emit('playersUpdate', this.getState().players); + this.startVotingTimer(); + this.processBotVoting(); + } finally { + this.transitioning = false; + } + } + + submitVote(playerId, entryIndex) { + if (this.currentPhase !== 'voting') { + return { ok: false, error: 'Voting is closed right now.' }; + } + if (this.votedPlayers.has(playerId)) { + return { ok: false, error: 'You already voted this round.' }; + } + if (!this.getPlayer(playerId)) { + return { ok: false, error: 'Unknown player.' }; + } + + const index = Number.parseInt(entryIndex, 10); + if (!Number.isInteger(index) || index < 0 || index >= this.round_data.length) { + return { ok: false, error: 'Invalid vote.' }; + } + + const entry = this.round_data[index]; + if (!entry || !entry.entry_text.trim()) { + return { ok: false, error: 'You cannot vote for an empty entry.' }; + } + if (entry.player_id === playerId) { + return { ok: false, error: 'You cannot vote for your own entry.' }; + } + + entry.votes.push(playerId); + this.votedPlayers.add(playerId); + this.emit('playersUpdate', this.getState().players); + return { ok: true }; + } + allPlayersVoted() { - return this.votedPlayers.size >= this.players.length; + return ( + this.players.length > 0 && + this.players.every((player) => this.votedPlayers.has(player.player_id)) + ); + } + + pointsMultiplier() { + const limit = this.gameSettings.roundLimit; + if (!limit) return 1; + return this.roundCount + 1 >= limit ? 2 : 1; } tallyVotes() { - if (this.round_data.length === 0) return null; + const multiplier = this.pointsMultiplier(); + const entries = this.eligibleEntries(); + const bonusByEntry = new Map(); - // ----- helper to award points, possibly doubled ----- - const givePoints = (player, pts) => { - if (!player) return; - const multiplier = - this.currentRound + 1 === this.gameSettings.roundLimit ? 2 : 1; - player.score += pts * multiplier; - }; - - // 1. Award 1 point per vote to every sentence - for (const entry of this.round_data) { - givePoints(this.getPlayer(entry.player_id), entry.votes.length); - } - - // 2. Find the top vote count - const maxVotes = Math.max(...this.round_data.map(e => e.votes.length)); - const leaders = this.round_data.filter(e => e.votes.length === maxVotes); - - // 3. Decide bonus points - if (leaders.length === 1) { - // Single winner: +3 - givePoints(this.getPlayer(leaders[0].player_id), 3); - } else { - // Tie: longest gets +2, rest +1 - const longest = leaders.reduce( - (a, b) => (b.entry_text.length > a.entry_text.length ? b : a), - leaders[0] - ); - for (const l of leaders) { - givePoints(this.getPlayer(l.player_id), l === longest ? 2 : 1); + if (entries.length) { + const maxVotes = Math.max(...entries.map((entry) => entry.votes.length)); + if (maxVotes > 0) { + const leaders = entries.filter((entry) => entry.votes.length === maxVotes); + const longest = leaders.reduce( + (a, b) => (b.entry_text.length > a.entry_text.length ? b : a), + leaders[0] + ); + leaders.forEach((leader) => { + bonusByEntry.set(leader, leader === longest ? (leaders.length === 1 ? 3 : 2) : 1); + }); } } - // 4. Append winning (or tied-longest) entry to the story - const storyEntry = - leaders.length === 1 - ? leaders[0] - : leaders.reduce( - (a, b) => (b.entry_text.length > a.entry_text.length ? b : a), - leaders[0] - ); - if (storyEntry?.entry_text) { - this.yarnStory.push({ - player: this.getPlayer(storyEntry.player_id)?.name ?? "Unknown", - str: storyEntry.entry_text - }); + // 1 point per vote for every eligible entry, plus any bonus. + const results = entries.map((entry) => { + const player = this.getPlayer(entry.player_id); + const bonus = bonusByEntry.get(entry) || 0; + const points = (entry.votes.length + bonus) * multiplier; + if (player) player.score += points; + return { + index: this.round_data.indexOf(entry), + entry_text: entry.entry_text, + player_id: entry.player_id, + player_name: player ? player.name : 'Unknown', + votes: entry.votes.length, + points, + bonus + }; + }); + + let winner = null; + let reason = 'no-entries'; + + if (entries.length) { + const maxVotes = Math.max(...entries.map((entry) => entry.votes.length)); + if (maxVotes === 0) { + reason = 'no-votes'; + } else { + const leaders = entries.filter((entry) => entry.votes.length === maxVotes); + const longest = leaders.reduce( + (a, b) => (b.entry_text.length > a.entry_text.length ? b : a), + leaders[0] + ); + const winnerResult = results.find( + (result) => result.entry_text === longest.entry_text && + result.player_id === longest.player_id + ); + winner = winnerResult ? { ...winnerResult } : null; + reason = leaders.length === 1 ? 'winner' : 'tie'; + + if (longest.entry_text) { + this.yarnStory.push({ + player: this.getPlayer(longest.player_id)?.name ?? 'Unknown', + str: longest.entry_text + }); + } + } } - this.roundCount++; // increment round counter - return leaders.length === 1 ? leaders[0] : null; // return single winner or null on tie + if (winner) { + winner.reason = reason; + winner.wasTie = reason === 'tie'; + } + + this.roundCount += 1; + return { winner, results, reason }; } checkGameOver() { - // score limit win - if (this.gameSettings.scoreLimit) { - const scoreWinner = this.players.find(p => p.score >= this.gameSettings.scoreLimit); - if (scoreWinner) return scoreWinner; - } - // round limit win - if (this.roundCount >= this.gameSettings.roundLimit && this.gameSettings.roundLimit != 0) { - return this.players.reduce( - (best, p) => (p.score > best.score ? p : best), + if (!this.players.length) return null; + + const best = () => + this.players.reduce( + (top, player) => (player.score > top.score ? player : top), this.players[0] ); + + const { scoreLimit, roundLimit } = this.gameSettings; + + if (scoreLimit) { + const candidate = best(); + if (candidate && candidate.score >= scoreLimit) return candidate; } - // if we're one before the round limit, append to the story - if (this.roundCount == this.gameSettings.roundLimit-1) { - this.yarnStory.push({ player: "Narrator", str: "The moral of this story: " }); - } + + if (roundLimit && this.roundCount >= roundLimit) return best(); + return null; } + endVotingPhase() { + if (this.currentPhase !== 'voting' || this.endingVotes) return; + this.endingVotes = true; + this.stopTimer(); + + this.currentPhase = 'results'; + const { winner, results, reason } = this.tallyVotes(); + this.lastResults = { winner, results, reason, round: this.roundCount }; + + this.emit('roundResults', { + winner, + results, + reason, + round: this.roundCount, + players: this.getState().players, + story: this.yarnStory + }); + + const gameWinner = this.checkGameOver(); + if (gameWinner) { + this.finishGame(gameWinner); + return; + } + + this.scheduleNextRound(); + } + + scheduleNextRound() { + if (this.nextRoundTimer) this.timers.clearTimeout(this.nextRoundTimer); + + this.nextRoundTimer = this.timers.setTimeout(() => { + this.nextRoundTimer = null; + if (this.currentPhase !== 'results') return; + + if (this.shouldAppendMoral()) { + this.moralAppended = true; + this.yarnStory.push({ + player: 'Narrator', + str: 'The moral of this story: ' + }); + this.emit('storyUpdate', this.yarnStory); + } + + this.startWritingPhase(); + }, 5000); + } + + shouldAppendMoral() { + const limit = this.gameSettings.roundLimit; + if (!limit || this.moralAppended) return false; + return this.roundCount + 1 === limit; + } + + finishGame(gameWinner) { + this.currentPhase = 'gameOver'; + this.inProgress = false; + this.stopTimer(); + this.stopBanterTimer(); + this.gameWinner = { + player_id: gameWinner.player_id, + name: gameWinner.name, + score: gameWinner.score + }; + + try { + this.saveStory(); + } catch (err) { + console.warn(`[${this.roomName}] Could not save story:`, err.message); + } + + this.emit('gameOver', { + winner: this.gameWinner, + players: this.getState().players, + story: this.yarnStory + }); + } + purgeBots() { for (const botObj of this.bots) { - this.removePlayer(botObj.bot_id) + this.removePlayer(botObj.bot_id, { disconnected: false }); } this.bots = []; } resetGame() { + this.destroy(); + this.purgeBots(); + this.inProgress = false; this.currentPhase = 'lobby'; - this.purgeBots(); this.round_data = []; this.yarnStory = []; this.submittedPlayers = new Set(); this.votedPlayers = new Set(); - if (this.timer) { - clearInterval(this.timer); - this.timer = null; + this.roundCount = 0; + this.timeRemaining = 0; + this.lastResults = null; + this.gameWinner = null; + this.moralAppended = false; + this.endingVotes = false; + this.transitioning = false; + this.votingOrder = []; + + this.emit('gameReset', this.getState()); + } + + destroy() { + this.stopTimer(); + this.stopBanterTimer(); + if (this.nextRoundTimer) { + this.timers.clearTimeout(this.nextRoundTimer); + this.nextRoundTimer = null; } } + // --- stories & chat ------------------------------------------------------ + saveStory() { - const story_text = this.getStoryText(); + const storyText = this.getStoryText(); const now = new Date(); const dateStamp = now.toISOString().slice(0, 10); // YYYY-MM-DD const timeStamp = now.toTimeString().slice(0, 8).replace(/:/g, '-'); // HH-MM-SS - const fileName = `${this.roomName} - ${dateStamp} ${timeStamp}.txt`; - const filePath = path.join('stories', fileName); + const safeRoom = sanitizeRoomName(this.roomName) || 'room'; + const baseName = `${safeRoom} - ${dateStamp} ${timeStamp}`; + const directory = this.storiesDir; - fs.mkdirSync('stories', { recursive: true }); // ensure dir exists - fs.writeFileSync(filePath, story_text, 'utf8'); - } - - botBanter() { - for (const botObj of this.bots) { - if (Math.random() > 0.8) { - console.log("Bot bantering") - const botBanter = botObj.bot.Banter(this.getStoryText()); - console.log(botBanter) - const chatMsg = this.addChatMessage(botObj.bot_id, botBanter) - io.to(this.roomName).emit('newChatMessage', chatMsg); //I don't like using io calls here but chat is weird - } + fs.mkdirSync(directory, { recursive: true }); + // path.basename guarantees the write stays inside stories/. + let fileName = `${baseName}.txt`; + let suffix = 2; + while (fs.existsSync(path.join(directory, fileName))) { + fileName = `${baseName} (${suffix}).txt`; + suffix += 1; } + const filePath = path.join(directory, path.basename(fileName)); + fs.writeFileSync(filePath, storyText, 'utf8'); + console.log(`[${this.roomName}] Saved story to ${filePath}`); + return path.basename(filePath); } addChatMessage(playerId, msg) { @@ -348,54 +940,124 @@ class YarnGame { const chatMsg = { id: playerId, name: player ? player.name : 'Unknown', - msg: msg + msg: cleanText(msg, LIMITS.chat) || '', + at: Date.now() }; + if (!chatMsg.msg) return null; + this.chat.push(chatMsg); - // Keep last 100 messages - if (this.chat.length > 100) { - this.chat.shift(); - } + if (this.chat.length > 100) this.chat.shift(); return chatMsg; } + // --- state --------------------------------------------------------------- + getState() { + const showResults = + this.currentPhase === 'results' || this.currentPhase === 'gameOver'; + const roundLimit = this.gameSettings.roundLimit; + const currentRound = roundLimit + ? Math.min(this.roundCount + 1, roundLimit) + : this.roundCount + 1; + return { roomName: this.roomName, inProgress: this.inProgress, gameHost: this.gameHost, - gameSettings: this.gameSettings, - players: this.players.map(p => ({ - player_id: p.player_id, - name: p.name, - score: p.score, - hasSubmitted: this.submittedPlayers.has(p.player_id), - hasVoted: this.votedPlayers.has(p.player_id) + gameSettings: { ...this.gameSettings }, + players: this.players.map((player) => ({ + player_id: player.player_id, + name: player.name, + score: player.score, + isBot: Boolean(player.isBot), + hasSubmitted: this.submittedPlayers.has(player.player_id), + hasVoted: this.votedPlayers.has(player.player_id) })), - yarnStory: this.yarnStory, + yarnStory: this.yarnStory.map((entry) => ({ ...entry })), currentPhase: this.currentPhase, - currentRound: this.roundCount+1, + currentRound, + roundLimit, + scoreLimit: this.gameSettings.scoreLimit, timeRemaining: this.timeRemaining, - round_data: this.currentPhase === 'voting' ? this.round_data.map((e, i) => ({ - index: i, - entry_text: e.entry_text, - voteCount: e.votes.length - })) : [] + round_data: this.currentPhase === 'voting' ? this.publicEntries() : [], + lastResults: showResults ? this.lastResults : null, + gameWinner: this.gameWinner + }; + } + + getStateFor(playerId) { + return { + ...this.getState(), + ownEntryIndex: this.getOwnEntryIndex(playerId) }; } } -// Get or create a game room -function getOrCreateGame(roomName) { - if (!games[roomName]) { - console.log(`Creating new room with name ${roomName}`) - games[roomName] = new YarnGame(roomName); - } - return games[roomName]; +// --------------------------------------------------------------------------- +// HTTP / Socket.IO wiring +// --------------------------------------------------------------------------- + +const app = express(); +const server = http.createServer(app); +const io = new Server(server); + +app.use(express.static(path.join(__dirname, 'public'))); + +// Store all game rooms +const games = {}; + +function createBroadcaster(roomName) { + return (event, payload) => { + // Voting entries are broadcast per-socket so each player learns which + // entry is theirs (without revealing authorship to everyone). + if (event === 'votingPhase') { + const game = games[roomName]; + const roomSockets = io.sockets.adapter.rooms.get(roomName); + if (!roomSockets || !roomSockets.size) return; + for (const socketId of roomSockets) { + const player = game ? game.getPlayerBySocket(socketId) : null; + const ownEntryIndex = player + ? game.getOwnEntryIndex(player.player_id) + : -1; + io.to(socketId).emit(event, { ...payload, ownEntryIndex }); + } + return; + } + io.to(roomName).emit(event, payload); + }; } -// Route for game rooms +function getOrCreateGame(roomName) { + const name = sanitizeRoomName(roomName); + if (!name) return null; + if (!games[name]) { + if (Object.keys(games).length >= LIMITS.maxRooms) return null; + console.log(`[rooms] Creating room ${name}`); + games[name] = new YarnGame(name, { + broadcast: createBroadcaster(name), + storiesDir + }); + } + return games[name]; +} + +function removeRoomIfEmpty(roomName) { + const game = games[roomName]; + if (!game) return; + if (game.getHumanPlayers().length > 0) return; + + console.log(`[rooms] Removing empty room ${roomName}`); + game.destroy(); + game.purgeBots(); + delete games[roomName]; +} + +// Routes -------------------------------------------------------------------- + app.get('/game/:roomName', (req, res) => { - res.sendFile(path.join(__dirname, 'public', 'game.html')); + const roomName = sanitizeRoomName(req.params.roomName); + if (!roomName) return res.status(400).send('Invalid room name.'); + return res.sendFile(path.join(__dirname, 'public', 'game.html')); }); app.get('/', (req, res) => { @@ -403,248 +1065,270 @@ app.get('/', (req, res) => { }); app.get('/api/rooms', (req, res) => { - const roomList = Object.keys(games).map(name => ({ + const roomList = Object.keys(games).map((name) => ({ name, - playerCount: games[name].players.length + playerCount: games[name].getHumanPlayers().length, + inProgress: games[name].inProgress, + phase: games[name].currentPhase })); res.json({ rooms: roomList }); }); -// Socket.IO connection handling +app.get('/api/stories', (req, res) => { + try { + if (!fs.existsSync(storiesDir)) return res.json({ stories: [] }); + const stories = fs + .readdirSync(storiesDir) + .filter((name) => name.endsWith('.txt')) + .sort() + .reverse() + .slice(0, 200); + return res.json({ stories }); + } catch (err) { + console.error('Could not list stories:', err.message); + return res.status(500).json({ stories: [], error: 'Could not list stories.' }); + } +}); + +app.get('/api/stories/:fileName', (req, res) => { + const fileName = path.basename(String(req.params.fileName)); + if (!/^[A-Za-z0-9 _.-]+\.txt$/.test(fileName)) { + return res.status(400).json({ error: 'Invalid story name.' }); + } + const filePath = path.join(storiesDir, fileName); + if (!filePath.startsWith(storiesDir + path.sep)) { + return res.status(400).json({ error: 'Invalid story name.' }); + } + if (!fs.existsSync(filePath)) { + return res.status(404).json({ error: 'Story not found.' }); + } + return res.type('text/plain').send(fs.readFileSync(filePath, 'utf8')); +}); + +// Socket.IO ----------------------------------------------------------------- + io.on('connection', (socket) => { let currentRoom = null; let playerId = null; + const sendError = (message) => socket.emit('error', { message }); + socket.on('joinRoom', (data) => { - const { roomName, playerUUID, playerName } = data; + const roomName = sanitizeRoomName(data?.roomName); + const uuid = sanitizePlayerId(data?.playerUUID); + if (!roomName) return sendError('Invalid room name.'); + if (!uuid) return sendError('Invalid player id.'); + currentRoom = roomName; - playerId = playerUUID; - + playerId = uuid; socket.join(roomName); - + const game = getOrCreateGame(roomName); - - if (playerName) { - game.addPlayer(playerId, playerName, socket.id); - } - - // Send current game state to the joining player - socket.emit('gameState', game.getState()); + if (!game) return sendError('The server is at capacity. Try again later.'); + + const rawName = typeof data?.playerName === 'string' ? data.playerName : ''; + if (rawName.trim()) game.addPlayer(playerId, rawName, socket.id); + + socket.emit('gameState', game.getStateFor(playerId)); socket.emit('chatHistory', game.chat); - - // Notify others - if (playerName) { + socket.emit('settingsUpdate', game.gameSettings); + + if (rawName.trim() && game.getPlayer(playerId)) { io.to(roomName).emit('playerJoined', { player_id: playerId, - name: playerName + name: game.getPlayer(playerId).name }); io.to(roomName).emit('playersUpdate', game.getState().players); + io.to(roomName).emit('gameState', game.getState()); } }); socket.on('setName', (data) => { - const { name } = data; if (!currentRoom || !playerId) return; - const game = games[currentRoom]; if (!game) return; - - game.addPlayer(playerId, name, socket.id); - - socket.emit('nameSet', { success: true, name }); + + const name = cleanText(data?.name, LIMITS.playerName); + if (!name) return sendError('Please choose a name (1-20 characters).'); + + const player = game.addPlayer(playerId, name, socket.id); + if (!player) return sendError('Could not join the game.'); + + socket.emit('nameSet', { success: true, name: player.name }); io.to(currentRoom).emit('playersUpdate', game.getState().players); io.to(currentRoom).emit('gameState', game.getState()); }); socket.on('updateSettings', (data) => { if (!currentRoom || !playerId) return; - const game = games[currentRoom]; if (!game || game.gameHost !== playerId) return; - - game.gameSettings = { ...game.gameSettings, ...data }; + if (game.inProgress) return sendError('Settings are locked during a game.'); + + game.gameSettings = normalizeSettings(game.gameSettings, data); io.to(currentRoom).emit('settingsUpdate', game.gameSettings); + io.to(currentRoom).emit('gameState', game.getState()); }); - socket.on('startGame', async (settings) => { + socket.on('startGame', async (data) => { if (!currentRoom || !playerId) return; - - const game = games[currentRoom]; - if (!game || game.gameHost !== playerId) return; - if ((game.players.length + settings.botCount) < 3) { - socket.emit('error', { message: 'Need at least 3 players to start' }); - return; - } - - console.log(`Starting new game with settings ${JSON.stringify(settings)}`) - await game.startGame(settings); - io.to(currentRoom).emit('gameStarted', game.getState()); - - await startTimer(game); - }); - - socket.on('submitEntry', async (data) => { - if (!currentRoom || !playerId) return; - const game = games[currentRoom]; if (!game) return; - - const success = game.submitEntry(playerId, data.entry); - - if (success) { - io.to(currentRoom).emit('playersUpdate', game.getState().players); - - if (game.allPlayersSubmitted()) { - clearInterval(game.timer); - await game.startVotingPhase(); - io.to(currentRoom).emit('votingPhase', { - entries: game.round_data.map((e, i) => ({ - index: i, - entry_text: e.entry_text - })), - timeRemaining: game.timeRemaining - }); - startVotingTimer(game); - } + if (game.gameHost !== playerId) return sendError('Only the host can start the game.'); + if (game.inProgress) return sendError('A game is already in progress.'); + + const settings = normalizeSettings(game.gameSettings, data); + if (game.players.length + settings.botCount < 3) { + return sendError('Need at least 3 players (including bots) to start.'); } + + console.log(`[${currentRoom}] Starting game ${JSON.stringify(settings)}`); + try { + await game.startGame(settings); + } catch (err) { + console.error(`[${currentRoom}] Failed to start game:`, err); + return sendError('Failed to start the game. Please try again.'); + } + }); + + socket.on('submitEntry', (data) => { + if (!currentRoom || !playerId) return; + const game = games[currentRoom]; + if (!game) return; + + const result = game.submitEntry(playerId, data?.entry); + if (!result.ok) return sendError(result.error); + game.checkSubmissionsComplete(); }); socket.on('submitVote', (data) => { if (!currentRoom || !playerId) return; - const game = games[currentRoom]; if (!game) return; - - const success = game.submitVote(playerId, data.entryIndex); - - if (success) { - io.to(currentRoom).emit('playersUpdate', game.getState().players); - - if (game.allPlayersVoted()) { - clearInterval(game.timer); - endVotingPhase(game); - } + + const result = game.submitVote(playerId, data?.entryIndex); + if (!result.ok) return sendError(result.error); + game.checkVotesComplete(); + }); + + socket.on('skipPhase', () => { + if (!currentRoom || !playerId) return; + const game = games[currentRoom]; + if (!game) return; + if (game.gameHost !== playerId) return sendError('Only the host can skip a phase.'); + + if (game.currentPhase === 'writing') { + game.beginVotingPhase({ force: true }); + } else if (game.currentPhase === 'voting') { + game.endVotingPhase(); } }); + socket.on('kickPlayer', (data) => { + if (!currentRoom || !playerId) return; + const game = games[currentRoom]; + if (!game) return; + if (game.gameHost !== playerId) return sendError('Only the host can remove players.'); + + const targetId = sanitizePlayerId(data?.playerId); + if (!targetId || targetId === playerId) return sendError('Invalid player.'); + const target = game.getPlayer(targetId); + if (!target || target.isBot) return sendError('Invalid player.'); + + const targetSocketId = target.socketId; + game.removePlayer(targetId, { disconnected: false }); + + const targetSocket = targetSocketId ? io.sockets.sockets.get(targetSocketId) : null; + if (targetSocket) { + targetSocket.leave(currentRoom); + targetSocket.emit('kicked', { + message: 'You were removed from the game by the host.' + }); + } + io.to(currentRoom).emit('playerLeft', { player_id: targetId, name: target.name }); + io.to(currentRoom).emit('playersUpdate', game.getState().players); + io.to(currentRoom).emit('gameState', game.getState()); + + if (game.currentPhase === 'writing' && game.allPlayersSubmitted()) { + game.beginVotingPhase(); + } else if (game.currentPhase === 'voting' && game.allPlayersVoted()) { + game.endVotingPhase(); + } + removeRoomIfEmpty(currentRoom); + }); + socket.on('chatMessage', (data) => { if (!currentRoom || !playerId) return; - const game = games[currentRoom]; - if (!game) return; - - const chatMsg = game.addChatMessage(playerId, data.msg); - io.to(currentRoom).emit('newChatMessage', chatMsg); + if (!game || !game.getPlayer(playerId)) return; + + const chatMsg = game.addChatMessage(playerId, data?.msg); + if (chatMsg) io.to(currentRoom).emit('newChatMessage', chatMsg); }); - socket.on('playAgain', (settings) => { + socket.on('playAgain', () => { if (!currentRoom || !playerId) return; - const game = games[currentRoom]; if (!game || game.gameHost !== playerId) return; - - game.saveStory(); + + if (game.inProgress) return sendError('The game is still in progress.'); game.resetGame(); - io.to(currentRoom).emit('gameReset', game.getState()); }); socket.on('disconnect', () => { if (!currentRoom || !playerId) return; - const game = games[currentRoom]; if (!game) return; - + const player = game.getPlayer(playerId); - const playerName = player ? player.name : 'Unknown'; - - const remaining = game.removePlayer(playerId); - + if (!player) return; + // A reconnecting socket may already own this player id; ignore stale + // disconnects so the fresh session is not kicked out. + if (player.socketId && player.socketId !== socket.id) return; + const playerName = player.name; + + game.removePlayer(playerId); io.to(currentRoom).emit('playerLeft', { player_id: playerId, name: playerName }); io.to(currentRoom).emit('playersUpdate', game.getState().players); io.to(currentRoom).emit('gameState', game.getState()); - - if (remaining === 0 || game.bots.length === game.players.length) { - // Clean up empty rooms - console.log(`Removing empty room ${currentRoom}`) - if (game.timer) clearInterval(game.timer); - delete games[currentRoom]; + + // Don't leave the remaining players waiting on the timer if the person + // who was still typing or voting just dropped. + if (game.inProgress) { + if (game.currentPhase === 'writing' && game.allPlayersSubmitted()) { + game.beginVotingPhase(); + } else if (game.currentPhase === 'voting' && game.allPlayersVoted()) { + game.endVotingPhase(); + } } + + removeRoomIfEmpty(currentRoom); }); }); -async function startTimer(game) { - if (game.timer) clearInterval(game.timer); - - game.timer = setInterval(async () => { - game.timeRemaining--; - io.to(game.roomName).emit('timerUpdate', game.timeRemaining); - - if (game.timeRemaining <= 0) { - clearInterval(game.timer); - - // Force submit empty entries for players who haven't submitted - for (const player of game.players) { - if (!game.submittedPlayers.has(player.player_id)) { - game.submitEntry(player.player_id, ""); - } - } - - await game.startVotingPhase(); - io.to(game.roomName).emit('votingPhase', { - entries: game.round_data.map((e, i) => ({ - index: i, - entry_text: e.entry_text - })), - timeRemaining: game.timeRemaining - }); - startVotingTimer(game); - } - }, 1000); -} - -function startVotingTimer(game) { - if (game.timer) clearInterval(game.timer); - - game.timer = setInterval(() => { - game.timeRemaining--; - io.to(game.roomName).emit('timerUpdate', game.timeRemaining); - - if (game.timeRemaining <= 0) { - clearInterval(game.timer); - endVotingPhase(game); - } - }, 1000); -} - -function endVotingPhase(game) { - const winner = game.tallyVotes(); - - io.to(game.roomName).emit('roundResults', { - winner: winner, - players: game.getState().players, - story: game.yarnStory - }); - - // Check for game over - const gameWinner = game.checkGameOver(); - if (gameWinner) { - io.to(game.roomName).emit('gameOver', { - winner: gameWinner, - players: game.getState().players, - story: game.yarnStory - }); - } else { - // Start next round after a short delay - setTimeout(async () => { - game.startWritingPhase(); - io.to(game.roomName).emit('newRound', game.getState()); - await startTimer(game); - }, 5000); - } -} - const PORT = process.env.PORT || 3000; -server.listen(PORT, () => { - console.log(`YARN server running on http://localhost:${PORT}`); - console.log(`Join a room at http://localhost:${PORT}/game101`); -}); + +if (require.main === module) { + server.listen(PORT, () => { + console.log(`YARN server running on http://localhost:${PORT}`); + console.log(`Join a room at http://localhost:${PORT}/game/lobby`); + }); +} + +module.exports = { + YarnGame, + app, + server, + io, + games, + storiesDir, + LIMITS, + sanitizeRoomName, + sanitizePlayerId, + normalizeSettings, + cleanText, + clampInt, + shuffle, + getOrCreateGame, + removeRoomIfEmpty +}; diff --git a/tests/bots.test.js b/tests/bots.test.js new file mode 100644 index 0000000..49a988d --- /dev/null +++ b/tests/bots.test.js @@ -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); +}); diff --git a/tests/client-ui.test.js b/tests/client-ui.test.js new file mode 100644 index 0000000..93bf801 --- /dev/null +++ b/tests/client-ui.test.js @@ -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(); +}); diff --git a/tests/helpers.js b/tests/helpers.js new file mode 100644 index 0000000..fed3932 --- /dev/null +++ b/tests/helpers.js @@ -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 +}; diff --git a/tests/socket-flow.test.js b/tests/socket-flow.test.js new file mode 100644 index 0000000..10a1c32 --- /dev/null +++ b/tests/socket-flow.test.js @@ -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'); +}); diff --git a/tests/validation.test.js b/tests/validation.test.js new file mode 100644 index 0000000..8555ee3 --- /dev/null +++ b/tests/validation.test.js @@ -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); +}); diff --git a/tests/yarn-game.test.js b/tests/yarn-game.test.js new file mode 100644 index 0000000..0ab1f82 --- /dev/null +++ b/tests/yarn-game.test.js @@ -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 - /); +});