// Wires socket.io events to the game-state machine. const { genCode, getRoom, setRoom, makeId } = require("./room-store.js"); const G = require("./game-state.js"); const { normalize } = require("./word-utils.js"); function escape(text) { return String(text || "") .slice(0, 200) .replace(/[<>]/g, (c) => (c === "<" ? "<" : ">")); } function broadcastState(io, room) { io.to(room.code).emit("room:state", G.publicRoomState(room)); } function chatSystem(io, room, text) { const msg = { id: makeId(), fromId: null, fromName: "system", text, kind: "system" }; room.chat.push(msg); io.to(room.code).emit("chat:msg", msg); } function pushChat(io, room, msg) { room.chat.push(msg); if (room.chat.length > 200) room.chat = room.chat.slice(-200); io.to(room.code).emit("chat:msg", msg); } // ---- Skribbl flow control ------------------------------------------------- function clearTimers(room) { for (const t of Object.values(room.timers || {})) { if (t) clearTimeout(t); } room.timers = {}; } function startSkribblNext(io, room) { const sk = room.skribbl; if (!sk) return; const r = G.skribblNextRound(room); if (r.done) { room.phase = "results"; clearTimers(room); io.to(room.code).emit("skribbl:gameOver", { finalScores: room.players.map((p) => ({ id: p.id, name: p.nickname, avatar: p.avatar, score: p.score })), }); broadcastState(io, room); return; } // notify drawer with choices const drawerSocket = io.sockets.sockets.get(r.drawer.socketId); if (drawerSocket) drawerSocket.emit("skribbl:wordChoices", sk.wordChoices); chatSystem(io, room, `${r.drawer.nickname} is choosing a word...`); broadcastState(io, room); // 15s pick timer — auto-pick first if (room.timers.pick) clearTimeout(room.timers.pick); room.timers.pick = setTimeout(() => { if (sk.phase === "choosing") { const auto = sk.wordChoices[0]; const pick = G.skribblPickWord(room, sk.drawerId, auto); if (pick.ok) startSkribblDrawing(io, room); } }, 15000); } function startSkribblDrawing(io, room) { const sk = room.skribbl; io.to(room.code).emit("skribbl:roundStart", { drawerId: sk.drawerId, wordMask: sk.wordMask, durationMs: sk.drawTimeSec * 1000, roundIndex: sk.roundIndex, totalRounds: sk.totalRounds, }); broadcastState(io, room); // hints at 1/3 and 2/3 if (room.timers.hint1) clearTimeout(room.timers.hint1); if (room.timers.hint2) clearTimeout(room.timers.hint2); if (room.timers.end) clearTimeout(room.timers.end); const total = sk.drawTimeSec * 1000; room.timers.hint1 = setTimeout(() => { const m = G.skribblRevealHint(room); if (m) io.to(room.code).emit("skribbl:hint", { wordMask: m }); broadcastState(io, room); }, Math.floor(total * 0.4)); room.timers.hint2 = setTimeout(() => { const m = G.skribblRevealHint(room); if (m) io.to(room.code).emit("skribbl:hint", { wordMask: m }); broadcastState(io, room); }, Math.floor(total * 0.7)); room.timers.end = setTimeout(() => endSkribblRound(io, room), total); } function endSkribblRound(io, room) { const sk = room.skribbl; if (!sk) return; clearTimers(room); const word = sk.word; sk.phase = "between"; io.to(room.code).emit("skribbl:roundEnd", { word, scores: room.players.map((p) => ({ id: p.id, name: p.nickname, score: p.score })), }); chatSystem(io, room, `The word was: ${word}`); broadcastState(io, room); // 4-second pause then next room.timers.between = setTimeout(() => startSkribblNext(io, room), 4000); } // ---- Gartic flow --------------------------------------------------------- function startGarticTurn(io, room) { const g = room.gartic; if (!g) return; if (g.phase === "done") { room.phase = "results"; clearTimers(room); io.to(room.code).emit("gartic:bookComplete", { books: g.books }); broadcastState(io, room); return; } g.endsAt = Date.now() + g.durationMs; const task = G.garticTaskForTurn(g.turnIndex); // emit per-player task with their assigned book content for (const playerId of g.players) { const player = room.players.find((p) => p.id === playerId); if (!player || !player.connected || !player.socketId) continue; const assign = G.garticAssignmentForPlayer(g, playerId); if (!assign) continue; const lastPage = assign.book.pages[assign.book.pages.length - 1]; const content = lastPage ? lastPage.content : ""; const sock = io.sockets.sockets.get(player.socketId); if (sock) { sock.emit("gartic:turn", { turnIndex: g.turnIndex, totalTurns: g.totalTurns, task, content, durationMs: g.durationMs, }); } } broadcastState(io, room); if (room.timers.gartic) clearTimeout(room.timers.gartic); room.timers.gartic = setTimeout(() => { G.garticAdvanceTurn(room); startGarticTurn(io, room); }, g.durationMs); } // --------------------------------------------------------------------------- function findPlayerBySession(token) { if (!token) return null; const { allRooms } = require("./room-store.js"); for (const room of allRooms().values()) { const p = room.players.find((pl) => pl.sessionToken === token); if (p) return { room, player: p }; } return null; } function registerHandlers(io) { io.on("connection", (socket) => { socket.data = { roomCode: null, playerId: null }; socket.on("room:create", (payload, ack) => { try { const { nickname, avatar, mode, settings } = payload || {}; if (!["skribbl", "gartic", "color"].includes(mode)) { return ack && ack({ ok: false, error: "bad mode" }); } const code = genCode(); const player = G.makePlayer({ nickname, avatar }); player.socketId = socket.id; const room = G.makeRoom({ code, hostId: player.id, mode, settings }); room.players.push(player); room.lastActiveAt = Date.now(); setRoom(code, room); socket.join(code); socket.data.roomCode = code; socket.data.playerId = player.id; ack && ack({ ok: true, code, sessionToken: player.sessionToken, playerId: player.id }); chatSystem(io, room, `${player.nickname} created the room`); broadcastState(io, room); } catch (e) { console.error("room:create err", e); ack && ack({ ok: false, error: "server error" }); } }); socket.on("room:join", (payload, ack) => { try { const { code, nickname, avatar, sessionToken } = payload || {}; const room = getRoom(code); if (!room) return ack && ack({ ok: false, error: "room not found" }); // reconnect via session token let player = sessionToken ? room.players.find((p) => p.sessionToken === sessionToken) : null; if (player) { player.connected = true; player.socketId = socket.id; if (nickname) player.nickname = String(nickname).slice(0, 20); if (avatar) player.avatar = String(avatar); } else { if (room.players.length >= 12) { return ack && ack({ ok: false, error: "room full" }); } player = G.makePlayer({ nickname, avatar }); player.socketId = socket.id; room.players.push(player); chatSystem(io, room, `${player.nickname} joined`); } room.lastActiveAt = Date.now(); socket.join(room.code); socket.data.roomCode = room.code; socket.data.playerId = player.id; ack && ack({ ok: true, code: room.code, sessionToken: player.sessionToken, playerId: player.id, mode: room.mode, }); // send chat history for (const m of room.chat.slice(-30)) socket.emit("chat:msg", m); if (room.color) { socket.emit("color:state", { strokes: room.color.strokes, regions: room.color.regionFills, }); } broadcastState(io, room); } catch (e) { console.error("room:join err", e); ack && ack({ ok: false, error: "server error" }); } }); socket.on("room:leave", () => { const code = socket.data.roomCode; if (!code) return; const room = getRoom(code); if (!room) return; const p = room.players.find((pl) => pl.id === socket.data.playerId); if (p) { p.connected = false; p.socketId = null; } socket.leave(code); socket.data.roomCode = null; socket.data.playerId = null; broadcastState(io, room); }); socket.on("disconnect", () => { const code = socket.data.roomCode; if (!code) return; const room = getRoom(code); if (!room) return; const p = room.players.find((pl) => pl.id === socket.data.playerId); if (p) { p.connected = false; p.socketId = null; } room.lastActiveAt = Date.now(); broadcastState(io, room); }); socket.on("chat:send", (payload) => { const code = socket.data.roomCode; const room = code && getRoom(code); if (!room) return; const player = room.players.find((p) => p.id === socket.data.playerId); if (!player) return; const text = escape(payload && payload.text); if (!text.trim()) return; // skribbl: check guesses if (room.mode === "skribbl" && room.phase === "playing" && room.skribbl) { const result = G.skribblHandleGuess(room, player.id, text); if (result.kind === "correct") { // public message pushChat(io, room, { id: makeId(), fromId: player.id, fromName: player.nickname, text: `guessed the word! +${result.points}`, kind: "correct", }); broadcastState(io, room); if (G.skribblShouldEndRound(room)) { endSkribblRound(io, room); } return; } if (result.kind === "close") { // private hint to guesser socket.emit("chat:msg", { id: makeId(), fromId: null, fromName: "system", text: `'${text}' is close!`, kind: "close", }); // still broadcast their guess to others pushChat(io, room, { id: makeId(), fromId: player.id, fromName: player.nickname, text, kind: "chat", }); return; } // suppress messages from solved guessers? broadcast normal pushChat(io, room, { id: makeId(), fromId: player.id, fromName: player.nickname, text, kind: "chat", }); return; } // default chat pushChat(io, room, { id: makeId(), fromId: player.id, fromName: player.nickname, text, kind: "chat", }); }); socket.on("game:start", (payload, ack) => { const code = socket.data.roomCode; const room = code && getRoom(code); if (!room) return ack && ack({ ok: false, error: "no room" }); if (room.hostId !== socket.data.playerId) return ack && ack({ ok: false, error: "not host" }); if (room.players.filter((p) => p.connected).length < 2 && room.mode !== "color") { return ack && ack({ ok: false, error: "need 2+ players" }); } if (room.mode === "gartic" && room.players.filter((p) => p.connected).length < 3) { return ack && ack({ ok: false, error: "Gartic Phone needs at least 3 players" }); } room.phase = "playing"; if (room.mode === "skribbl") { G.skribblInit(room); startSkribblNext(io, room); } else if (room.mode === "gartic") { G.garticInit(room); // turn 0 (prompt) — start room.gartic.phase = G.garticTaskForTurn(0); startGarticTurn(io, room); } else if (room.mode === "color") { G.colorInit(room); broadcastState(io, room); } broadcastState(io, room); ack && ack({ ok: true }); }); // Skribbl-specific socket.on("skribbl:pickWord", (payload, ack) => { const code = socket.data.roomCode; const room = code && getRoom(code); if (!room || !room.skribbl) return ack && ack({ ok: false }); const r = G.skribblPickWord( room, socket.data.playerId, (payload || {}).word ); if (!r.ok) return ack && ack({ ok: false, error: r.error }); if (room.timers.pick) clearTimeout(room.timers.pick); startSkribblDrawing(io, room); ack && ack({ ok: true }); }); socket.on("skribbl:stroke", (data) => { const code = socket.data.roomCode; const room = code && getRoom(code); if (!room || !room.skribbl) return; if (room.skribbl.drawerId !== socket.data.playerId) return; socket.to(code).emit("skribbl:stroke", data); }); socket.on("skribbl:clear", () => { const code = socket.data.roomCode; const room = code && getRoom(code); if (!room || !room.skribbl) return; if (room.skribbl.drawerId !== socket.data.playerId) return; socket.to(code).emit("skribbl:clear"); }); // Gartic socket.on("gartic:submit", (payload, ack) => { const code = socket.data.roomCode; const room = code && getRoom(code); if (!room || !room.gartic) return ack && ack({ ok: false }); const { type, data } = payload || {}; const r = G.garticSubmit(room, socket.data.playerId, type, data); if (!r.ok) return ack && ack({ ok: false, error: r.error }); ack && ack({ ok: true }); broadcastState(io, room); if (G.garticAllSubmitted(room)) { if (room.timers.gartic) clearTimeout(room.timers.gartic); G.garticAdvanceTurn(room); startGarticTurn(io, room); } }); // Color Together socket.on("color:stroke", (data) => { const code = socket.data.roomCode; const room = code && getRoom(code); if (!room || !room.color) return; G.colorAddStroke(room, data); socket.to(code).emit("color:strokeBroadcast", data); }); socket.on("color:bucket", (payload) => { const code = socket.data.roomCode; const room = code && getRoom(code); if (!room || !room.color) return; const { regionId, color } = payload || {}; if (!regionId || !color) return; G.colorBucket(room, regionId, color); io.to(code).emit("color:bucketBroadcast", { regionId, color }); }); socket.on("color:reset", (payload) => { const code = socket.data.roomCode; const room = code && getRoom(code); if (!room || !room.color) return; G.colorReset(room, (payload || {}).regionId); io.to(code).emit("color:state", { strokes: room.color.strokes, regions: room.color.regionFills, }); }); socket.on("presence:cursor", (data) => { const code = socket.data.roomCode; if (!code) return; socket.to(code).emit("presence:cursors", [ { playerId: socket.data.playerId, x: (data && data.x) || 0, y: (data && data.y) || 0, }, ]); }); }); } module.exports = { registerHandlers };