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

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