// Pure game state machine — no socket dependencies. Functions take the room object // and return mutations + a list of side-effect descriptors that the socket layer // (socket-handlers.js) will translate into io.emit calls. const path = require("path"); const fs = require("fs"); const { makeId } = require("./room-store.js"); const { normalize, maskWord, pickRevealIndex, isCloseGuess, } = require("./word-utils.js"); const WORDS_PATH = path.join(__dirname, "..", "app", "lib", "words", "en.json"); let WORDS = []; try { WORDS = JSON.parse(fs.readFileSync(WORDS_PATH, "utf8")); } catch (e) { console.warn("[words] could not read en.json:", e.message); WORDS = ["apple", "banana", "rocket", "robot", "tree"]; } function pickRandomWords(n = 3) { const out = new Set(); while (out.size < n) { out.add(WORDS[Math.floor(Math.random() * WORDS.length)]); } return [...out]; } function makePlayer({ nickname, avatar }) { return { id: makeId(), sessionToken: makeId(), nickname: String(nickname || "Player").slice(0, 20), avatar: String(avatar || "🐱"), score: 0, connected: true, socketId: null, }; } function makeRoom({ code, hostId, mode, settings }) { return { code, hostId, mode, // 'skribbl' | 'gartic' | 'color' settings: settings || {}, players: [], chat: [], // recent messages phase: "lobby", // lobby | playing | results lastActiveAt: Date.now(), // mode-specific state skribbl: null, gartic: null, color: null, timers: {}, }; } function publicRoomState(room) { return { code: room.code, hostId: room.hostId, mode: room.mode, settings: room.settings, phase: room.phase, players: room.players.map((p) => ({ id: p.id, nickname: p.nickname, avatar: p.avatar, score: p.score, connected: p.connected, isHost: p.id === room.hostId, })), skribbl: room.skribbl ? { roundIndex: room.skribbl.roundIndex, totalRounds: room.skribbl.totalRounds, drawerId: room.skribbl.drawerId, wordMask: room.skribbl.wordMask, wordLength: room.skribbl.word ? room.skribbl.word.length : 0, phase: room.skribbl.phase, // 'choosing' | 'drawing' | 'between' endsAt: room.skribbl.endsAt, solvedIds: [...(room.skribbl.solvedIds || [])], } : null, gartic: room.gartic ? { turnIndex: room.gartic.turnIndex, totalTurns: room.gartic.totalTurns, phase: room.gartic.phase, endsAt: room.gartic.endsAt, submittedIds: [...(room.gartic.submittedIds || [])], } : null, color: room.color ? { canvasType: room.color.canvasType, templateId: room.color.templateId, strokeCount: room.color.strokes.length, } : null, }; } // ---- Skribbl ---------------------------------------------------------------- function skribblInit(room) { const rounds = Number(room.settings.rounds) || 4; const drawTimeSec = Number(room.settings.drawTimeSec) || 80; room.skribbl = { roundIndex: 0, totalRounds: rounds * room.players.length, drawerOrder: room.players.map((p) => p.id), drawerCursor: 0, drawerId: null, word: null, wordChoices: null, wordMask: null, revealed: new Set(), drawTimeSec, endsAt: 0, phase: "between", solvedIds: new Set(), solveOrder: [], }; } function skribblNextRound(room) { const sk = room.skribbl; if (!sk) return { done: true }; if (sk.roundIndex >= sk.totalRounds) return { done: true }; // pick next drawer that is connected let attempts = 0; while (attempts < sk.drawerOrder.length) { const candidate = sk.drawerOrder[sk.drawerCursor % sk.drawerOrder.length]; sk.drawerCursor++; const p = room.players.find((pl) => pl.id === candidate); if (p && p.connected) { sk.drawerId = p.id; sk.phase = "choosing"; sk.word = null; sk.wordChoices = pickRandomWords(3); sk.wordMask = null; sk.revealed = new Set(); sk.solvedIds = new Set(); sk.solveOrder = []; sk.endsAt = Date.now() + 15000; sk.roundIndex++; return { ok: true, drawer: p, choices: sk.wordChoices }; } attempts++; } return { done: true }; } function skribblPickWord(room, drawerId, word) { const sk = room.skribbl; if (!sk) return { ok: false, error: "no game" }; if (sk.drawerId !== drawerId) return { ok: false, error: "not drawer" }; if (sk.phase !== "choosing") return { ok: false, error: "not choosing" }; if (!sk.wordChoices.includes(word)) return { ok: false, error: "bad word" }; sk.word = word; sk.wordMask = maskWord(word, new Set()); sk.revealed = new Set(); sk.phase = "drawing"; sk.endsAt = Date.now() + sk.drawTimeSec * 1000; return { ok: true, drawer: sk.drawerId, word, mask: sk.wordMask, endsAt: sk.endsAt }; } function skribblHandleGuess(room, fromId, text) { const sk = room.skribbl; if (!sk || sk.phase !== "drawing" || !sk.word) { return { kind: "chat" }; } if (fromId === sk.drawerId) return { kind: "chat" }; if (sk.solvedIds.has(fromId)) return { kind: "chat" }; const guess = normalize(text); const target = normalize(sk.word); if (guess === target) { sk.solvedIds.add(fromId); sk.solveOrder.push(fromId); // award points const total = sk.drawTimeSec * 1000; const left = Math.max(0, sk.endsAt - Date.now()); const factor = left / total; const points = Math.floor(50 + factor * 100); const player = room.players.find((p) => p.id === fromId); if (player) player.score += points; // drawer bonus, +25 per guesser const drawer = room.players.find((p) => p.id === sk.drawerId); if (drawer) drawer.score += 25; return { kind: "correct", points }; } if (isCloseGuess(text, sk.word)) { return { kind: "close" }; } return { kind: "chat" }; } function skribblShouldEndRound(room) { const sk = room.skribbl; if (!sk || sk.phase !== "drawing") return false; // all non-drawer connected players solved? const guessers = room.players.filter( (p) => p.id !== sk.drawerId && p.connected ); if (guessers.length === 0) return false; return guessers.every((p) => sk.solvedIds.has(p.id)); } function skribblRevealHint(room) { const sk = room.skribbl; if (!sk || !sk.word) return null; const idx = pickRevealIndex(sk.word, sk.revealed); if (idx == null) return null; sk.revealed.add(idx); sk.wordMask = maskWord(sk.word, sk.revealed); return sk.wordMask; } // ---- Gartic ---------------------------------------------------------------- function garticInit(room) { const players = room.players.filter((p) => p.connected); const totalTurns = players.length; // each book goes around once const books = players.map((p) => ({ ownerId: p.id, ownerName: p.nickname, pages: [], // [{type, content, authorId, authorName}] })); room.gartic = { players: players.map((p) => p.id), books, turnIndex: 0, totalTurns, phase: "prompt", // prompt | drawing | guess | done submittedIds: new Set(), endsAt: 0, durationMs: 60000, }; } function garticAssignmentForPlayer(gartic, playerId) { // book index for this player on this turn = (originalIndex + turnIndex) % len const playerIdx = gartic.players.indexOf(playerId); if (playerIdx < 0) return null; const bookIdx = (playerIdx + gartic.turnIndex) % gartic.players.length; return { bookIdx, book: gartic.books[bookIdx] }; } function garticTaskForTurn(turnIndex) { if (turnIndex === 0) return "prompt"; return turnIndex % 2 === 1 ? "drawing" : "guess"; } function garticAdvanceTurn(room) { const g = room.gartic; if (!g) return { done: true }; // collect submissions: for any player who didn't submit, place a placeholder for (const playerId of g.players) { if (g.submittedIds.has(playerId)) continue; const assign = garticAssignmentForPlayer(g, playerId); if (!assign) continue; const task = garticTaskForTurn(g.turnIndex); assign.book.pages.push({ type: task, content: task === "drawing" ? "" : "...", authorId: playerId, authorName: (room.players.find((p) => p.id === playerId) || {}).nickname || "?", }); } g.submittedIds = new Set(); g.turnIndex++; if (g.turnIndex >= g.totalTurns) { g.phase = "done"; return { done: true }; } g.phase = garticTaskForTurn(g.turnIndex); g.endsAt = Date.now() + g.durationMs; return { done: false }; } function garticSubmit(room, playerId, type, data) { const g = room.gartic; if (!g || g.phase === "done") return { ok: false }; const expected = garticTaskForTurn(g.turnIndex); if (type !== expected) return { ok: false, error: "wrong type" }; if (g.submittedIds.has(playerId)) return { ok: false, error: "already" }; const assign = garticAssignmentForPlayer(g, playerId); if (!assign) return { ok: false, error: "no book" }; const player = room.players.find((p) => p.id === playerId); assign.book.pages.push({ type, content: String(data || ""), authorId: playerId, authorName: player ? player.nickname : "?", }); g.submittedIds.add(playerId); return { ok: true }; } function garticAllSubmitted(room) { const g = room.gartic; if (!g) return false; return g.players.every((id) => g.submittedIds.has(id)); } // ---- Color Together -------------------------------------------------------- function colorInit(room) { room.color = { canvasType: room.settings.canvasType || "blank", templateId: room.settings.templateId || null, strokes: [], regionFills: {}, }; } function colorAddStroke(room, stroke) { if (!room.color) return; // store last 1500 strokes max room.color.strokes.push(stroke); if (room.color.strokes.length > 1500) { room.color.strokes = room.color.strokes.slice(-1500); } } function colorBucket(room, regionId, color) { if (!room.color) return; room.color.regionFills[regionId] = color; } function colorReset(room, regionId) { if (!room.color) return; if (regionId) { delete room.color.regionFills[regionId]; } else { room.color.strokes = []; room.color.regionFills = {}; } } module.exports = { WORDS, makeRoom, makePlayer, publicRoomState, // skribbl skribblInit, skribblNextRound, skribblPickWord, skribblHandleGuess, skribblShouldEndRound, skribblRevealHint, // gartic garticInit, garticAssignmentForPlayer, garticTaskForTurn, garticAdvanceTurn, garticSubmit, garticAllSubmitted, // color colorInit, colorAddStroke, colorBucket, colorReset, };