Add private chat to multiplayer rooms

This commit is contained in:
2026-10-08 10:22:19 +05:00
parent 1307436e65
commit 36e8d11eb4
14 changed files with 810 additions and 4 deletions

View File

@@ -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;
}
}

View File

@@ -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 }) {
</button>
</div>
)}
<RoomChat
key={id}
id={id}
playerId={room.you}
available={room.status !== "closed"}
playing
/>
</>
);
const owner = !!room?.you && room.you === room.hostId;
@@ -362,6 +370,13 @@ export function MultiplayerRoom({ id }: { id: string }) {
</>
)}
</main>
<RoomChat
key={id}
id={id}
playerId={room?.you ?? null}
available={!!room && room.status !== "closed"}
playing={false}
/>
</>
);
}

245
components/RoomChat.tsx Normal file
View File

@@ -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<ChatView["messages"]>([]);
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<HTMLInputElement>(null);
const toggle = useRef<HTMLButtonElement>(null);
const log = useRef<HTMLDivElement>(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<ChatView>(`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<typeof setTimeout>;
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<ChatView>(`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 (
<aside
className={`room-chat${playing ? " room-chat-playing" : ""}`}
aria-label={t("Room chat")}
>
<button
ref={toggle}
className="button room-chat-toggle"
aria-expanded={open}
aria-controls="room-chat-panel"
onClick={() => setOpen((value) => !value)}
>
<MessageCircle size={18} aria-hidden="true" /> {t("Chat")}
{unread > 0 && (
<span
className="room-chat-unread"
aria-label={t("{count} unread messages", { count: unread })}
>
{unread}
</span>
)}
</button>
<section
id="room-chat-panel"
hidden={!open}
onKeyDown={(event) => {
if (event.key === "Escape") {
setOpen(false);
toggle.current?.focus();
}
}}
>
<div className="room-chat-heading">
<strong>{t("Room chat")}</strong>
<button
type="button"
aria-label={t("Close chat")}
onClick={() => {
setOpen(false);
toggle.current?.focus();
}}
>
<X size={18} />
</button>
</div>
<div
className="room-chat-log"
ref={log}
role="log"
aria-live={open ? "polite" : "off"}
aria-relevant="additions"
aria-label={t("Chat messages")}
onScroll={(event) => {
const element = event.currentTarget;
followLatest.current =
element.scrollHeight - element.scrollTop - element.clientHeight <
40;
}}
>
{!messages.length && (
<p className="muted">{t("Say hello to your teammates.")}</p>
)}
{messages.map((message) => (
<p
key={message.id}
className={message.playerId === playerId ? "room-chat-mine" : ""}
>
<strong>
{message.name}
{message.playerId === playerId && ` ${t("(you)")}`}
</strong>
<span>{message.text}</span>
</p>
))}
</div>
{error && (
<p className="error-message" role="alert">
{t(error)}{" "}
<button className="text-link" onClick={() => void load()}>
{t("Retry")}
</button>
</p>
)}
<form
onSubmit={(event) => {
event.preventDefault();
void send();
}}
>
<label className="sr-only" htmlFor="room-chat-input">
{t("Message")}
</label>
<input
id="room-chat-input"
ref={input}
value={text}
maxLength={500}
autoComplete="off"
disabled={sending}
placeholder={t("Message your teammates…")}
onChange={(event) => setText(event.target.value)}
/>
<button
className="button primary"
disabled={sending || !text.trim()}
aria-label={t("Send message")}
>
<Send size={18} aria-hidden="true" />
</button>
</form>
</section>
</aside>
);
}

View File

@@ -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

View File

@@ -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

View File

@@ -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<string, string | number> = {};
if (request.method === "POST" && path.length === 3) {
const input = await jsonBody(request);

View File

@@ -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 (: <script>window.chatInjected=true</script>");
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();

View File

@@ -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 <b>plain text</b>",
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) {

View File

@@ -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, "Привет (: <script>alert(1)</script>", randomUUID())
).messages;
assert.equal(messages[1].name, "Friend");
assert.equal(messages[1].text, "Привет (: <script>alert(1)</script>");
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();

View File

@@ -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: {

View File

@@ -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": "Недопустимое сообщение чата"
}

View File

@@ -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),
});

View File

@@ -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;

View File

@@ -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: "<script>alert(1)</script>",
}).text,
"<script>alert(1)</script>",
);
});
test("room limits include the owner and stay between 2 and 16", () => {
for (const playerLimit of [2, 16])
assert.equal(