fix: skribbl pre-pick label + gartic min 3 players + tests

This commit is contained in:
PM
2026-05-01 20:27:41 +00:00
parent 2a40097fad
commit 5c55e33710
20 changed files with 943 additions and 3 deletions
+6 -2
View File
@@ -196,10 +196,14 @@ export default function SkribblGame() {
</div>
<div className="flex-1 text-center">
<div className="text-xs font-bold uppercase tracking-wider" style={{color:"rgba(45,45,45,0.55)"}}>
{isDrawer ? "Your secret word" : `${drawerName} is drawing`}
{sk?.phase === "choosing"
? (isDrawer ? "Pick a word!" : `${drawerName} is choosing a word…`)
: (isDrawer ? "Your secret word" : `${drawerName} is drawing`)}
</div>
<div className="text-xl font-bold tracking-[6px] mt-1" data-testid="word-mask">
{isDrawer && sk?.phase === "drawing" ? (sk as any)?.word || wordMask : wordMask || "_____"}
{sk?.phase === "drawing"
? (isDrawer ? ((sk as any)?.word || wordMask) : (wordMask || "_____"))
: ""}
</div>
</div>
<div className="pill" style={{background:"var(--lavender)", color:"white"}}>
+8 -1
View File
@@ -24,6 +24,8 @@ export default function LobbyPage() {
const isHost = room && myId && room.hostId === myId;
const shareUrl = typeof window !== "undefined" ? `${window.location.origin}/join?code=${code}` : "";
const playerCount = room?.players.filter(p => p.connected).length || 0;
const garticNeedsMore = room?.mode === "gartic" && playerCount < 3;
const start = () => {
setErr("");
@@ -105,8 +107,13 @@ export default function LobbyPage() {
</>}
</div>
{garticNeedsMore && (
<div data-testid="gartic-min-players" className="mt-4 p-3 rounded-2xl border-[3px] border-dashed border-dark text-center text-sm font-semibold" style={{background:"var(--cream)", color:"rgba(45,45,45,0.75)"}}>
Gartic Phone is way more fun with friends! Grab at least <strong>3 players</strong> to start. ({playerCount}/3)
</div>
)}
{isHost ? (
<button onClick={start} data-testid="start-game" className="btn btn-primary w-full mt-4" style={{padding:"16px",fontSize:18}}>
<button onClick={start} disabled={!!garticNeedsMore} data-testid="start-game" className="btn btn-primary w-full mt-4" style={{padding:"16px",fontSize:18, opacity: garticNeedsMore ? 0.5 : 1, cursor: garticNeedsMore ? "not-allowed" : undefined}}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round"><polygon points="6 4 20 12 6 20 6 4"/></svg>
Start Game
</button>
+3
View File
@@ -344,6 +344,9 @@ function registerHandlers(io) {
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);
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+132
View File
@@ -0,0 +1,132 @@
{
"summary": {
"unit": { "passed": 13, "failed": 0 },
"api": { "passed": 2, "failed": 0 },
"socket": { "passed": 31, "failed": 0 },
"e2e": { "passed": 3, "failed": 0 }
},
"unit_tests": [
{ "name": "normalize: lowercases and trims", "status": "pass" },
{ "name": "maskWord: blanks letters but preserves spaces", "status": "pass" },
{ "name": "maskWord: with all indices revealed produces lowercased word", "status": "pass" },
{ "name": "maskWord: partial reveal mixes letters and underscores", "status": "pass" },
{ "name": "pickRevealIndex: picks unrevealed non-space index", "status": "pass" },
{ "name": "pickRevealIndex: returns null when all letters revealed", "status": "pass" },
{ "name": "revealHint flow: applying picked index reveals exactly N more letters", "status": "pass" },
{ "name": "revealHint idempotence: re-applying same revealed set yields same mask", "status": "pass" },
{ "name": "isCloseGuess: edit distance 1-2 returns true", "status": "pass" },
{ "name": "genCode: returns a unique 6-char alphanumeric code", "status": "pass" },
{ "name": "setRoom + getRoom: returns same instance, case-insensitive lookup", "status": "pass" },
{ "name": "makeId: returns a non-empty string id", "status": "pass" },
{ "name": "allRooms: exposes the underlying Map", "status": "pass" }
],
"api_tests": [
{
"endpoint": "GET /api/health",
"status": "pass",
"code": 200,
"notes": "Returns {status:'ok', uptime:N, service:'skribbl-gartic-color', ts:N}; matches PRD contract."
},
{
"endpoint": "GET /api/room/NOPE12/exists",
"status": "pass",
"code": 200,
"notes": "Returns {exists:false, mode:null, playerCount:0} for non-existent room as required."
}
],
"socket_tests": {
"skribbl": [
{ "name": "clients A and B connected", "status": "pass" },
{ "name": "A: room:create -> ok, returns code+sessionToken+playerId", "status": "pass" },
{ "name": "B: room:join -> ok", "status": "pass" },
{ "name": "Both clients receive room:state with both players", "status": "pass" },
{ "name": "chat:send broadcasts to both clients", "status": "pass" },
{ "name": "game:start ack ok (host)", "status": "pass" },
{ "name": "drawer received skribbl:wordChoices (3 choices)", "status": "pass" },
{ "name": "skribbl:pickWord ack ok with chosen word", "status": "pass" },
{ "name": "Both clients received skribbl:roundStart", "status": "pass" },
{ "name": "Non-drawer received skribbl:stroke from drawer", "status": "pass" },
{ "name": "Correct guess -> chat:msg kind=correct on both clients", "status": "pass" },
{ "name": "room:state shows guesser score > 0 after correct guess (149 pts)", "status": "pass" },
{ "name": "skribbl:roundEnd fires with the word", "status": "pass" }
],
"gartic": [
{ "name": "Both clients connected", "status": "pass" },
{ "name": "room:create (mode=gartic) ok", "status": "pass" },
{ "name": "B joined", "status": "pass" },
{ "name": "game:start ok", "status": "pass" },
{ "name": "Both clients got gartic:turn task=prompt", "status": "pass" },
{ "name": "Both prompts submitted (acks ok)", "status": "pass" },
{ "name": "Both got next turn task=drawing with content from prior prompt", "status": "pass" },
{ "name": "Drawing turn includes prior prompt as content", "status": "pass" },
{ "name": "Drawings submitted (acks ok)", "status": "pass" },
{ "name": "gartic:bookComplete fired with 2 books (2-player chain)", "status": "pass" }
],
"color": [
{ "name": "Clients A,B connected", "status": "pass" },
{ "name": "room:create (mode=color, canvasType=blank) ok", "status": "pass" },
{ "name": "B joined", "status": "pass" },
{ "name": "game:start ok", "status": "pass" },
{ "name": "room:state transitioned to phase=playing with color object", "status": "pass" },
{ "name": "B received color:strokeBroadcast with same payload", "status": "pass" },
{ "name": "C joined mid-game", "status": "pass" },
{ "name": "New joiner C received color:state with existing stroke", "status": "pass" }
]
},
"e2e_tests": [
{
"flow": "A",
"status": "pass",
"screenshots": [
"test-results/e2e-skribbl-01-landing.png",
"test-results/e2e-skribbl-02-create-form.png",
"test-results/e2e-skribbl-03-lobby-alice.png",
"test-results/e2e-skribbl-04-lobby-bob.png",
"test-results/e2e-skribbl-05-play-alice.png",
"test-results/e2e-skribbl-06-play-bob.png"
],
"notes": "Landing renders DrawTogether brand. Create CTA navigates to /create. Skribbl mode + nickname submit creates room, lands on /room/{code} with [data-testid=room-code] visible. Bob joins via /join with code. Alice clicks start-game, both Alice and Bob auto-navigate to /play."
},
{
"flow": "B",
"status": "pass",
"screenshots": ["test-results/e2e-join-bad-01.png"],
"notes": "Submitting code 'ZZZZZZ' from /join keeps user on /join and renders error message ('couldn't join'/'room not found'). No incorrect redirect to a room page."
},
{
"flow": "C",
"status": "pass",
"screenshots": [
"test-results/e2e-color-01-create.png",
"test-results/e2e-color-02-lobby.png",
"test-results/e2e-color-03-play.png"
],
"notes": "Color mode room created (single-player allowed by server). Start-game transitions to /play; [data-testid=color-canvas] renders and 16 color palette swatches detected on the page."
}
],
"issues_found": [
{
"severity": "minor",
"area": "skribbl",
"description": "On the drawer's play screen during the 'choosing a word' phase, the secret-word area still shows underscores (`_ _ _ _ _`) of arbitrary length even though no word is yet picked. The drawer hasn't selected a word, so showing a placeholder mask of unrelated length is confusing. Likely the UI keeps a stale wordMask from an earlier state or shows a default placeholder.",
"reproduce": "Create a skribbl room with 2 players, start game, observe the drawer's play screen during the 15-second word-choice window — you'll see 5 underscores under 'YOUR SECRET WORD' before any word is chosen."
},
{
"severity": "minor",
"area": "color",
"description": "On the color play screen at viewport 1280x800, the canvas occupies the full viewport so the color palette + tool buttons (brush/eraser/bucket) sit below the fold; the user has to scroll to access them. This is not a feature break (selectors exist in DOM and respond) but at common laptop viewports the toolbar is not immediately visible.",
"reproduce": "Open a color room as host, click Start Game, observe at 1280x800: header + canvas fill the screen, palette/tools require scrolling. See screenshot test-results/e2e-color-03-play.png."
},
{
"severity": "minor",
"area": "gartic",
"description": "For 2-player gartic, the chain only goes prompt -> drawing -> done (totalTurns = players.length = 2). There's no 'guess' turn ever produced because turnIndex 1 is drawing and the loop ends. This is consistent with the algorithm (totalTurns = players.length) but means with 2 players you never see a guess phase. PRD/test spec mentioned a guess turn — verify if 3+ players is required for the full prompt->draw->guess cycle.",
"reproduce": "Run test/socket-gartic.js — totalTurns=2, after drawing submission, gartic:bookComplete fires immediately with no guess turn."
}
],
"recommendations": [
"Consider hiding/blanking the word-mask placeholder on the drawer's play screen during the 'choosing' phase so users don't see meaningless underscores before picking a word.",
"Color play screen: anchor the palette/toolbar to the bottom edge of the viewport (sticky/fixed) or shrink the canvas height so the tools are visible without scrolling on 1280x800 and smaller laptop viewports — this will likely come up again in Phase 7 (UI/UX) and Phase 8 (responsive).",
"Gartic: for 2-player rooms the book never reaches a guess phase. Either require >=3 players for gartic mode, or extend totalTurns to players.length+1 to ensure at least one guess turn even at 2 players. Document expected behaviour either way."
]
}
+189
View File
@@ -0,0 +1,189 @@
// E2E browser tests using playwright-core + system chromium.
// This is a fallback because the Playwright MCP server is configured for `chrome`
// (not present in this env). It produces equivalent screenshots and assertions.
const path = require("path");
const fs = require("fs");
const { chromium } = require("/home/claude/.npm/_npx/e41f203b7505f1fb/node_modules/playwright-core");
const URL = "http://localhost:3000";
const OUT = path.join(__dirname, "..", "test-results");
if (!fs.existsSync(OUT)) fs.mkdirSync(OUT, { recursive: true });
const flows = [];
function pushResult(flow, status, screenshots, notes) {
flows.push({ flow, status, screenshots, notes });
console.log(`${status === "pass" ? "PASS" : "FAIL"}: E2E ${flow} - ${notes}`);
}
async function shot(page, name) {
const p = path.join(OUT, name);
await page.screenshot({ path: p, fullPage: false });
return name;
}
(async () => {
let browser;
try {
browser = await chromium.launch({
headless: true,
executablePath: "/usr/bin/chromium",
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
console.log("PASS: chromium launched");
// -------------------- E2E A: Skribbl flow --------------------
{
const screenshots = [];
let notes = [];
try {
const ctxA = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const pageA = await ctxA.newPage();
await pageA.goto(URL + "/", { waitUntil: "networkidle" });
const title = await pageA.title();
const html = await pageA.content();
const hasBrand = /DrawTogether/i.test(html);
if (!hasBrand) throw new Error("brand 'DrawTogether' not found on landing");
notes.push("landing has DrawTogether");
screenshots.push(await shot(pageA, "e2e-skribbl-01-landing.png"));
// Click create-room CTA
await pageA.click('[data-testid=create-room-cta]');
await pageA.waitForURL("**/create", { timeout: 5000 });
notes.push("navigated to /create");
// Fill nickname, click skribbl mode
await pageA.click('[data-testid=mode-skribbl]');
await pageA.fill('[data-testid=nickname-input]', "Alice");
screenshots.push(await shot(pageA, "e2e-skribbl-02-create-form.png"));
await pageA.click('[data-testid=create-submit]');
// wait for /room/XXXXXX
await pageA.waitForURL(/\/room\/[A-Z0-9]{6}$/, { timeout: 8000 });
const lobbyUrl = pageA.url();
const code = lobbyUrl.match(/\/room\/([A-Z0-9]{6})/)[1];
notes.push("created room, code=" + code);
// Wait for room-code element, ensure it shows the code
await pageA.waitForSelector('[data-testid=room-code]', { timeout: 5000 });
const onPageCode = (await pageA.textContent('[data-testid=room-code]')).trim();
if (onPageCode !== code) throw new Error("room-code mismatch on page: " + onPageCode + " vs URL " + code);
notes.push("room-code visible on page: " + onPageCode);
screenshots.push(await shot(pageA, "e2e-skribbl-03-lobby-alice.png"));
// Open second context for Bob
const ctxB = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const pageB = await ctxB.newPage();
await pageB.goto(URL + "/join", { waitUntil: "networkidle" });
await pageB.fill('[data-testid=room-code-input]', code);
await pageB.fill('[data-testid=nickname-input]', "Bob");
await pageB.click('[data-testid=join-submit]');
await pageB.waitForURL(/\/room\/[A-Z0-9]{6}/, { timeout: 8000 });
notes.push("Bob joined the room");
// ensure player-list shows both
await pageB.waitForSelector('[data-testid=player-list]', { timeout: 5000 });
screenshots.push(await shot(pageB, "e2e-skribbl-04-lobby-bob.png"));
// Back to Alice — start the game
await pageA.waitForSelector('[data-testid=start-game]', { timeout: 5000 });
// small wait to ensure both connected
await pageA.waitForTimeout(500);
await pageA.click('[data-testid=start-game]');
// play screen has [data-testid=room-pill]
await pageA.waitForURL(/\/room\/[A-Z0-9]{6}\/play/, { timeout: 8000 });
await pageA.waitForSelector('[data-testid=room-pill]', { timeout: 5000 });
notes.push("Alice landed on /play");
screenshots.push(await shot(pageA, "e2e-skribbl-05-play-alice.png"));
// Bob should also navigate to play
try {
await pageB.waitForURL(/\/room\/[A-Z0-9]{6}\/play/, { timeout: 8000 });
screenshots.push(await shot(pageB, "e2e-skribbl-06-play-bob.png"));
notes.push("Bob landed on /play");
} catch (e) {
notes.push("Bob did NOT auto-navigate to /play within timeout (minor): " + e.message);
}
await ctxA.close();
await ctxB.close();
pushResult("A", "pass", screenshots, notes.join("; "));
} catch (e) {
pushResult("A", "fail", screenshots, "error: " + e.message + "; trace: " + (notes.join("; ")));
}
}
// -------------------- E2E B: Join non-existent room --------------------
{
const screenshots = [];
let notes = [];
try {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await ctx.newPage();
await page.goto(URL + "/join", { waitUntil: "networkidle" });
await page.fill('[data-testid=room-code-input]', "ZZZZZZ");
await page.fill('[data-testid=nickname-input]', "Ghost");
await page.click('[data-testid=join-submit]');
// Should NOT navigate to a /room/ URL successfully — wait briefly to see
await page.waitForTimeout(2000);
const finalUrl = page.url();
const successful = /\/room\/[A-Z0-9]{6}$/.test(finalUrl);
if (successful) throw new Error("incorrectly redirected to a room: " + finalUrl);
// look for an error message
const text = await page.textContent("body");
const hasError = /room not found|couldn[']t join|not found|error/i.test(text);
notes.push("stayed on /join, hasErrorMessage=" + hasError);
screenshots.push(await shot(page, "e2e-join-bad-01.png"));
if (!hasError) {
// not strictly fail — but flag
pushResult("B", "fail", screenshots, "no error message visible after invalid code; final URL=" + finalUrl);
} else {
pushResult("B", "pass", screenshots, notes.join("; ") + "; finalUrl=" + finalUrl);
}
await ctx.close();
} catch (e) {
pushResult("B", "fail", screenshots, "error: " + e.message);
}
}
// -------------------- E2E C: Color mode lobby start --------------------
{
const screenshots = [];
let notes = [];
try {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await ctx.newPage();
await page.goto(URL + "/create", { waitUntil: "networkidle" });
await page.click('[data-testid=mode-color]');
await page.fill('[data-testid=nickname-input]', "Painter");
screenshots.push(await shot(page, "e2e-color-01-create.png"));
await page.click('[data-testid=create-submit]');
await page.waitForURL(/\/room\/[A-Z0-9]{6}$/, { timeout: 8000 });
await page.waitForSelector('[data-testid=room-code]', { timeout: 5000 });
notes.push("color room created");
screenshots.push(await shot(page, "e2e-color-02-lobby.png"));
// start game (color allows single player per server logic)
await page.click('[data-testid=start-game]');
await page.waitForURL(/\/room\/[A-Z0-9]{6}\/play/, { timeout: 8000 });
// wait for color-canvas
await page.waitForSelector('[data-testid=color-canvas]', { timeout: 8000 });
// also color palette
const paletteCount = await page.locator('[data-testid^="color-#"]').count();
notes.push("color-canvas + palette swatches=" + paletteCount);
screenshots.push(await shot(page, "e2e-color-03-play.png"));
if (paletteCount < 1) throw new Error("no color palette swatches found");
pushResult("C", "pass", screenshots, notes.join("; "));
await ctx.close();
} catch (e) {
pushResult("C", "fail", screenshots, "error: " + e.message + "; trace: " + notes.join("; "));
}
}
} catch (e) {
console.log("FAIL: e2e bootstrap - " + e.message);
} finally {
if (browser) try { await browser.close(); } catch (_) {}
console.log("__E2E_RESULTS__ " + JSON.stringify(flows));
process.exit(flows.every((f) => f.status === "pass") ? 0 : 1);
}
})();
+100
View File
@@ -0,0 +1,100 @@
// Color Together mode socket test.
const { io } = require("socket.io-client");
const URL = "http://localhost:3000";
const TIMEOUT_MS = 20000;
const results = [];
let exitCode = 0;
const pass = (n) => { results.push({ name: n, status: "pass" }); console.log("PASS:", n); };
const fail = (n, e) => { results.push({ name: n, status: "fail", error: String(e) }); console.log("FAIL:", n, "-", e); exitCode = 1; };
function waitFor(socket, event, predicate = () => true, timeout = 6000) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`timeout waiting for ${event}`)), timeout);
const handler = (data) => {
if (predicate(data)) {
clearTimeout(t);
socket.off(event, handler);
resolve(data);
}
};
socket.on(event, handler);
});
}
function emitAck(s, event, payload) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`ack timeout for ${event}`)), 5000);
s.emit(event, payload, (resp) => { clearTimeout(t); resolve(resp); });
});
}
const killer = setTimeout(() => { console.log("FAIL: Global timeout"); process.exit(1); }, TIMEOUT_MS);
killer.unref();
(async () => {
let a, b, c;
try {
a = io(URL, { transports: ["websocket"], forceNew: true });
b = io(URL, { transports: ["websocket"], forceNew: true });
await Promise.all([
new Promise((r) => a.on("connect", r)),
new Promise((r) => b.on("connect", r)),
]);
pass("color: clients A,B connected");
const create = await emitAck(a, "room:create", {
nickname: "Alice", avatar: "🐱", mode: "color",
settings: { canvasType: "blank" },
});
if (!create || !create.ok) throw new Error("create failed: " + JSON.stringify(create));
const code = create.code;
pass("color: room created, code=" + code);
const j = await emitAck(b, "room:join", { code, nickname: "Bob", avatar: "🐶" });
if (!j || !j.ok) throw new Error("join failed");
pass("color: B joined");
// listen FIRST, then trigger
const stateAfterStartP = waitFor(a, "room:state", (s) => s.color !== null && s.phase === "playing", 4000);
const start = await emitAck(a, "game:start", {});
if (!start || !start.ok) throw new Error("game:start failed: " + JSON.stringify(start));
pass("color: game:start ok");
const stateAfterStart = await stateAfterStartP;
pass("color: room transitioned to playing with color state");
// A emits color:stroke; B should receive color:strokeBroadcast
const strokePayload = {
points: [{ x: 10, y: 10 }, { x: 20, y: 20 }],
color: "#FF0000",
size: 5,
tool: "brush",
};
const bRecv = waitFor(b, "color:strokeBroadcast", (d) => d && d.color === "#FF0000", 4000);
a.emit("color:stroke", strokePayload);
const got = await bRecv;
if (!got || !Array.isArray(got.points) || got.points.length !== 2) throw new Error("stroke broadcast malformed: " + JSON.stringify(got));
pass("color: B received color:strokeBroadcast with same payload");
// C joins mid-game and should receive color:state with the existing stroke
c = io(URL, { transports: ["websocket"], forceNew: true });
await new Promise((r) => c.on("connect", r));
const cStateP = waitFor(c, "color:state", (s) => s && Array.isArray(s.strokes), 5000);
const j2 = await emitAck(c, "room:join", { code, nickname: "Carol", avatar: "🦊" });
if (!j2 || !j2.ok) throw new Error("C join failed");
pass("color: C joined mid-game");
const cState = await cStateP;
if (!cState.strokes || cState.strokes.length < 1) throw new Error("color:state to new joiner missing strokes: " + JSON.stringify(cState));
pass("color: new joiner C received color:state with " + cState.strokes.length + " stroke(s)");
} catch (e) {
fail("color flow", e && e.message ? e.message : String(e));
} finally {
try { a && a.disconnect(); } catch (_) {}
try { b && b.disconnect(); } catch (_) {}
try { c && c.disconnect(); } catch (_) {}
clearTimeout(killer);
console.log("__RESULTS__ " + JSON.stringify(results));
setTimeout(() => process.exit(exitCode), 200);
}
})();
+62
View File
@@ -0,0 +1,62 @@
// Verify Gartic mode requires at least 3 players to start.
const { io } = require("socket.io-client");
const URL = "http://localhost:3000";
const TIMEOUT_MS = 15000;
const results = [];
let exitCode = 0;
const pass = (n) => { results.push({ name: n, status: "pass" }); console.log("PASS:", n); };
const fail = (n, e) => { results.push({ name: n, status: "fail", error: String(e) }); console.log("FAIL:", n, "-", e); exitCode = 1; };
function emitAck(s, event, payload) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`ack timeout for ${event}`)), 5000);
s.emit(event, payload, (resp) => { clearTimeout(t); resolve(resp); });
});
}
const killer = setTimeout(() => { console.log("FAIL: Global timeout"); process.exit(1); }, TIMEOUT_MS);
killer.unref();
(async () => {
let a, b;
try {
a = io(URL, { transports: ["websocket"], forceNew: true });
b = io(URL, { transports: ["websocket"], forceNew: true });
await Promise.all([
new Promise((r) => a.on("connect", r)),
new Promise((r) => b.on("connect", r)),
]);
pass("min-players: two clients connected");
const create = await emitAck(a, "room:create", {
nickname: "Alice", avatar: "🐱", mode: "gartic",
settings: {},
});
if (!create || !create.ok) throw new Error("create failed: " + JSON.stringify(create));
const code = create.code;
pass("min-players: room created, code=" + code);
const j = await emitAck(b, "room:join", { code, nickname: "Bob", avatar: "🐶" });
if (!j || !j.ok) throw new Error("B join failed: " + JSON.stringify(j));
pass("min-players: B joined (2 players total)");
// game:start should fail with min-players error
const start = await emitAck(a, "game:start", {});
if (!start) throw new Error("no ack from game:start");
if (start.ok) throw new Error("expected game:start to fail with 2 players, got ok=true");
if (!start.error || !/3 players|at least 3/i.test(start.error)) {
throw new Error("expected error mentioning '3 players', got: " + JSON.stringify(start));
}
pass("min-players: game:start blocked with friendly error: " + start.error);
} catch (e) {
fail("min-players flow", e && e.message ? e.message : String(e));
} finally {
try { a && a.disconnect(); } catch (_) {}
try { b && b.disconnect(); } catch (_) {}
clearTimeout(killer);
console.log("__RESULTS__ " + JSON.stringify(results));
setTimeout(() => process.exit(exitCode), 200);
}
})();
+145
View File
@@ -0,0 +1,145 @@
// Gartic mode socket flow test.
const { io } = require("socket.io-client");
const URL = "http://localhost:3000";
const TIMEOUT_MS = 30000;
const results = [];
let exitCode = 0;
const pass = (n) => { results.push({ name: n, status: "pass" }); console.log("PASS:", n); };
const fail = (n, e) => { results.push({ name: n, status: "fail", error: String(e) }); console.log("FAIL:", n, "-", e); exitCode = 1; };
function waitFor(socket, event, predicate = () => true, timeout = 8000) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`timeout waiting for ${event}`)), timeout);
const handler = (data) => {
if (predicate(data)) {
clearTimeout(t);
socket.off(event, handler);
resolve(data);
}
};
socket.on(event, handler);
});
}
function emitAck(s, event, payload) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`ack timeout for ${event}`)), 5000);
s.emit(event, payload, (resp) => { clearTimeout(t); resolve(resp); });
});
}
const killer = setTimeout(() => { console.log("FAIL: Global timeout"); process.exit(1); }, TIMEOUT_MS);
killer.unref();
(async () => {
let a, b, c;
try {
a = io(URL, { transports: ["websocket"], forceNew: true });
b = io(URL, { transports: ["websocket"], forceNew: true });
c = io(URL, { transports: ["websocket"], forceNew: true });
await Promise.all([
new Promise((r) => a.on("connect", r)),
new Promise((r) => b.on("connect", r)),
new Promise((r) => c.on("connect", r)),
]);
pass("gartic: three clients connected");
// A creates gartic room
const create = await emitAck(a, "room:create", {
nickname: "Alice", avatar: "🐱", mode: "gartic",
settings: {},
});
if (!create || !create.ok) throw new Error("create failed: " + JSON.stringify(create));
const code = create.code;
pass("gartic: room:create ok, code=" + code);
// B and C join
const j1 = await emitAck(b, "room:join", { code, nickname: "Bob", avatar: "🐶" });
if (!j1 || !j1.ok) throw new Error("B join failed: " + JSON.stringify(j1));
pass("gartic: B joined");
const j2 = await emitAck(c, "room:join", { code, nickname: "Carol", avatar: "🦊" });
if (!j2 || !j2.ok) throw new Error("C join failed: " + JSON.stringify(j2));
pass("gartic: C joined");
// Each client should receive a "gartic:turn" with task=prompt after game:start
const aTurn0P = waitFor(a, "gartic:turn", () => true, 5000);
const bTurn0P = waitFor(b, "gartic:turn", () => true, 5000);
const cTurn0P = waitFor(c, "gartic:turn", () => true, 5000);
const start = await emitAck(a, "game:start", {});
if (!start || !start.ok) throw new Error("game:start: " + JSON.stringify(start));
pass("gartic: game:start ok");
const aT0 = await aTurn0P;
const bT0 = await bTurn0P;
const cT0 = await cTurn0P;
if (aT0.task !== "prompt" || bT0.task !== "prompt" || cT0.task !== "prompt") throw new Error("expected prompt task, got " + aT0.task + "/" + bT0.task + "/" + cT0.task);
pass("gartic: all three got gartic:turn task=prompt");
// All three submit prompts
const aTurn1P = waitFor(a, "gartic:turn", () => true, 8000);
const bTurn1P = waitFor(b, "gartic:turn", () => true, 8000);
const cTurn1P = waitFor(c, "gartic:turn", () => true, 8000);
const sub1 = await emitAck(a, "gartic:submit", { type: "prompt", data: "a cat" });
if (!sub1 || !sub1.ok) throw new Error("A prompt submit failed: " + JSON.stringify(sub1));
const sub2 = await emitAck(b, "gartic:submit", { type: "prompt", data: "a robot" });
if (!sub2 || !sub2.ok) throw new Error("B prompt submit failed: " + JSON.stringify(sub2));
const sub3 = await emitAck(c, "gartic:submit", { type: "prompt", data: "a tree" });
if (!sub3 || !sub3.ok) throw new Error("C prompt submit failed: " + JSON.stringify(sub3));
pass("gartic: prompts submitted ok");
const aT1 = await aTurn1P;
const bT1 = await bTurn1P;
const cT1 = await cTurn1P;
if (aT1.task !== "drawing" || bT1.task !== "drawing" || cT1.task !== "drawing") throw new Error("expected drawing task, got " + aT1.task);
pass("gartic: all three got next turn task=drawing with content");
if (!aT1.content || !bT1.content || !cT1.content) throw new Error("drawing content missing");
pass("gartic: drawing turn includes prior prompt as content");
// Submit drawings — should advance to guess phase (not done) for 3 players
const tinyImg = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
const aTurn2P = waitFor(a, "gartic:turn", () => true, 8000);
const bTurn2P = waitFor(b, "gartic:turn", () => true, 8000);
const cTurn2P = waitFor(c, "gartic:turn", () => true, 8000);
const d1 = await emitAck(a, "gartic:submit", { type: "drawing", data: tinyImg });
if (!d1 || !d1.ok) throw new Error("A drawing submit failed: " + JSON.stringify(d1));
const d2 = await emitAck(b, "gartic:submit", { type: "drawing", data: tinyImg });
if (!d2 || !d2.ok) throw new Error("B drawing submit failed: " + JSON.stringify(d2));
const d3 = await emitAck(c, "gartic:submit", { type: "drawing", data: tinyImg });
if (!d3 || !d3.ok) throw new Error("C drawing submit failed: " + JSON.stringify(d3));
pass("gartic: drawings submitted ok");
// 3 players: totalTurns = 3 -> turnIndex 0 (prompt), 1 (drawing), 2 (guess)
const aT2 = await aTurn2P;
const bT2 = await bTurn2P;
const cT2 = await cTurn2P;
if (aT2.task !== "guess" || bT2.task !== "guess" || cT2.task !== "guess") throw new Error("expected guess task, got " + aT2.task + "/" + bT2.task + "/" + cT2.task);
pass("gartic: all three got next turn task=guess");
// Submit guesses — book should complete
const bookCompleteP = waitFor(a, "gartic:bookComplete", () => true, 10000);
const g1 = await emitAck(a, "gartic:submit", { type: "guess", data: "kitty" });
if (!g1 || !g1.ok) throw new Error("A guess submit failed: " + JSON.stringify(g1));
const g2 = await emitAck(b, "gartic:submit", { type: "guess", data: "metal man" });
if (!g2 || !g2.ok) throw new Error("B guess submit failed: " + JSON.stringify(g2));
const g3 = await emitAck(c, "gartic:submit", { type: "guess", data: "plant" });
if (!g3 || !g3.ok) throw new Error("C guess submit failed: " + JSON.stringify(g3));
pass("gartic: guesses submitted ok");
const bookComplete = await bookCompleteP;
if (!bookComplete || !Array.isArray(bookComplete.books)) {
throw new Error("expected gartic:bookComplete event");
}
pass("gartic: gartic:bookComplete fired, books=" + bookComplete.books.length);
} catch (e) {
fail("gartic flow", e && e.message ? e.message : String(e));
} finally {
try { a && a.disconnect(); } catch (_) {}
try { b && b.disconnect(); } catch (_) {}
try { c && c.disconnect(); } catch (_) {}
clearTimeout(killer);
console.log("__RESULTS__ " + JSON.stringify(results));
setTimeout(() => process.exit(exitCode), 200);
}
})();
+148
View File
@@ -0,0 +1,148 @@
// E2E socket flow test for SKRIBBL mode.
const { io } = require("socket.io-client");
const URL = "http://localhost:3000";
const TIMEOUT_MS = 25000;
const results = [];
let exitCode = 0;
function pass(name) { results.push({ name, status: "pass" }); console.log("PASS:", name); }
function fail(name, err) { results.push({ name, status: "fail", error: String(err) }); console.log("FAIL:", name, "-", err); exitCode = 1; }
const wait = (ms) => new Promise(r => setTimeout(r, ms));
function waitFor(socket, event, predicate = () => true, timeout = 8000) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`timeout waiting for ${event}`)), timeout);
const handler = (data) => {
if (predicate(data)) {
clearTimeout(t);
socket.off(event, handler);
resolve(data);
}
};
socket.on(event, handler);
});
}
function emitAck(socket, event, payload) {
return new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error(`ack timeout for ${event}`)), 5000);
socket.emit(event, payload, (resp) => {
clearTimeout(t);
resolve(resp);
});
});
}
const killer = setTimeout(() => {
console.log("FAIL: Global timeout reached");
process.exit(1);
}, TIMEOUT_MS);
killer.unref();
(async () => {
let a, b;
try {
a = io(URL, { transports: ["websocket"], forceNew: true });
b = io(URL, { transports: ["websocket"], forceNew: true });
await Promise.all([
new Promise((r) => a.on("connect", r)),
new Promise((r) => b.on("connect", r)),
]);
pass("clients A and B connected");
// 1. A creates a skribbl room
const createResp = await emitAck(a, "room:create", {
nickname: "Alice", avatar: "🐱", mode: "skribbl",
settings: { rounds: 2, drawTimeSec: 60, language: "en" },
});
if (!createResp || !createResp.ok || !createResp.code) throw new Error("create failed: " + JSON.stringify(createResp));
const code = createResp.code;
pass("A: room:create -> ok, code=" + code);
// capture state from B's join
const aStateP = waitFor(a, "room:state", (s) => s.players.length >= 2, 5000);
const bStateP = waitFor(b, "room:state", (s) => s.players.length >= 2, 5000);
// 2. B joins
const joinResp = await emitAck(b, "room:join", { code, nickname: "Bob", avatar: "🐶" });
if (!joinResp || !joinResp.ok) throw new Error("join failed: " + JSON.stringify(joinResp));
pass("B: room:join -> ok");
// 3. Both clients receive room:state with both players
const aState = await aStateP;
const bState = await bStateP;
const aHasBoth = aState.players.find(p => p.nickname === "Alice") && aState.players.find(p => p.nickname === "Bob");
const bHasBoth = bState.players.find(p => p.nickname === "Alice") && bState.players.find(p => p.nickname === "Bob");
if (!aHasBoth || !bHasBoth) throw new Error("room:state missing players");
pass("Both clients receive room:state with both players");
// 4. Chat send/receive
const aChatP = waitFor(a, "chat:msg", (m) => m.text === "hello" && m.fromName === "Alice", 5000);
const bChatP = waitFor(b, "chat:msg", (m) => m.text === "hello" && m.fromName === "Alice", 5000);
a.emit("chat:send", { text: "hello" });
await Promise.all([aChatP, bChatP]);
pass("chat:send broadcasts to both clients");
// 5. Start the game
// Drawer (A is host & first in order) gets skribbl:wordChoices
const choicesP = waitFor(a, "skribbl:wordChoices", () => true, 5000);
const startResp = await emitAck(a, "game:start", {});
if (!startResp || !startResp.ok) throw new Error("start failed: " + JSON.stringify(startResp));
pass("game:start ack ok");
const choices = await choicesP;
if (!Array.isArray(choices) || choices.length < 1) throw new Error("invalid choices");
pass("drawer received skribbl:wordChoices: " + JSON.stringify(choices));
// 6. Drawer picks a word
const pickedWord = choices[0];
// Listen for roundStart on B
const bRoundStartP = waitFor(b, "skribbl:roundStart", () => true, 5000);
const aRoundStartP = waitFor(a, "skribbl:roundStart", () => true, 5000);
const pickResp = await emitAck(a, "skribbl:pickWord", { word: pickedWord });
if (!pickResp || !pickResp.ok) throw new Error("pickWord failed: " + JSON.stringify(pickResp));
pass("skribbl:pickWord ack ok, word=" + pickedWord);
const aRound = await aRoundStartP;
const bRound = await bRoundStartP;
if (!aRound || !bRound) throw new Error("roundStart missing");
pass("both clients received skribbl:roundStart");
// 7. Drawer emits strokes — other client receives them
const strokeP = waitFor(b, "skribbl:stroke", (s) => s && s.color === "#000", 3000);
a.emit("skribbl:stroke", { x: 10, y: 10, prevX: 5, prevY: 5, color: "#000", size: 4 });
a.emit("skribbl:stroke", { x: 20, y: 20, prevX: 10, prevY: 10, color: "#000", size: 4 });
const strokeReceived = await strokeP;
if (!strokeReceived) throw new Error("no stroke received on B");
pass("B received skribbl:stroke from A");
// 8. B guesses correctly
const aCorrectP = waitFor(a, "chat:msg", (m) => m.kind === "correct", 5000);
const bCorrectP = waitFor(b, "chat:msg", (m) => m.kind === "correct", 5000);
b.emit("chat:send", { text: pickedWord });
await Promise.all([aCorrectP, bCorrectP]);
pass("correct guess produces 'correct' chat message on both clients");
// verify state shows score updated
const stateAfterP = waitFor(a, "room:state", (s) => {
const bob = s.players.find(p => p.nickname === "Bob");
return bob && bob.score > 0;
}, 5000);
const stateAfter = await stateAfterP;
pass("room:state shows Bob score > 0 after correct guess: " + stateAfter.players.find(p=>p.nickname==="Bob").score);
// 9. Wait for round end
const roundEndP = waitFor(b, "skribbl:roundEnd", () => true, 8000);
const roundEnd = await roundEndP;
if (!roundEnd || !roundEnd.word) throw new Error("roundEnd missing word");
pass("skribbl:roundEnd fired, word=" + roundEnd.word);
} catch (e) {
fail("flow", e && e.message ? e.message : String(e));
} finally {
try { a && a.disconnect(); } catch (_) {}
try { b && b.disconnect(); } catch (_) {}
clearTimeout(killer);
// Print summary as JSON for parser
console.log("__RESULTS__ " + JSON.stringify(results));
setTimeout(() => process.exit(exitCode), 200);
}
})();
+150
View File
@@ -0,0 +1,150 @@
// Unit tests for server-side game logic. Uses node:test (no extra deps).
// Run with: node test/unit.test.js (NOT `node --test`) — see comment at bottom.
const test = require("node:test");
const assert = require("node:assert");
const {
normalize,
maskWord,
pickRevealIndex,
isCloseGuess,
} = require("../server/word-utils.js");
const roomStore = require("../server/room-store.js");
const { genCode, getRoom, setRoom, deleteRoom, allRooms, makeId } = roomStore;
const { makeRoom, makePlayer } = require("../server/game-state.js");
// Unref the room-store cleanup interval so node exits cleanly after tests.
function unrefAllHandles() {
const handles = process._getActiveHandles ? process._getActiveHandles() : [];
for (const h of handles) {
if (h && typeof h.unref === "function") {
try { h.unref(); } catch (_) {}
}
}
}
unrefAllHandles();
// --- word-utils tests ---
test("normalize: lowercases and trims", () => {
assert.strictEqual(normalize(" HELLO "), "hello");
assert.strictEqual(normalize("Foo Bar"), "foo bar");
assert.strictEqual(normalize("MULTI SPACE"), "multi space");
assert.strictEqual(normalize(""), "");
assert.strictEqual(normalize(undefined), "");
assert.strictEqual(normalize(null), "");
});
test("maskWord: blanks letters but preserves spaces", () => {
const out = maskWord("ice cream");
// letters become "_", space stays
assert.strictEqual(out, "___ _____");
});
test("maskWord: with all indices revealed produces lowercased word", () => {
const word = "Apple";
const revealed = new Set([0, 1, 2, 3, 4]);
const out = maskWord(word, revealed);
assert.strictEqual(out, "apple");
});
test("maskWord: partial reveal mixes letters and underscores", () => {
const out = maskWord("apple", new Set([0, 2]));
// a _ p _ _
assert.strictEqual(out, "a_p__");
});
test("pickRevealIndex: picks unrevealed non-space index", () => {
const word = "ab cd";
const revealed = new Set([0]);
const idx = pickRevealIndex(word, revealed);
// valid candidate indices: 1, 3, 4
assert.ok([1, 3, 4].includes(idx), `unexpected index ${idx}`);
assert.notStrictEqual(idx, 2); // never the space
});
test("pickRevealIndex: returns null when all letters revealed", () => {
const word = "ab";
const revealed = new Set([0, 1]);
assert.strictEqual(pickRevealIndex(word, revealed), null);
});
test("revealHint flow: applying picked index reveals exactly N more letters", () => {
// simulate revealing: count underscores decreases by exactly 1 each step
let revealed = new Set();
const word = "rocket";
let prevMask = maskWord(word, revealed);
let prevUnderscores = (prevMask.match(/_/g) || []).length;
for (let i = 0; i < 3; i++) {
const idx = pickRevealIndex(word, revealed);
assert.notStrictEqual(idx, null);
revealed.add(idx);
const m = maskWord(word, revealed);
const newUnderscores = (m.match(/_/g) || []).length;
assert.strictEqual(newUnderscores, prevUnderscores - 1, "exactly 1 letter revealed");
prevUnderscores = newUnderscores;
prevMask = m;
}
});
test("revealHint idempotence: re-applying same revealed set yields same mask", () => {
const word = "tree";
const revealed = new Set([0, 2]);
const m1 = maskWord(word, revealed);
const m2 = maskWord(word, revealed);
assert.strictEqual(m1, m2);
});
test("isCloseGuess: edit distance 1-2 returns true", () => {
assert.strictEqual(isCloseGuess("aple", "apple"), true); // 1 edit
assert.strictEqual(isCloseGuess("aplee", "apple"), true); // 1 edit
assert.strictEqual(isCloseGuess("apple", "apple"), false); // exact match -> not "close"
assert.strictEqual(isCloseGuess("xyz", "apple"), false); // too far
});
// --- room-store tests ---
test("genCode: returns a unique 6-char alphanumeric code", () => {
const seen = new Set();
for (let i = 0; i < 10; i++) {
const c = genCode();
assert.strictEqual(typeof c, "string");
assert.strictEqual(c.length, 6);
assert.ok(/^[A-Z0-9]+$/.test(c), `code ${c} should be uppercase alnum`);
seen.add(c);
}
assert.strictEqual(seen.size, 10, "codes should be unique");
});
test("setRoom + getRoom: returns same instance, case-insensitive lookup", () => {
const code = genCode();
const player = makePlayer({ nickname: "U", avatar: "x" });
const room = makeRoom({ code, hostId: player.id, mode: "skribbl", settings: {} });
room.players.push(player);
setRoom(code, room);
const got = getRoom(code);
assert.strictEqual(got, room, "same instance");
// lower-case lookup also works
const gotLower = getRoom(code.toLowerCase());
assert.strictEqual(gotLower, room);
deleteRoom(code);
assert.strictEqual(getRoom(code), undefined);
});
test("makeId: returns a non-empty string id", () => {
const id1 = makeId();
const id2 = makeId();
assert.strictEqual(typeof id1, "string");
assert.ok(id1.length > 0);
assert.notStrictEqual(id1, id2);
});
test("allRooms: exposes the underlying Map", () => {
const map = allRooms();
assert.ok(map instanceof Map);
});
// Force exit after a short tick — room-store uses an unrefable setInterval
// that we already unrefed above, but be defensive.
process.on("beforeExit", () => process.exit(0));
setTimeout(() => process.exit(0), 1500).unref();