Files
yarn-ng/server.js
T

1335 lines
39 KiB
JavaScript

'use strict';
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const path = require('path');
const fs = require('fs');
const { randomUUID } = require('node:crypto');
const BotFactory = require('./bots/BotFactory');
// ---------------------------------------------------------------------------
// Limits & small validation helpers
// ---------------------------------------------------------------------------
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]
}
};
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
// ---------------------------------------------------------------------------
class YarnGame {
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 = {
scoreLimit: 0,
roundLimit: 15,
timeLimit: 60,
botCount: 0,
startText: DEFAULT_START_TEXT
};
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; // 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 = [];
}
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 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,
score: 0,
socketId,
isBot: false
};
this.players.push(player);
if (!this.gameHost) this.gameHost = playerId;
console.log(`[${this.roomName}] ${name} joined.`);
return player;
}
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) => !p.isBot);
}
getPlayer(playerId) {
return this.players.find((p) => p.player_id === playerId);
}
getPlayerBySocket(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 && 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) {
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 });
this.players.push({
player_id: bot_id,
name,
score: 0,
socketId: null,
isBot: true
});
console.log(`[${this.roomName}] Created bot ${name} (${bot_id})`);
}
}
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() {
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 processBotVoting() {
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 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
);
}
})
);
}
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.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() {
const multiplier = this.pointsMultiplier();
const entries = this.eligibleEntries();
const bonusByEntry = new Map();
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);
});
}
}
// 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
});
}
}
}
if (winner) {
winner.reason = reason;
winner.wasTie = reason === 'tie';
}
this.roundCount += 1;
return { winner, results, reason };
}
checkGameOver() {
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 (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, { disconnected: false });
}
this.bots = [];
}
resetGame() {
this.destroy();
this.purgeBots();
this.inProgress = false;
this.currentPhase = 'lobby';
this.round_data = [];
this.yarnStory = [];
this.submittedPlayers = new Set();
this.votedPlayers = new Set();
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 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 safeRoom = sanitizeRoomName(this.roomName) || 'room';
const baseName = `${safeRoom} - ${dateStamp} ${timeStamp}`;
const directory = this.storiesDir;
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) {
const player = this.getPlayer(playerId);
const chatMsg = {
id: playerId,
name: player ? player.name : 'Unknown',
msg: cleanText(msg, LIMITS.chat) || '',
at: Date.now()
};
if (!chatMsg.msg) return null;
this.chat.push(chatMsg);
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((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.map((entry) => ({ ...entry })),
currentPhase: this.currentPhase,
currentRound,
roundLimit,
scoreLimit: this.gameSettings.scoreLimit,
timeRemaining: this.timeRemaining,
round_data: this.currentPhase === 'voting' ? this.publicEntries() : [],
lastResults: showResults ? this.lastResults : null,
gameWinner: this.gameWinner
};
}
getStateFor(playerId) {
return {
...this.getState(),
ownEntryIndex: this.getOwnEntryIndex(playerId)
};
}
}
// ---------------------------------------------------------------------------
// 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);
};
}
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) => {
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) => {
res.sendFile(path.join(__dirname, 'public', 'menu.html'));
});
app.get('/api/rooms', (req, res) => {
const roomList = Object.keys(games).map((name) => ({
name,
playerCount: games[name].getHumanPlayers().length,
inProgress: games[name].inProgress,
phase: games[name].currentPhase
}));
res.json({ rooms: roomList });
});
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 = 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 = uuid;
socket.join(roomName);
const game = getOrCreateGame(roomName);
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);
socket.emit('settingsUpdate', game.gameSettings);
if (rawName.trim() && game.getPlayer(playerId)) {
io.to(roomName).emit('playerJoined', {
player_id: playerId,
name: game.getPlayer(playerId).name
});
io.to(roomName).emit('playersUpdate', game.getState().players);
io.to(roomName).emit('gameState', game.getState());
}
});
socket.on('setName', (data) => {
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game) return;
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;
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 (data) => {
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game) return;
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 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 || !game.getPlayer(playerId)) return;
const chatMsg = game.addChatMessage(playerId, data?.msg);
if (chatMsg) io.to(currentRoom).emit('newChatMessage', chatMsg);
});
socket.on('playAgain', () => {
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game || game.gameHost !== playerId) return;
if (game.inProgress) return sendError('The game is still in progress.');
game.resetGame();
});
socket.on('disconnect', () => {
if (!currentRoom || !playerId) return;
const game = games[currentRoom];
if (!game) return;
const player = game.getPlayer(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());
// 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);
});
});
const PORT = process.env.PORT || 3000;
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
};