371 lines
10 KiB
JavaScript
371 lines
10 KiB
JavaScript
// 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,
|
|
};
|