1335 lines
39 KiB
JavaScript
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
|
|
};
|