Add private chat to multiplayer rooms
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
245
components/RoomChat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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": "Недопустимое сообщение чата"
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
58
supabase/migrations/20261007155622_multiplayer_chat.sql
Normal file
58
supabase/migrations/20261007155622_multiplayer_chat.sql
Normal 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;
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user