diff --git a/app/multiplayer.css b/app/multiplayer.css index 624afff..66a028c 100644 --- a/app/multiplayer.css +++ b/app/multiplayer.css @@ -215,3 +215,134 @@ align-items: center; gap: 8px; } + +.room-chat { + position: fixed; + bottom: max(20px, env(safe-area-inset-bottom)); + left: max(20px, env(safe-area-inset-left)); + z-index: 70; + width: min(350px, calc(100vw - 40px)); + color: var(--text); +} +.room-chat-toggle { + background: var(--panel, #1a222c); + box-shadow: 0 4px 18px #0005; + min-height: 44px; +} +.room-chat-unread { + border-radius: 50%; + min-width: 22px; + padding: 2px 5px; + background: var(--accent, #8dce66); + color: #172210; + font: 12px sans-serif; +} +.room-chat section { + position: absolute; + bottom: calc(100% + 8px); + width: 100%; + border: 1px solid var(--line, #35404c); + border-radius: 12px; + background: var(--panel, #1a222c); + box-shadow: 0 8px 32px #0006; + overflow: hidden; +} +.room-chat:not(.room-chat-playing) { + position: static; + width: 90%; + max-width: 900px; + margin: 0 auto 40px; +} +.room-chat:not(.room-chat-playing) section { + position: static; + margin-top: 8px; +} +.room-chat-heading { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + border-bottom: 1px solid var(--line); +} +.room-chat-heading button { + display: grid; + place-items: center; + width: 44px; + height: 44px; + background: transparent; + border: 0; + color: inherit; + cursor: pointer; +} +.room-chat-log { + height: min(260px, 30dvh); + overflow-y: auto; + overscroll-behavior: contain; + padding: 12px; +} +.room-chat-log p { + margin: 0 0 14px; + overflow-wrap: anywhere; + font-size: 14px; +} +.room-chat-log strong, +.room-chat-log span { + display: block; +} +.room-chat-log strong { + font-size: 12px; + margin-bottom: 3px; +} +.room-chat-mine strong { + color: var(--accent, #8dce66); +} +.room-chat form { + display: flex; + gap: 8px; + padding: 10px; + border-top: 1px solid var(--line); +} +.room-chat input { + flex: 1; + min-width: 0; + min-height: 44px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--bg, #131b24); + color: inherit; + font: inherit; + font-size: 16px; +} +.room-chat form .button { + padding: 10px 12px; +} +.room-chat .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.room-chat .error-message { + margin: 0; + padding: 0 12px 10px; + font-size: 13px; +} +@media (max-width: 700px) { + .room-chat { + left: max(10px, env(safe-area-inset-left)); + width: min(350px, calc(100vw - 20px)); + } + .room-chat-playing { + bottom: calc(max(10px, env(safe-area-inset-bottom)) + 68px); + } + .room-chat-playing section { + max-height: calc(100dvh - 220px); + overflow-y: auto; + } +} diff --git a/components/MultiplayerRoom.tsx b/components/MultiplayerRoom.tsx index 8bc91f8..b3bcb1a 100644 --- a/components/MultiplayerRoom.tsx +++ b/components/MultiplayerRoom.tsx @@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"; import { Copy, Users, Crown, Trophy } from "lucide-react"; import { Brand } from "./Brand"; import { MultiplayerGame } from "./MultiplayerGame"; +import { RoomChat } from "./RoomChat"; import { useI18n } from "./I18nProvider"; import { api } from "@/lib/client-api"; import { roundClock } from "@/shared/round-clock"; @@ -125,6 +126,13 @@ export function MultiplayerRoom({ id }: { id: string }) { )} + ); const owner = !!room?.you && room.you === room.hostId; @@ -362,6 +370,13 @@ export function MultiplayerRoom({ id }: { id: string }) { )} + ); } diff --git a/components/RoomChat.tsx b/components/RoomChat.tsx new file mode 100644 index 0000000..64424e5 --- /dev/null +++ b/components/RoomChat.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { MessageCircle, Send, X } from "lucide-react"; +import { api } from "@/lib/client-api"; +import { + chatMessageSchema, + type RoomChat as ChatView, +} from "@/shared/multiplayer"; +import { useI18n } from "./I18nProvider"; + +export function RoomChat({ + id, + playerId, + available, + playing, +}: { + id: string; + playerId: string | null; + available: boolean; + playing: boolean; +}) { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const [messages, setMessages] = useState([]); + const [text, setText] = useState(""); + const [error, setError] = useState(""); + const [sending, setSending] = useState(false); + const [lastRead, setLastRead] = useState(0n); + const version = useRef(0); + const inFlight = useRef(false); + const initialized = useRef(false); + const pending = useRef<{ text: string; clientId: string } | null>(null); + const input = useRef(null); + const toggle = useRef(null); + const log = useRef(null); + const followLatest = useRef(true); + const enabled = !!playerId && available; + const accept = useCallback((view: ChatView) => { + setMessages((previous) => + previous.length === view.messages.length && + previous.at(-1)?.id === view.messages.at(-1)?.id + ? previous + : view.messages, + ); + if (!initialized.current) { + initialized.current = true; + setLastRead(BigInt(view.messages.at(-1)?.id ?? "0")); + } + setError(""); + }, []); + const load = useCallback(async () => { + if (inFlight.current) return; + const current = ++version.current; + try { + const view = await api(`rooms/${id}/chat`); + if (current === version.current) accept(view); + } catch (failure) { + if (current === version.current) setError((failure as Error).message); + } + }, [id, accept]); + useEffect(() => { + if (!enabled) return; + let stopped = false; + let timer: ReturnType; + const poll = async () => { + await load(); + if (!stopped) timer = setTimeout(poll, document.hidden ? 5000 : 2000); + }; + void poll(); + const resume = () => { + if (!document.hidden) void load(); + }; + document.addEventListener("visibilitychange", resume); + return () => { + stopped = true; + version.current++; + clearTimeout(timer); + document.removeEventListener("visibilitychange", resume); + }; + }, [enabled, load]); + useEffect(() => { + if (open) { + setLastRead(BigInt(messages.at(-1)?.id ?? "0")); + if (log.current && followLatest.current) + log.current.scrollTop = log.current.scrollHeight; + } + }, [open, messages]); + useEffect(() => { + if (open) { + followLatest.current = true; + if (log.current) log.current.scrollTop = log.current.scrollHeight; + input.current?.focus(); + } + }, [open]); + async function send() { + if (inFlight.current || !enabled) return; + const draft = text.trim(); + const body = + pending.current?.text === draft + ? pending.current + : { text: draft, clientId: crypto.randomUUID() }; + if (!chatMessageSchema.safeParse(body).success) { + setError("Write a message of 1–500 characters without hidden controls"); + return; + } + pending.current = body; + inFlight.current = true; + setSending(true); + setError(""); + const current = ++version.current; + try { + const view = await api(`rooms/${id}/chat`, body); + if (current === version.current) { + accept(view); + followLatest.current = true; + setText(""); + pending.current = null; + } + } catch (failure) { + if (current === version.current) setError((failure as Error).message); + } finally { + inFlight.current = false; + setSending(false); + input.current?.focus(); + } + } + if (!enabled) return null; + const unread = messages.filter( + (message) => message.playerId !== playerId && BigInt(message.id) > lastRead, + ).length; + return ( + + ); +} diff --git a/docs/multiplayer.md b/docs/multiplayer.md index 2d6424e..2610d06 100644 --- a/docs/multiplayer.md +++ b/docs/multiplayer.md @@ -27,12 +27,23 @@ Room state refreshes every two seconds, or every five seconds in a hidden tab. Unfinished rooms expire after 24 hours. Multiplayer results are separate from the solo leaderboard. +Room chat is available to current players in the lobby, during rounds, and at +the final standings. Open **Chat** to send a plain-text message of up to 500 +characters. A badge shows unread messages while it is collapsed. Messages +refresh every two seconds and the latest 100 are restored after a refresh. +Sending chat does not lock the guess controls or change the round clock. +Removed players, players who left, and invite visitors cannot read or send +messages. Closed rooms and rooms older than 24 hours cannot use chat. +Retries are deduplicated; each player can send at most one new message per +second and 30 per minute. Player names come from the room identity. + ## Database and verification Apply `supabase/migrations/20261007071259_multiplayer_rooms.sql` to a new database before deploying the web update. It has already been applied to MineGeo's configured Supabase project. Deployments continue through `npm run deploy` without a Git-provider connection. +Chat additionally requires `supabase/migrations/20261007155622_multiplayer_chat.sql`. - `npm test`: validation and existing regression checks. - `npm run test:multiplayer`: isolated PostgreSQL state, permission, and scoring diff --git a/docs/vercel-deployment.md b/docs/vercel-deployment.md index c40f592..e3890fd 100644 --- a/docs/vercel-deployment.md +++ b/docs/vercel-deployment.md @@ -41,6 +41,7 @@ additional Supabase credentials. Multiplayer uses the Supabase migration described in [multiplayer.md](multiplayer.md). Apply that migration to a new database before deploying the multiplayer web files. +Room chat also requires `20261007155622_multiplayer_chat.sql` before the web update. Map ZIP sharing requires `20261007145000_map_sharing.sql`. Imported maps stay private and can be played without moderation; publication is blocked. See diff --git a/lib/multiplayer-api.ts b/lib/multiplayer-api.ts index dbb4773..3f9b895 100644 --- a/lib/multiplayer-api.ts +++ b/lib/multiplayer-api.ts @@ -11,6 +11,7 @@ import { joinRoomSchema, roomRoundSchema, roomGuessSchema, + chatMessageSchema, type RoomView, } from "../shared/multiplayer"; import { @@ -68,6 +69,29 @@ export async function multiplayerApi( } const id = z.string().uuid().parse(path[1]); const action = path[2] ?? "view"; + if ( + action === "chat" && + path.length === 3 && + ["GET", "POST"].includes(request.method) + ) { + if (!savedSecret) + throw new ServiceError("Chat permission requires joining this room", 403); + const body = + request.method === "POST" + ? chatMessageSchema.parse(await jsonBody(request)) + : null; + if (body) await limit("multiplayer:chat:" + accessHash, 60, 60); + return respond( + required( + await client.rpc("multiplayer_chat", { + p_room: id, + p_access_hash: accessHash, + p_text: body?.text ?? null, + p_client_id: body?.clientId ?? null, + }), + ), + ); + } let payload: Record = {}; if (request.method === "POST" && path.length === 3) { const input = await jsonBody(request); diff --git a/scripts/test-multiplayer-browser.mjs b/scripts/test-multiplayer-browser.mjs index eb2b02c..92b7138 100644 --- a/scripts/test-multiplayer-browser.mjs +++ b/scripts/test-multiplayer-browser.mjs @@ -57,6 +57,7 @@ try { waitUntil: "domcontentloaded", timeout: 60000, }); + await expect(friend.getByRole("button", { name: /^Chat/ })).toHaveCount(0); await friend.getByLabel("Your player name").fill("Browser Friend"); await friend.getByRole("button", { name: "Join room", exact: true }).click(); await expect( @@ -67,6 +68,29 @@ try { await expect( host.getByRole("button", { name: "Start multiplayer match", exact: true }), ).toBeEnabled({ timeout: 20000 }); + await host.getByRole("button", { name: /^Chat/ }).click(); + await host + .getByLabel("Message", { exact: true }) + .fill("Hello from the lobby (: "); + await host.getByRole("button", { name: "Send message", exact: true }).click(); + await expect(host.getByRole("log")).toContainText("Hello from the lobby"); + await expect(friend.locator(".room-chat-unread")).toHaveText("1", { + timeout: 15000, + }); + await friend.getByRole("button", { name: /^Chat/ }).click(); + await expect(friend.getByRole("log")).toContainText("Hello from the lobby"); + assert.equal( + await friend.evaluate(() => window.chatInjected), + undefined, + "chat renders HTML as plain text", + ); + await friend + .getByLabel("Message", { exact: true }) + .fill("Привет с телефона!"); + await friend.getByLabel("Message", { exact: true }).press("Enter"); + await expect(host.getByRole("log")).toContainText("Привет с телефона!", { + timeout: 15000, + }); fs.mkdirSync("artifacts/multiplayer", { recursive: true }); await host.screenshot({ path: "artifacts/multiplayer/desktop-lobby.png", @@ -85,6 +109,11 @@ try { await host .getByRole("button", { name: "Start multiplayer match", exact: true }) .click(); + await expect(friend.getByRole("log")).toContainText("Hello from the lobby", { + timeout: 15000, + }); + await friend.getByRole("button", { name: "Close chat", exact: true }).click(); + await host.getByRole("button", { name: "Close chat", exact: true }).click(); for (let round = 0; round < 5; round++) { await Promise.all([ expect(host.locator(".game-info strong").first()).toHaveText( @@ -107,6 +136,36 @@ try { await expect(host.locator(".game-loading")).toHaveCount(0, { timeout: 45000, }); + await host.getByRole("button", { name: /^Chat/ }).click(); + await expect(host.getByRole("log")).toContainText("Привет с телефона!"); + await host + .getByLabel("Message", { exact: true }) + .fill("Good luck this round!"); + await host + .getByRole("button", { name: "Send message", exact: true }) + .click(); + await expect(friend.locator(".room-chat-unread")).toHaveText("1", { + timeout: 15000, + }); + await friend.getByRole("button", { name: /^Chat/ }).click(); + await expect(friend.getByRole("log")).toContainText( + "Good luck this round!", + ); + await friend.screenshot({ + path: "artifacts/multiplayer/mobile-chat.png", + }); + await host.screenshot({ path: "artifacts/multiplayer/desktop-chat.png" }); + assert.ok( + await friend.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + "mobile chat fits the viewport", + ); + await friend.getByLabel("Message", { exact: true }).press("Escape"); + await expect(friend.getByRole("button", { name: /^Chat/ })).toBeFocused(); + await host + .getByRole("button", { name: "Close chat", exact: true }) + .click(); } await friend .getByRole("button", { name: "Open guess map", exact: true }) @@ -150,6 +209,17 @@ try { friend.getByRole("heading", { name: "Final standings", exact: true }), ).toBeVisible({ timeout: 20000 }); assert.equal(await host.locator(".multiplayer-final tbody tr").count(), 2); + await friend.getByRole("button", { name: /^Chat/ }).click(); + await friend + .getByLabel("Message", { exact: true }) + .fill("GG, thanks for playing!"); + await friend + .getByRole("button", { name: "Send message", exact: true }) + .click(); + await host.getByRole("button", { name: /^Chat/ }).click(); + await expect(host.getByRole("log")).toContainText("GG, thanks for playing!", { + timeout: 15000, + }); await host.screenshot({ path: "artifacts/multiplayer/desktop-results.png", fullPage: true, @@ -166,7 +236,7 @@ try { ); assert.deepEqual(pageErrors, []); console.log( - "Desktop/mobile browser multiplayer passed: create, invite, join, reconnect, five shared rounds, map guesses, and final standings. No page errors.", + "Desktop/mobile browser multiplayer passed: lobby and in-game chat, unread badges, plain-text rendering, keyboard controls, reconnect, five shared rounds, map guesses, and final standings chat. No page errors.", ); } finally { await browser.close(); diff --git a/scripts/test-multiplayer-http.mjs b/scripts/test-multiplayer-http.mjs index 9952e09..3654c05 100644 --- a/scripts/test-multiplayer-http.mjs +++ b/scripts/test-multiplayer-http.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { createClient } from "@supabase/supabase-js"; if (existsSync(".env.local")) process.loadEnvFile(".env.local"); @@ -52,6 +53,57 @@ try { assert.deepEqual(preview.rounds, []); await outsider(`${path}/media`, undefined, 403); const joined = await friend(`${path}/join`, { displayName: "HTTP Friend" }); + await outsider(`${path}/chat`, undefined, 403); + await outsider( + `${path}/chat`, + { text: "Intruder", clientId: randomUUID() }, + 403, + ); + const sent = { + text: "HTTP hello plain text", + clientId: randomUUID(), + name: "Forged", + }; + const messages = (await host(`${path}/chat`, sent)).messages; + assert.equal( + messages[0].name, + "HTTP Host", + "sender name is server-controlled", + ); + assert.equal(messages[0].playerId, hostView.you); + assert.equal((await host(`${path}/chat`, sent)).messages.length, 1); + assert.deepEqual((await friend(`${path}/chat`)).messages, messages); + await friend(`${path}/chat`, { text: " ", clientId: randomUUID() }, 400); + await friend( + `${path}/chat`, + { text: "x".repeat(501), clientId: randomUUID() }, + 400, + ); + await friend( + `${path}/chat`, + { text: "x\u202e", clientId: randomUUID() }, + 400, + ); + const concurrentMessages = await Promise.allSettled([ + friend(`${path}/chat`, { + text: "First simultaneous send", + clientId: randomUUID(), + }), + friend(`${path}/chat`, { + text: "Second simultaneous send", + clientId: randomUUID(), + }), + ]); + assert.equal( + concurrentMessages.filter((result) => result.status === "fulfilled").length, + 1, + "only one simultaneous new message succeeds", + ); + assert.match( + concurrentMessages.find((result) => result.status === "rejected").reason + .message, + /Too many chat messages/, + ); assert.equal( (await friend(`${path}/join`, { displayName: "HTTP Other" })).you, joined.you, @@ -122,6 +174,18 @@ try { } assert.equal(results.players[0].id, hostView.you); assert.equal(results.players[0].total, 25000); + assert.equal( + (await host(`${path}/chat`)).messages.length, + 2, + "messages persist over five rounds", + ); + await friend(`${path}/leave`, {}); + await friend(`${path}/chat`, undefined, 403); + await friend( + `${path}/chat`, + { text: "After leaving", clientId: randomUUID() }, + 403, + ); assert.equal(results.rounds.flatMap((round) => round.results).length, 10); const capacityHost = player(); const full = await capacityHost("rooms", { @@ -149,7 +213,7 @@ try { assert.match(rejection.reason.message, /This room is full/); assert.equal((await capacityHost(`rooms/${full.id}`)).playerCount, 16); console.log( - "Multiplayer HTTP smoke passed: private invite joining, shared signed media and timers, five-round ranking, reconnects, retries, and simultaneous 16-player capacity checks.", + "Multiplayer HTTP smoke passed: private room chat, trusted sender names, validation, concurrent-send throttling, retries, revoked membership, five shared rounds, signed media, and simultaneous 16-player capacity checks.", ); } finally { if (rooms.length) { diff --git a/scripts/test-multiplayer-sql.mjs b/scripts/test-multiplayer-sql.mjs index 63dfbe1..322ed9c 100644 --- a/scripts/test-multiplayer-sql.mjs +++ b/scripts/test-multiplayer-sql.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; import { readFile, readdir } from "node:fs/promises"; import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js"; @@ -83,6 +84,46 @@ try { await assert.rejects(act(room, 3, "start"), /membership permission/); const joined = await act(room, 2, "join", { name: "Friend" }); const friend = joined.you; + const chat = async (roomId, who, text = null, clientId = null) => + ( + await db.query("select public.multiplayer_chat($1,$2,$3,$4) as chat", [ + roomId, + secret(who), + text, + clientId, + ]) + ).rows[0].chat; + const messageId = randomUUID(); + let messages = (await chat(room, 1, "Hello, friend!", messageId)).messages; + assert.equal(messages[0].name, "Host"); + assert.equal(messages[0].playerId, host); + assert.equal(messages[0].text, "Hello, friend!"); + assert.equal(typeof messages[0].id, "string"); + assert.deepEqual((await chat(room, 2)).messages, messages); + assert.equal( + (await chat(room, 1, "Hello, friend!", messageId)).messages.length, + 1, + "retry is idempotent", + ); + await assert.rejects(chat(room, 1, "Spam", randomUUID()), /Too many chat/); + await assert.rejects(chat(room, 3), /Chat permission/); + await assert.rejects(chat(room, 3, "Spoof", randomUUID()), /Chat permission/); + for (const text of [" ", "x".repeat(501), "x\nname", "x\u202e", "x\u0085"]) + await assert.rejects(chat(room, 2, text, randomUUID()), /Invalid chat/); + await assert.rejects(chat(room, 2, "Missing nonce"), /Invalid chat/); + messages = ( + await chat(room, 2, "Привет (: ", randomUUID()) + ).messages; + assert.equal(messages[1].name, "Friend"); + assert.equal(messages[1].text, "Привет (: "); + assert.ok(!JSON.stringify(messages).includes("access_hash")); + assert.ok(!JSON.stringify(messages).includes("viewpoint")); + const otherRoom = await create(); + assert.deepEqual( + (await chat(otherRoom, 1)).messages, + [], + "room histories stay separate", + ); assert.equal( (await act(room, 2, "join", { name: "Changed name" })).you, friend, @@ -103,6 +144,11 @@ try { view = await act(room, 2, "ready", { round: 0 }); assert.equal(view.status, "playing"); const deadline = view.rounds[0].deadline; + assert.equal( + (await chat(room, 1)).messages.length, + 2, + "chat persists into the game", + ); assert.equal( (await act(room, 1, "ready", { round: 0 })).rounds[0].deadline, deadline, @@ -185,6 +231,11 @@ try { } else view = await act(room, 2, "guess", { round, x: 0, z: 0 }); } assert.equal(view.status, "finished"); + assert.equal( + (await chat(room, 2)).messages.length, + 2, + "chat remains available at final standings", + ); assert.equal(view.players[0].id, host); assert.equal(view.players[0].total, 25000); assert.equal(view.players[0].rank, 1); @@ -201,9 +252,14 @@ try { assert.equal((await act(capacity, 1)).playerCount, 16); const removed = (await act(capacity, 16)).you; await act(capacity, 1, "kick", { playerId: removed }); + await assert.rejects(chat(capacity, 16), /Chat permission/); assert.equal((await act(capacity, 16)).you, null); await act(capacity, 17, "join", { name: "Replacement" }); await act(capacity, 1, "leave"); + await assert.rejects( + chat(capacity, 1, "Left", randomUUID()), + /Chat permission/, + ); assert.equal( (await act(capacity, 2)).hostId, (await act(capacity, 2)).you, @@ -257,8 +313,35 @@ try { view = await act(stalled, 1, "guess", { round: 1, x: point.x, z: point.z }); assert.equal(view.status, "results", "departed players cannot block scoring"); await act(stalled, 1, "close"); + await assert.rejects(chat(stalled, 1), /closed or expired/); assert.equal((await act(stalled, 1)).status, "closed"); + const expired = await create(); + await db.query( + "update public.multiplayer_rooms set created_at=now()-interval '25 hours' where id=$1", + [expired], + ); + await assert.rejects(chat(expired, 1), /closed or expired/); + const history = await create(); + const historyHost = (await act(history, 1)).you; + await db.query( + `insert into public.multiplayer_messages(room_id,player_id,client_id,display_name,body,created_at) + select $1,$2,gen_random_uuid(),'Host','message ' || n,now()-interval '2 minutes' from generate_series(1,120) n`, + [history, historyHost], + ); + const recent = (await chat(history, 1)).messages; + assert.equal(recent.length, 100); + assert.equal(recent[0].text, "message 21"); + assert.equal(recent[99].text, "message 120"); + await db.query( + "update public.multiplayer_messages set created_at=clock_timestamp()-interval '2 seconds' where room_id=$1", + [history], + ); + await assert.rejects( + chat(history, 1, "Minute flood", randomUUID()), + /Too many chat/, + ); + for (const role of ["anon", "authenticated"]) { await db.exec(`set role ${role}`); for (const table of [ @@ -266,17 +349,19 @@ try { "multiplayer_players", "multiplayer_rounds", "multiplayer_guesses", + "multiplayer_messages", ]) await assert.rejects( db.query(`select * from public.${table}`), /permission denied/, ); await assert.rejects(act(room, 1), /permission denied/); + await assert.rejects(chat(room, 1), /permission denied/); await assert.rejects(create(), /permission denied/); await db.exec("reset role"); } console.log( - "Multiplayer SQL checks passed: 16-player cap, owner permissions, private answers, synchronized rounds, deadlines, retries, ties, departure, and client-role isolation.", + "Multiplayer SQL checks passed: room chat privacy, membership revocation, message validation, bounded history, rate limits, retries, expiration, 16-player cap, synchronized rounds, and client-role isolation.", ); } finally { await db.close(); diff --git a/shared/database.ts b/shared/database.ts index 120ec46..f53f4c7 100644 --- a/shared/database.ts +++ b/shared/database.ts @@ -43,6 +43,27 @@ export type Database = { }; public: { Tables: { + multiplayer_messages: { + Row: { + id: number; + room_id: string; + player_id: string; + client_id: string; + display_name: string; + body: string; + created_at: string; + }; + Insert: { + room_id: string; + player_id: string; + client_id: string; + display_name: string; + body: string; + created_at?: string; + }; + Update: { body?: string }; + Relationships: []; + }; multiplayer_rooms: { Row: { id: string; @@ -668,6 +689,15 @@ export type Database = { }; Returns: Json; }; + multiplayer_chat: { + Args: { + p_room: string; + p_access_hash: string; + p_text: string | null; + p_client_id: string | null; + }; + Returns: Json; + }; legacy_password_needed: { Args: { p_user: string }; Returns: boolean }; claim_legacy_password: { Args: { p_user: string }; Returns: boolean }; set_beta_badge: { diff --git a/shared/locales/ru.json b/shared/locales/ru.json index d59b7e5..8aa8dc9 100644 --- a/shared/locales/ru.json +++ b/shared/locales/ru.json @@ -700,5 +700,19 @@ "This map is not ready to export": "Эта карта ещё не готова к экспорту", "Could not export map": "Не удалось экспортировать карту", "Could not import map": "Не удалось импортировать карту", - "Close": "Закрыть" + "Close": "Закрыть", + "Room chat": "Чат комнаты", + "Chat": "Чат", + "Close chat": "Закрыть чат", + "Chat messages": "Сообщения чата", + "Message": "Сообщение", + "Message your teammates…": "Сообщение друзьям…", + "Send message": "Отправить сообщение", + "Say hello to your teammates.": "Поздоровайтесь с друзьями.", + "{count} unread messages": "Непрочитанных сообщений: {count}", + "Write a message of 1–500 characters without hidden controls": "Напишите сообщение от 1 до 500 символов без скрытых управляющих символов", + "Chat permission requires joining this room": "Чтобы пользоваться чатом, войдите в эту комнату", + "Chat is unavailable in a closed or expired room": "Чат недоступен в закрытой комнате или комнате с истёкшим сроком", + "Too many chat messages. Wait a moment and try again.": "Слишком много сообщений. Подождите немного и попробуйте снова.", + "Invalid chat message": "Недопустимое сообщение чата" } diff --git a/shared/multiplayer.ts b/shared/multiplayer.ts index f4596a9..3d7f0f3 100644 --- a/shared/multiplayer.ts +++ b/shared/multiplayer.ts @@ -9,6 +9,28 @@ export const createRoomSchema = z.object({ playerLimit: z.number().int().min(2).max(16), }); export const joinRoomSchema = z.object({ displayName: displayNameSchema }); +export const chatMessageSchema = z.object({ + clientId: z.string().uuid(), + text: z + .string() + .trim() + .min(1) + .max(500) + .refine( + (text) => + !/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(text), + "Messages cannot contain hidden controls", + ), +}); +export type RoomChat = { + messages: { + id: string; + playerId: string; + name: string; + text: string; + createdAt: string; + }[]; +}; export const roomRoundSchema = z.object({ round: z.number().int().min(0).max(4), }); diff --git a/supabase/migrations/20261007155622_multiplayer_chat.sql b/supabase/migrations/20261007155622_multiplayer_chat.sql new file mode 100644 index 0000000..41843cc --- /dev/null +++ b/supabase/migrations/20261007155622_multiplayer_chat.sql @@ -0,0 +1,58 @@ +-- Room cookies are verified by the API; browser roles cannot access chat directly. +create table public.multiplayer_messages ( + id bigint generated always as identity primary key, + room_id uuid not null references public.multiplayer_rooms(id) on delete cascade, + player_id uuid not null, + client_id uuid not null, + display_name text not null, + body text not null check (length(btrim(body)) between 1 and 500 + and body !~ '[[:cntrl:]]' and body !~ U&'[\0080-\009F\202A-\202E\2066-\2069]'), + created_at timestamptz not null default clock_timestamp(), + foreign key(room_id,player_id) references public.multiplayer_players(room_id,id) on delete cascade, + unique(room_id,player_id,client_id) +); +create index multiplayer_messages_history on public.multiplayer_messages(room_id,id desc); +create index multiplayer_messages_rate on public.multiplayer_messages(room_id,player_id,created_at desc); +alter table public.multiplayer_messages enable row level security; +revoke all on public.multiplayer_messages from public,anon,authenticated; +revoke all on sequence public.multiplayer_messages_id_seq from public,anon,authenticated; +grant all on public.multiplayer_messages to service_role; +grant usage,select on sequence public.multiplayer_messages_id_seq to service_role; + +create function public.multiplayer_chat(p_room uuid,p_access_hash text,p_text text default null,p_client_id uuid default null) +returns jsonb language plpgsql security invoker set search_path='' as $$ +declare room public.multiplayer_rooms; player public.multiplayer_players; +begin + -- Match the room action lock order, including kicks/leaves/closure. Duplicate + -- sends and rate checks are serialized without touching game timers or guesses. + select * into room from public.multiplayer_rooms where id=p_room for update; + if not found then raise exception 'Room not found'; end if; + select * into player from public.multiplayer_players + where room_id=p_room and access_hash=p_access_hash and not has_left; + if not found then raise exception 'Chat permission requires joining this room'; end if; + if room.status='closed' or room.created_at < clock_timestamp()-interval '24 hours' then + raise exception 'Chat is unavailable in a closed or expired room'; + end if; + if p_text is not null or p_client_id is not null then + if p_text is null or p_client_id is null or length(btrim(p_text)) not between 1 and 500 + or p_text ~ '[[:cntrl:]]' or p_text ~ U&'[\0080-\009F\202A-\202E\2066-\2069]' then + raise exception 'Invalid chat message'; + end if; + if not exists(select 1 from public.multiplayer_messages + where room_id=p_room and player_id=player.id and client_id=p_client_id) then + if exists(select 1 from public.multiplayer_messages where room_id=p_room and player_id=player.id + and created_at > clock_timestamp()-interval '1 second') + or (select count(*) from public.multiplayer_messages where room_id=p_room and player_id=player.id + and created_at > clock_timestamp()-interval '1 minute') >= 30 then + raise exception 'Too many chat messages. Wait a moment and try again.'; + end if; + insert into public.multiplayer_messages(room_id,player_id,client_id,display_name,body) + values(p_room,player.id,p_client_id,player.display_name,btrim(p_text)); + end if; + end if; + return jsonb_build_object('messages',coalesce((select jsonb_agg(jsonb_build_object( + 'id',m.id::text,'playerId',m.player_id,'name',m.display_name,'text',m.body,'createdAt',m.created_at + ) order by m.id) from (select * from public.multiplayer_messages where room_id=p_room order by id desc limit 100) m),'[]'::jsonb)); +end; $$; +revoke all on function public.multiplayer_chat(uuid,text,text,uuid) from public,anon,authenticated; +grant execute on function public.multiplayer_chat(uuid,text,text,uuid) to service_role; diff --git a/tests/multiplayer.test.ts b/tests/multiplayer.test.ts index bde8fe2..3a5191a 100644 --- a/tests/multiplayer.test.ts +++ b/tests/multiplayer.test.ts @@ -4,9 +4,45 @@ import { createRoomSchema, joinRoomSchema, roomGuessSchema, + chatMessageSchema, } from "../shared/multiplayer"; const map = "00000000-0000-4000-8000-000000000000"; +test("chat accepts plain multilingual text and rejects blank, oversized or hidden-control messages", () => { + assert.equal( + chatMessageSchema.parse({ clientId: map, text: " Привет (: " }).text, + "Привет (:", + ); + assert.equal( + chatMessageSchema.safeParse({ clientId: map, text: "x".repeat(500) }) + .success, + true, + ); + for (const text of [ + "", + " ", + "x".repeat(501), + "hidden\u202e", + "hidden\u0085", + "x\nname", + "x\u0000name", + ]) + assert.equal( + chatMessageSchema.safeParse({ clientId: map, text }).success, + false, + ); + assert.equal( + chatMessageSchema.safeParse({ clientId: "wrong", text: "hi" }).success, + false, + ); + assert.equal( + chatMessageSchema.parse({ + clientId: map, + text: "", + }).text, + "", + ); +}); test("room limits include the owner and stay between 2 and 16", () => { for (const playerLimit of [2, 16]) assert.equal(