Public: anyone can read this; the crew can change it.

forked from trevato/cs

A room is a link you send, and your friend lands on your side #378

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/cs-engine:vesper/315-a-room-is-a-link-you-send-and-your-frien → main 3 commits fork

  • Merged. vesper/315-a-room-is-a-link-you-send-and-your-frien was merged into main as 491fe46.

Review

Sign in to review this change.
16 files changed +469 −42
README.md +27 −4
@@ -1665,12 +1665,33 @@ whether a run is possible, and plain PPO rather than a clever algorithm.
16651665 ## Playing with friends: rooms
16661666
16671667 The match at `/` runs in your own browser against bots. A **room** is the other
1668 thing: make one from the menu, read the six-character code out to your friends,
1669 and they type it in. A room's round is stepped **on this server** at the game's
1668 thing, and a room is **a link and a side**, not a code read aloud:
1669
1670 - **A link.** Make a room from the menu and *Copy this room's link* sits beside
1671 the code field — `/?room=8UTZQR` — and under the stage while you play, as
1672 *Link to this room*. Whoever opens it lands on the menu with the code filled
1673 in and **Join** under the focus. It never joins by itself: pointer lock needs a
1674 press, and a link that drops a stranger into a live round is one nobody sends
1675 twice. `?room=` is read with `roomCodeFrom`; anything it does not accept —
1676 junk, the wrong length, a character outside the alphabet — is ignored, and the
1677 page is byte for byte the one with no parameter.
1678 - **A side.** *Room side* beside the code is Either, Counter-Terrorists or
1679 Terrorists, remembered by this browser the way the name is, and sent as
1680 `team=t|ct` on the socket (`src/server.ts` validates it; junk is Either).
1681 Asked for a side, you take a bot's seat on it — so five friends can sit on one
1682 side against five bots, and nothing rebalances that behind your backs. A side
1683 that is all people already seats you on the other, and the room line says so
1684 in a sentence. Either keeps today's rule: the side with fewer people. A seat is
1685 claimed when you join and never moves mid-round.
1686 - **Who is here.** The room line under the code — and the pause panel, for a
1687 full screen — names everybody in the room and the side each is on, off the
1688 roster the server sends whenever somebody arrives or leaves.
1689
1690 A room's round is stepped **on this server** at the game's
16701691 own 64 Hz — `src/rooms/room.ts` runs the shooter's own `createWorld` and
16711692 `updateWith` — and every member is sent the same 544-byte packed snapshot sixteen
16721693 times a second (`src/rooms/wire.ts`), so nobody in a room can disagree about what
1673 happened. Joining takes a bot's seat, so a round is always five against five, and
1694 happened. Joining takes a bot's seat, so a round is always ten bodies, and
16741695 leaving hands the body straight back to the bot with the money and guns it had.
16751696
16761697 The hub under that — the rooms a process holds, the one clock that steps
@@ -1688,7 +1709,9 @@ What is real and what is not, today:
16881709 after ninety seconds (`src/rooms/hub.ts`).
16891710 - Not yet: your own movement is **not** predicted, so it answers on the round
16901711 trip rather than instantly; a room has no hit marker or damage arrow (both are
1691 effects recorded for one screen and a snapshot goes to everybody). Rooms live
1712 effects recorded for one screen and a snapshot goes to everybody). Who is in a
1713 room is seen from inside it: there is no page that lists a room's people before
1714 you join, because a code is all a stranger would need to read it. Rooms live
16921715 in this process only — a deploy ends them.
16931716
16941717 ### Whose result a room's match is
src/client/main.test.ts +81 −1
@@ -31,7 +31,7 @@
3131 import { expect, test } from "bun:test";
3232 import { generateLevel, nameOf, seedFrom, seedOf } from "./generate.ts";
3333 import type { Level } from "./level.ts";
34 import { boot, COPIED, gameMeshes, NOT_AN_ID, NOT_A_MAP, NOT_COPIED, ROOM_SAVED, type Frame } from "./main.ts";
34 import { boot, COPIED, gameMeshes, NOT_AN_ID, NOT_A_MAP, NOT_COPIED, ROOM_SAVED, ROOM_SHARE, type Frame } from "./main.ts";
3535 import type { Socket } from "./session.ts";
3636 import { NO_PICTURE } from "../engine/index.ts";
3737 import { HINT } from "./buy.ts";
@@ -1791,6 +1791,86 @@ test("a browser that crosses midnight picks up the new day's match, not yesterda
17911791 expect(stage.by("willplay").textContent).toBe(after.map);
17921792 });
17931793
1794 // --- a room is a link and a side -------------------------------------------
1795
1796 /** A page that can open sockets, and the URLs it opened them to. */
1797 function withSockets(stage: FakeStage): { stage: FakeStage; urls: string[] } {
1798 const urls: string[] = [];
1799 stage.host.connect = (url) => {
1800 urls.push(url);
1801 return {
1802 binaryType: "", send: () => {}, close: () => {},
1803 onopen: null, onmessage: null, onclose: null, onerror: null,
1804 };
1805 };
1806 return { stage, urls };
1807 }
1808
1809 test("a link that was a room focuses Join once the game is ready, and joins nothing by itself", () => {
1810 const { stage, urls } = withSockets(fakeStage());
1811 // What the server rendered for `/?room=8UTZQR`.
1812 stage.by("room").value = "8UTZQR";
1813 start(stage);
1814 expect(stage.by("copyroom").hidden).toBe(false);
1815 expect(stage.by("copyroom").getAttribute("href")).toBe("/?room=8UTZQR");
1816 expect(stage.by("joinroom").focused).toBe(0);
1817 painted(stage);
1818 expect(stage.by("joinroom").disabled).toBe(false);
1819 expect(stage.by("joinroom").focused).toBe(1);
1820 expect(urls).toEqual([]);
1821 expect(stage.by("menu").hidden).toBe(false);
1822 // Without a code, nothing about a room takes the focus.
1823 const plain = painted(start(withSockets(fakeStage()).stage));
1824 expect(plain.by("joinroom").focused).toBe(0);
1825 expect(plain.by("copyroom").hidden).toBe(true);
1826 });
1827
1828 test("the side asked for goes up with the join, is remembered, and either sends nothing", () => {
1829 const { stage, urls } = withSockets(painted(start(fakeStage())));
1830 stage.by("room").value = "8utzqr";
1831 stage.by("roomside").value = "t";
1832 stage.by("joinroom").fire("click");
1833 expect(urls).toEqual(["/ws?room=8UTZQR&name=you&team=t"]);
1834 expect(stage.store.get("cs.roomside")).toBe("t");
1835 // Under the stage, the link beside the room is the room's.
1836 expect(stage.by("share").getAttribute("href")).toBe("/?room=8UTZQR");
1837 expect(stage.by("share").textContent).toBe(ROOM_SHARE);
1838
1839 // A second visit starts on the side this browser asked for last.
1840 const again = fakeStage();
1841 again.store.set("cs.roomside", "t");
1842 expect(start(again).by("roomside").value).toBe("t");
1843 // And something that is not a side is not written into the control.
1844 const junk = fakeStage();
1845 junk.store.set("cs.roomside", "<b>");
1846 expect(start(junk).by("roomside").value).not.toBe("<b>");
1847
1848 const either = withSockets(painted(start(fakeStage())));
1849 either.stage.by("room").value = "8UTZQR";
1850 either.stage.by("roomside").value = "";
1851 either.stage.by("joinroom").fire("click");
1852 expect(either.urls).toEqual(["/ws?room=8UTZQR&name=you"]);
1853 });
1854
1855 test("the room's link shows for a code in the field, hides for junk, and copies as a whole URL", async () => {
1856 const stage = painted(start(fakeStage()));
1857 const room = stage.by("room");
1858 const link = stage.by("copyroom");
1859 room.value = "abc";
1860 room.fire("input");
1861 expect(link.hidden).toBe(true);
1862 room.value = "8utzqr";
1863 room.fire("input");
1864 expect(link.hidden).toBe(false);
1865 expect(link.getAttribute("href")).toBe("/?room=8UTZQR");
1866 let prevented = false;
1867 link.fire("click", { preventDefault: () => void (prevented = true) });
1868 expect(prevented).toBe(true);
1869 expect(stage.copied).toEqual([`${ORIGIN}/?room=8UTZQR`]);
1870 await Promise.resolve();
1871 expect(link.textContent).toBe(COPIED);
1872 });
1873
17941874 // --- a room posts nothing ------------------------------------------------------
17951875
17961876 test("a page in a room sends nothing to /api/match, through every round and the end of the match", () => {
src/client/main.ts +73 −5
@@ -103,7 +103,8 @@ import { drawBuy, HINT, labelOf, offers } from "./buy.ts";
103103 import { buy, canBuy, priceOf, type BuyItem } from "./economy.ts";
104104 import { atRound, createLoadout, noteBuy, rebuy, saidRebuy } from "./loadout.ts";
105105 import { createWorld, eyePos, say as showMessage, update, type Player, type World } from "./world.ts";
106 import { createSession, type Session, type Socket } from "./session.ts";
106 import { createSession, roomLine, type Session, type Socket } from "./session.ts";
107 import { roomLink } from "../roomlink.ts";
107108 import { SWITCH_KNIFE, SWITCH_NONE, SWITCH_PRIMARY, SWITCH_SECONDARY } from "../rooms/wire.ts";
108109 import type { BuyItem as Item } from "./economy.ts";
109110 import { roomCodeFrom, unpack } from "../engine/index.ts";
@@ -161,6 +162,12 @@ export const NO_MAKE = "A room could not be made right now. Try again in a momen
161162 export const ROOM_SAVED = "This room's match is on the leaderboard for everyone who was seated when it ended — written by the server, not this page.";
162163
163164 export const COPIED = "Copied";
165
166 /** What the link under the stage says while it is a room's rather than a match's. */
167 export const ROOM_SHARE = "Link to this room";
168
169 /** Where the room's side is remembered between visits, beside the name. */
170 const ROOM_SIDE_KEY = "cs.roomside";
164171 export const NOT_COPIED = "Copy failed — the link still works";
165172
166173 /** The same, for the line about this browser: there is no link to fall back to. */
@@ -398,6 +405,7 @@ export function boot(host: Host): Frame {
398405 const playingId = by("playing");
399406 const mapName = by<Toggle>("mapname");
400407 const share = by<Toggle>("share");
408 const shareLabel = share.textContent;
401409 const pauseId = by("pauseid");
402410 const onMap = by("onmap");
403411 // What that line said before anything was typed, kept for the same reason
@@ -425,6 +433,11 @@ export function boot(host: Host): Frame {
425433 const joinRoom = by<Panel & { disabled: boolean }>("joinroom");
426434 const makeRoom = by<Panel & { disabled: boolean }>("makeroom");
427435 const roomSaid = by("roomsaid");
436 // The side a room is asked for, and the link that is the room in the field:
437 // hidden until the field holds a code, so it never points at nothing.
438 const roomSideField = by<Field>("roomside");
439 const copyRoom = by<Toggle>("copyroom");
440 const copyRoomLabel = copyRoom.textContent;
428441 // Where this game's weights are: the shell put the link in the head with the
429442 // content-hashed URL on it, so this is the only place the path is known.
430443 const weightsLink = doc.getElementById("weights") as Link | null;
@@ -674,6 +687,10 @@ export function boot(host: Host): Frame {
674687 let session: Session | null = null;
675688 /** One item asked for, sent on the next frame and then forgotten. */
676689 let roomBuy: Item | null = null;
690 /** The side this page asked the room for, which the room line holds the roster to. */
691 let roomWanted: "t" | "ct" | null = null;
692 /** The map the room is on, once its hello has said. */
693 let roomMap = "";
677694 let lastSound = 0;
678695 let frameAverage = 16.7;
679696 /** Whether a frame has actually been drawn, so the average above means something. */
@@ -749,6 +766,10 @@ export function boot(host: Host): Frame {
749766 joinRoom.disabled = !canJoin;
750767 makeRoom.disabled = !canJoin;
751768 if (!canJoin) roomSaid.textContent = NO_SOCKET;
769 // A link that was a room filled the field on the server; the one press
770 // left is Join, so that is where the focus is. Never the join itself:
771 // pointer lock needs a gesture, and so does walking into somebody's round.
772 else if (roomCodeFrom(roomField.value) !== null) joinRoom.focus();
752773 }
753774
754775 // --- the buy menu, rebuilt whenever it opens so the prices are honest
@@ -1192,6 +1213,7 @@ export function boot(host: Host): Frame {
11921213 mapName.setAttribute("href", mapLink(mapFrom(level.name)));
11931214 pauseId.textContent = `Match ${matchId} on ${level.name}.`;
11941215 share.setAttribute("href", `/?match=${matchId}&map=${choice}`);
1216 share.textContent = shareLabel;
11951217 world = createWorld(level, rules, name, team, 5, seed);
11961218 // A fresh match buys from scratch: the last match's loadout is not this
11971219 // match's first rebuy.
@@ -1231,11 +1253,20 @@ export function boot(host: Host): Frame {
12311253 if (renderer === null || !policyIn || host.connect === undefined) return;
12321254 const name = nameField.value.trim().slice(0, 24) || "you";
12331255 host.storage.setItem("cs.name", name);
1256 roomWanted = sideWanted();
1257 roomMap = "";
1258 host.storage.setItem(ROOM_SIDE_KEY, roomSideField.value);
1259 showRoomLink(code);
1260 // Under the stage, the link beside the room is the room's own: it is what
1261 // somebody still outside needs, and it is on the screen for the whole round.
1262 share.setAttribute("href", roomLink(code));
1263 share.textContent = ROOM_SHARE;
12341264 session?.close();
12351265 world = null;
12361266 session = createSession({
12371267 connect: host.connect,
1238 url: `/ws?room=${encodeURIComponent(code)}&name=${encodeURIComponent(name)}`,
1268 url: `/ws?room=${encodeURIComponent(code)}&name=${encodeURIComponent(name)}` +
1269 (roomWanted === null ? "" : `&team=${roomWanted}`),
12391270 code,
12401271 now: () => win.performance.now(),
12411272 build: (hello) => {
@@ -1246,7 +1277,7 @@ export function boot(host: Host): Frame {
12461277 }
12471278 playingId.textContent = `room ${code}`;
12481279 mapName.textContent = level.name;
1249 pauseId.textContent = `Room ${code} on ${level.name}.`;
1280 roomMap = level.name;
12501281 return createWorld(level, hello.rules === "long" ? COMPETITIVE : SHORT, name, "ct", 5, hello.seed);
12511282 },
12521283 });
@@ -1270,6 +1301,39 @@ export function boot(host: Host): Frame {
12701301 void takePointer();
12711302 }
12721303
1304 /** The side the room control asks for, or null for either: the page's own read of its select. */
1305 function sideWanted(): "t" | "ct" | null {
1306 const value = roomSideField.value;
1307 return value === "t" || value === "ct" ? value : null;
1308 }
1309
1310 /** The room's link beside its field: shown for a code, hidden for anything else. */
1311 let keepingRoom = "/";
1312 function showRoomLink(code: string | null): void {
1313 show(copyRoom, code !== null);
1314 keepingRoom = code === null ? "/" : roomLink(code);
1315 copyRoom.setAttribute("href", keepingRoom);
1316 copyRoom.textContent = copyRoomLabel;
1317 }
1318 for (const event of ["change", "input"])
1319 roomField.addEventListener(event, () => showRoomLink(roomCodeFrom(roomField.value)));
1320 // A code the server filled in from the link: the same link, held for the copy.
1321 showRoomLink(roomCodeFrom(roomField.value));
1322 roomSideField.addEventListener("change", () => host.storage.setItem(ROOM_SIDE_KEY, roomSideField.value));
1323 // The side is remembered the way the name is: what this browser asked for last.
1324 const savedRoomSide = host.storage.getItem(ROOM_SIDE_KEY);
1325 if (savedRoomSide === "" || savedRoomSide === "t" || savedRoomSide === "ct") roomSideField.value = savedRoomSide;
1326
1327 // A copy control like the map's: a link first, the clipboard when there is one.
1328 copyRoom.addEventListener<{ preventDefault(): void }>("click", (event) => {
1329 if (host.copy === undefined) return;
1330 event.preventDefault();
1331 void host.copy(host.origin + keepingRoom).then(
1332 () => void (copyRoom.textContent = COPIED),
1333 () => void (copyRoom.textContent = NOT_COPIED),
1334 );
1335 });
1336
12731337 joinRoom.addEventListener("click", () => {
12741338 const code = roomCodeFrom(roomField.value);
12751339 if (code === null) {
@@ -1306,6 +1370,7 @@ export function boot(host: Host): Frame {
13061370 return;
13071371 }
13081372 roomField.value = code;
1373 showRoomLink(code);
13091374 startRoom(code);
13101375 },
13111376 () => void (roomSaid.textContent = NO_MAKE),
@@ -1521,8 +1586,11 @@ export function boot(host: Host): Frame {
15211586 // the first snapshot has described it.
15221587 if (session !== null) {
15231588 world = session.world();
1524 const said = session.standing(now);
1525 roomSaid.textContent = `Room ${said.code} — ${said.line}. ${said.people} here.`;
1589 // Who is here and on which side, off the latest roster: on the menu's
1590 // room line and in the pause panel, which is what a full screen has.
1591 const line = roomLine(session.standing(now), roomWanted);
1592 roomSaid.textContent = line;
1593 pauseId.textContent = roomMap === "" ? line : `${line} On ${roomMap}.`;
15261594 }
15271595 if (world === null || !running) return;
15281596
src/client/session.test.ts +27 −2
@@ -30,7 +30,7 @@ import {
3030 import { DE_FORGE } from "./map.ts";
3131 import { SHORT } from "./match.ts";
3232 import {
33 BEHIND, BEHIND_MS, CONNECTING, DROPPED, PLAYING, createSession, type Session, type Socket,
33 BEHIND, BEHIND_MS, CONNECTING, DROPPED, PLAYING, createSession, roomLine, sideFull, type Session, type Socket,
3434 } from "./session.ts";
3535 import { createWorld, NO_INPUT, type Player, type World } from "./world.ts";
3636
@@ -208,7 +208,7 @@ test("the session connects to the url it was given and asks for bytes it can rea
208208 expect(s.session.world()).toBeNull();
209209 expect(s.session.seat()).toBe(-1);
210210 expect(s.session.standing(START)).toEqual({
211 code: CODE, live: false, behind: -1, line: CONNECTING, people: 0,
211 code: CODE, live: false, behind: -1, line: CONNECTING, people: 0, here: [], side: null,
212212 });
213213 });
214214
@@ -289,6 +289,30 @@ test("a later roster is the one that counts, and our body stays ours", () => {
289289 expect(world.me).toBe(body(world, SEAT));
290290 });
291291
292 test("the room line names who is here and on which side, and changes when somebody arrives or leaves", () => {
293 const s = stage();
294 s.join();
295 expect(roomLine(s.session.standing(START), null))
296 .toBe(`Room ${CODE} — ${CONNECTING}. 2 here: ada (T), you (you, CT).`);
297 s.say(rosterText([person(0, "ada", "t"), person(1, "bo", "t"), person(SEAT, "you"), bot(5, "Rook", "t")]));
298 expect(roomLine(s.session.standing(START), null)).toContain("3 here: ada (T), bo (T), you (you, CT).");
299 s.say(rosterText([bot(0, "Rook", "t"), person(SEAT, "you")]));
300 expect(roomLine(s.session.standing(START), null)).toContain("1 here: you (you, CT).");
301 // A name is somebody else's typing and the line is text, so it goes in as itself.
302 s.say(rosterText([person(0, "<b>x</b>", "t"), person(SEAT, "you")]));
303 expect(roomLine(s.session.standing(START), null)).toContain("<b>x</b> (T)");
304 });
305
306 test("a side asked for and not given is a sentence on the room line; one given says nothing more", () => {
307 const s = stage();
308 s.join();
309 expect(s.session.standing(START).side).toBe("ct");
310 expect(roomLine(s.session.standing(START), "t")).toContain(sideFull("t", "ct"));
311 expect(sideFull("t", "ct")).toBe("The Terrorists were all people already, so you are with the Counter-Terrorists.");
312 expect(roomLine(s.session.standing(START), "ct")).not.toContain("already");
313 expect(roomLine(s.session.standing(START), null)).not.toContain("already");
314 });
315
292316 test("a roster line for a seat this room has no body for changes nothing", () => {
293317 const s = stage();
294318 s.join();
@@ -327,6 +351,7 @@ test("a snapshot moves the bodies and the round with them", () => {
327351 expect(world.match.round).toBe(3);
328352 expect(s.session.standing(5000)).toEqual({
329353 code: CODE, live: true, behind: 0, line: PLAYING, people: 2,
354 here: [{ name: "ada", team: "t", you: false }, { name: "you", team: "ct", you: true }], side: "ct",
330355 });
331356 // `behind` is the milliseconds since it landed, and nothing else.
332357 expect(s.session.standing(5120).behind).toBe(120);
src/client/session.ts +43 −1
@@ -50,8 +50,15 @@ export type Standing = {
5050 line: string;
5151 /** How many people are in the room, bots not counted. */
5252 people: number;
53 /** Who those people are and which side each is on, in seat order, off the latest roster. */
54 here: Here[];
55 /** The side our own body is on, or null before `hello`. */
56 side: "t" | "ct" | null;
5357 };
5458
59 /** One person in a room, as the room line says them. */
60 export type Here = { name: string; team: "t" | "ct"; you: boolean };
61
5562 export type Session = {
5663 /** The world the snapshots are filling, or null until the first `hello`. */
5764 world(): World | null;
@@ -94,6 +101,8 @@ export function createSession(options: {
94101 }): Session {
95102 let seat = -1;
96103 let people = 0;
104 let here: Here[] = [];
105 let side: "t" | "ct" | null = null;
97106
98107 const member = createMember<World, Down, Omit<Pressed, "tick">, Snapshot>({
99108 connect: options.connect,
@@ -147,12 +156,16 @@ export function createSession(options: {
147156 /** Names and bots off the roster, onto the bodies they belong to. */
148157 function apply(world: World, roster: Hello["roster"]): void {
149158 people = 0;
159 here = [];
150160 for (const seated of roster) {
151161 const body = world.players[seated.seat];
152162 if (body === undefined) continue;
153163 body.name = seated.name;
154164 body.bot = seated.bot;
155 if (!seated.bot) people += 1;
165 if (seated.seat === seat) side = seated.team;
166 if (seated.bot) continue;
167 people += 1;
168 here.push({ name: seated.name, team: seated.team, you: seated.seat === seat });
156169 }
157170 // Ours is ours whatever the roster says about the body: the camera follows it.
158171 if (world.players[seat] !== undefined) world.me = world.players[seat] as Player;
@@ -163,8 +176,37 @@ export function createSession(options: {
163176 seat: () => seat,
164177 standing: (now) => ({
165178 code: options.code, live: member.live(), behind: member.behind(now), line: member.line(now), people,
179 here, side,
166180 }),
167181 press: member.press,
168182 close: member.close,
169183 };
170184 }
185
186 /** The two sides, as the room line names them: short for a list, whole for a sentence. */
187 const SHORT_SIDE = { t: "T", ct: "CT" } as const;
188 const WHOLE_SIDE = { t: "Terrorists", ct: "Counter-Terrorists" } as const;
189
190 /**
191 * The sentence a side you asked for and did not get is: said on the room line
192 * for as long as you are in the room, because a seat never moves mid-round and
193 * the reason you are where you are does not stop being true.
194 */
195 export const sideFull = (wanted: "t" | "ct", got: "t" | "ct"): string =>
196 `The ${WHOLE_SIDE[wanted]} were all people already, so you are with the ${WHOLE_SIDE[got]}.`;
197
198 /**
199 * The room line: the code, what the connection is doing, who is here and on
200 * which side, and — when the side you asked for was full — that you are on the
201 * other one. Written as text, never as markup: every name in it is somebody
202 * else's typing, cleaned by the server and still not trusted as HTML here.
203 */
204 export function roomLine(standing: Standing, wanted: "t" | "ct" | null): string {
205 const who = standing.here
206 .map((h) => `${h.name} (${h.you ? "you, " : ""}${SHORT_SIDE[h.team]})`)
207 .join(", ");
208 const moved = wanted !== null && standing.side !== null && standing.side !== wanted
209 ? ` ${sideFull(wanted, standing.side)}`
210 : "";
211 return `Room ${standing.code} — ${standing.line}. ${standing.people} here${who === "" ? "" : `: ${who}`}.${moved}`;
212 }
src/client/stage.test-helper.ts +1 −1
@@ -165,7 +165,7 @@ export type FakeStage = {
165165 * contract between the page and this file, so `src/server.test.ts` checks the
166166 * served page against this same list rather than a second copy of it.
167167 */
168 export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "touchsens", "motion", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "room", "joinroom", "makeroom", "roomsaid", "prep", "resume", "quit", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full", "support", "supportline", "copysupport"];
168 export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "touchsens", "motion", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "room", "roomside", "joinroom", "makeroom", "copyroom", "roomsaid", "prep", "resume", "quit", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full", "support", "supportline", "copysupport"];
169169
170170 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
171171 const HIDDEN = ["pause", "summary", "buy", "touch", "full"];
src/engine/GUIDE.md +7 −5
@@ -763,11 +763,13 @@ second copy of it in the second game that wants to be played with a friend.
763763 functions — deliver these bytes or this line of text to that member, close that
764764 member with a code and a sentence — so nothing in here is a socket, and a test
765765 hands over a wire that remembers instead of sending. The `RoomGame` is the table
766 of everything the hub calls on a room: create one, sit somebody down, stand them
767 up, drive a frame into it, step it, write a member their snapshot, say what one
768 member is told on arrival and who else is in the room, say what has been said
769 since the last beat, say whether it is idle, and describe it in a line for a
770 terminal — plus the two rates, the seconds in a step and the snapshots a second.
766 of everything the hub calls on a room: create one, sit somebody down — with
767 whatever else they asked for at the door, a side say, handed from `Hub.enter` to
768 your `RoomGame.join` unread — stand them up, drive a frame into it, step it,
769 write a member their snapshot, say what one member is told on arrival and who
770 else is in the room, say what has been said since the last beat, say whether it
771 is idle, and describe it in a line for a terminal — plus the two rates, the
772 seconds in a step and the snapshots a second.
771773 The room type is yours; the type the hub needs it to be is `HubRoom`, which is
772774 three fields — the code, the members by id, and when somebody was last in it —
773775 and the options a room is opened with are opaque, so a map name, a seed and a
src/engine/rooms.ts +9 −5
@@ -126,8 +126,12 @@ export type RoomGame<R extends HubRoom<M>, M extends { id: string }, O> = {
126126 snapshotHz: number;
127127 /** A fresh room under that code, opened with the game's own options. */
128128 create(code: string, options: O, now: number): R;
129 /** Somebody sits down. Null is a full room, which is the member cap. */
130 join(room: R, id: string, name: unknown, now: number): M | null;
129 /**
130 * Somebody sits down. Null is a full room, which is the member cap. `asked`
131 * is whatever else the arrival asked for — a side, a seat, nothing — passed
132 * through from `enter` unread, so it is the game's to validate.
133 */
134 join(room: R, id: string, name: unknown, now: number, asked?: unknown): M | null;
131135 /** And stands up again. Null is somebody who was not in it. */
132136 leave(room: R, id: string, now: number): M | null;
133137 /** One frame off the wire, for the member it came from. False is refused. */
@@ -181,7 +185,7 @@ export type Hub<R extends HubRoom<M>, M extends { id: string }, O> = {
181185 open(options: O, now: number): R | null;
182186 find(code: string): R | undefined;
183187 /** Somebody joins a room by code. Null is a full room or a room that is gone. */
184 enter(code: string, id: string, name: unknown, now: number): { room: R; member: M } | null;
188 enter(code: string, id: string, name: unknown, now: number, asked?: unknown): { room: R; member: M } | null;
185189 /** And leaves it, whichever room they were in. */
186190 exit(id: string, now: number): void;
187191 /** One frame off the wire from a member. False when it was refused. */
@@ -238,10 +242,10 @@ export function createHub<R extends HubRoom<M>, M extends { id: string }, O>(
238242 return room;
239243 },
240244 find: (code) => rooms.get(code),
241 enter(code, id, name, now) {
245 enter(code, id, name, now, asked) {
242246 const room = rooms.get(code);
243247 if (room === undefined) return null;
244 const member = game.join(room, id, name, now);
248 const member = game.join(room, id, name, now, asked);
245249 if (member === null) return null;
246250 where.set(id, { code, heardAt: now });
247251 wire.deliver(id, JSON.stringify(game.hello(room, member)));
src/pages/game.test.ts +36 −1
@@ -34,7 +34,10 @@ import { FLASH, HE, SMOKE } from "../client/weapons.ts";
3434 import { ALL, damageRows, gunsOf } from "../sheet.ts";
3535 import { GAMES } from "../games.ts";
3636 import { digitsIn, proseOf, unsourced } from "./digits.test-helper.ts";
37 import { gamePage, policyHtml, shareHref, statsHtml, STYLE, BAD_ID, BAD_MAP, type PageOptions } from "./game.ts";
37 import {
38 gamePage, policyHtml, shareHref, statsHtml, STYLE, BAD_ID, BAD_MAP, COPY_ROOM, NOTHING_ASKED, type PageOptions,
39 } from "./game.ts";
40 import { roomLink } from "../roomlink.ts";
3841 import { WEAPONS_HREF, WEAPONS_SECTION, WEAPONS_SUMMARY } from "./weapons.ts";
3942 import { escape, SUPPORT } from "./shell.ts";
4043
@@ -225,6 +228,38 @@ test("a link that asked for one map by name opens on it, before Play and as a li
225228 expect(bad).not.toMatch(/id="mapseed"[^>]*value=/);
226229 });
227230
231 test("a link that is a room opens with its code in the field and its link beside it, never joined", () => {
232 const html = gamePage(db(), { ...options, asked: { ...NOTHING_ASKED, room: "8UTZQR" } });
233 expect(html).toMatch(/<input id="room" [^>]*value="8UTZQR">/);
234 expect(html).toContain(`<a class="share" id="copyroom" href="${roomLink("8UTZQR")}">${escape(COPY_ROOM)}</a>`);
235 // Join is still the page's to enable and the person's to press.
236 expect(html).toContain(`id="joinroom" disabled>Join room</button>`);
237 // No room is an empty field and a link that is not there to follow.
238 for (const none of [NOTHING_ASKED, { ...NOTHING_ASKED, room: null }]) {
239 const empty = gamePage(db(), { ...options, asked: none });
240 expect(empty).not.toMatch(/<input id="room" [^>]*value=/);
241 expect(empty).toContain(`<a class="share" id="copyroom" href="/" hidden>`);
242 }
243 // The server hands over only a code `roomCodeFrom` accepted; the page escapes
244 // it anyway, because two checks are the point.
245 const hostile = gamePage(db(), { ...options, asked: { ...NOTHING_ASKED, room: `"><script>alert(1)</script>` } });
246 expect(hostile).not.toContain("<script>alert(1)");
247 expect(hostile).toContain(escape(`"><script>alert(1)</script>`));
248 });
249
250 test("the side a room is asked for is a control beside the code, with either as the default", () => {
251 const html = gamePage(db(), options);
252 const row = html.slice(html.indexOf(`<input id="room"`), html.indexOf(`id="roomsaid"`));
253 expect(row).toContain(
254 `<select id="roomside"><option value="">Either — keep it even</option>` +
255 `<option value="ct">Counter-Terrorists</option><option value="t">Terrorists</option></select>`,
256 );
257 expect(row.indexOf(`id="roomside"`)).toBeLessThan(row.indexOf(`id="joinroom"`));
258 // And the prose says a room is a link and a side, not a code read aloud.
259 expect(html).toContain("make a room and send its link");
260 expect(html).not.toContain("read the code out");
261 });
262
228263 test("the id's sentence promises what an id keeps, and says the link is what keeps the map", () => {
229264 // #215: the page used to say an id was "the whole match: the same map, …",
230265 // which the id has not been since the map became a control of its own. What
src/pages/game.ts +22 −4
@@ -27,6 +27,7 @@ import { FLASH, HE, SMOKE } from "../client/weapons.ts";
2727 import { CARD, type Card } from "../client/weights.ts";
2828 import { mapFrom, mapLink, type MapChoice } from "../mapname.ts";
2929 import { LONGEST_PASTE } from "../matchlink.ts";
30 import { roomLink } from "../roomlink.ts";
3031 import type { Who } from "../who.ts";
3132 import { figure } from "./figure.ts";
3233 import { navFor, noscriptFor, othersHtml } from "./games.ts";
@@ -131,6 +132,13 @@ export type Asked = {
131132 map: MapChoice;
132133 /** A `?map=` was there and was not a map, which is de_forge and a sentence. */
133134 badMap: boolean;
135 /**
136 * The room a link asked to join: a code `roomCodeFrom` accepted, or null.
137 * Junk is not a sentence here, it is nothing at all — the page with a bad
138 * `?room=` is byte for byte the page with none, so a crafted link has no
139 * text of its own to put on this page. Absent is null.
140 */
141 room?: string | null;
134142 };
135143
136144 /** A link with nothing on it: no id asked for, no map asked for, a fresh match. */
@@ -182,6 +190,8 @@ export const STYLE = `
182190 a.share { display:inline-flex; align-items:center; min-height:44px; padding:0 .7rem; border-radius:.45rem;
183191 background:#1b2230; border:1px solid var(--line); color:var(--ct); text-decoration:none; }
184192 a.share:hover, a.share:focus-visible { background:#27303f; text-decoration:underline; }
193 /* The room's link is there only when there is a room: a code in the field. */
194 a.share[hidden] { display:none; }
185195 #thismatch p { margin:.35rem 0; }
186196 /* The thumb's own sensitivity is a field only where there is a thumb: a mouse
187197 never grows a control for a pointer it does not have. CSS rather than script,
@@ -260,6 +270,9 @@ export const UNDRAWN = "a new one every match";
260270 */
261271 export const UNPLANNED = "A plan of it is drawn here as soon as the game has booted.";
262272
273 /** What the copy control beside the room field says, until it has copied. */
274 export const COPY_ROOM = "Copy this room's link";
275
263276 /** The link that plays this match again: the id it names and the map it was on. */
264277 export const shareHref = (asked: Asked): string =>
265278 asked.match === null ? "/" : `/?match=${asked.match}&map=${asked.map}`;
@@ -387,13 +400,18 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => `
387400 <div class="row"><button type="button" class="primary" id="play" disabled>Play</button></div>
388401 <div class="row">
389402 <label>Room code<input id="room" maxlength="6" placeholder="ABCDEF" autocapitalize="characters"
390 autocomplete="off" spellcheck="false"></label>
403 autocomplete="off" spellcheck="false"${asked.room == null ? "" : ` value="${escape(asked.room)}"`}></label>
404 <label>Room side<select id="roomside"><option value="">Either — keep it even</option><option value="ct">Counter-Terrorists</option><option value="t">Terrorists</option></select></label>
391405 <button type="button" id="joinroom" disabled>Join room</button>
392406 <button type="button" id="makeroom" disabled>Make a room</button>
407 <a class="share" id="copyroom" href="${escape(asked.room == null ? "/" : roomLink(asked.room))}"${asked.room == null ? " hidden" : ""}>${escape(COPY_ROOM)}</a>
393408 </div>
394 <p class="hint" id="roomsaid">Play with friends: make a room, read the code out, and they join it. A room&#39;s
395 round is decided on this server, so everyone in it sees the same round — the match above runs in your
396 own browser against bots. When a room&#39;s match ends, the server puts it on the leaderboard for everyone
409 <p class="hint" id="roomsaid">Play with friends: make a room and send its link — whoever opens it lands here
410 with the code filled in, picks a side and presses Join. Ask for a side and you sit on it while a bot is
411 still there, so friends can all be on one side against the bots; a side that is all people already seats
412 you on the other, and this line says so. Either keeps the sides even. A room&#39;s round is decided on this
413 server, so everyone in it sees the same round — the match above runs in your own browser against bots.
414 When a room&#39;s match ends, the server puts it on the leaderboard for everyone
397415 still seated — leave before the end and it is not counted for you. Rooms live in this process: a deploy
398416 ends them, and an empty one is forgotten after ninety seconds.</p>
399417 <p class="hint" id="prep">Preparing textures…</p>
src/rooms/hub.test.ts +11
@@ -31,6 +31,17 @@ import { createRoom, isIdle, isOver, join as sit, type Finished, type Room } fro
3131 import { ENDING_TICKS, forward, lastRound, opened, spy } from "./stage.test-helper.ts";
3232 import { type Player } from "../client/world.ts";
3333
34 test("the side a member asked for at the door reaches the room's own join", () => {
35 const wire = spy();
36 const hub = createHub(wire, { now: 0 });
37 const r = hub.open({ ...opened, now: 0 }) as Room;
38 const a = hub.enter(r.code, "s1", "sam", 0, "t");
39 const b = hub.enter(r.code, "s2", "kim", 0, "t");
40 expect(r.world.players[a?.member.seat ?? -1]?.team).toBe("t");
41 expect(r.world.players[b?.member.seat ?? -1]?.team).toBe("t");
42 hub.rooms.delete(r.code);
43 });
44
3445 test("a hub holds its rooms, hears one member and never the other room", () => {
3546 const wire = spy();
3647 const hub = createHub(wire, { now: 0 });
src/rooms/room.test.ts +44 −2
@@ -13,8 +13,8 @@
1313
1414 import { expect, test } from "bun:test";
1515 import {
16 drive, freeSeat, isOver, join, leave, logSince, resultsOf, rosterOf, snapshotFor, stepRoom, uniqueName,
17 INPUT_HOLD, type Finished, type Member, type Room,
16 drive, freeSeat, isOver, join, leave, logSince, resultsOf, rosterOf, sideFrom, snapshotFor, stepRoom, uniqueName,
17 INPUT_HOLD, TEAM_SIZE, type Finished, type Member, type Room,
1818 } from "./room.ts";
1919 import { writeInput, SWITCH_PRIMARY, SWITCH_SECONDARY } from "./wire.ts";
2020 // The cap is the hub's, because the hub is what enforces it on a socket; a
@@ -63,6 +63,48 @@ test("two friends land on opposite sides, and a name is never two people's", ()
6363 expect(r.members.size).toBe(2);
6464 });
6565
66 test("two friends who both ask for t both sit on t, while there is a bot there to replace", () => {
67 const r = room();
68 const a = join(r, "s1", "sam", 1, "t") as Member;
69 const b = join(r, "s2", "kim", 2, "t") as Member;
70 expect(r.world.players[a.seat]?.team).toBe("t");
71 expect(r.world.players[b.seat]?.team).toBe("t");
72 expect(a.seat).not.toBe(b.seat);
73 // Five friends on one side against five bots is the point, and nothing
74 // rebalances it: every seat on t is a person's and ct is all bots.
75 for (let i = 3; i <= 5; i++) join(r, `s${i}`, `p${i}`, i, "t");
76 const t = rosterOf(r).filter((s) => s.team === "t");
77 expect(t.every((s) => !s.bot)).toBe(true);
78 expect(rosterOf(r).filter((s) => s.team === "ct").every((s) => s.bot)).toBe(true);
79 });
80
81 test("asking for a side that is all people seats you on the other, and moves nobody", () => {
82 const r = room();
83 const before: number[] = [];
84 for (let i = 0; i < TEAM_SIZE; i++) before.push((join(r, `s${i}`, `p${i}`, i, "ct") as Member).seat);
85 const late = join(r, "late", "late", 9, "ct") as Member;
86 expect(r.world.players[late.seat]?.team).toBe("t");
87 // Everybody already seated is exactly where they were.
88 expect([...r.members.values()].slice(0, TEAM_SIZE).map((m) => m.seat)).toEqual(before);
89 });
90
91 test("no side, or junk, is exactly today's balanced seat", () => {
92 for (const junk of [undefined, null, "", "T", "ct ", "both", 1, { team: "t" }]) {
93 const asked = room();
94 const plain = room();
95 for (let i = 0; i < 4; i++) {
96 expect(join(asked, `s${i}`, `p${i}`, i, junk)?.seat).toBe(join(plain, `s${i}`, `p${i}`, i)?.seat as number);
97 }
98 }
99 expect(sideFrom("t")).toBe("t");
100 expect(sideFrom("ct")).toBe("ct");
101 expect(sideFrom("CT")).toBe(null);
102 expect(sideFrom(undefined)).toBe(null);
103 // And the balanced seat is still balanced: two strangers land opposite.
104 const r = room();
105 expect(freeSeat(r, null)).toBe(freeSeat(r));
106 });
107
66108 test("a room fills up, and the eleventh person is refused rather than dropped", () => {
67109 const r = room();
68110 for (let i = 0; i < 10; i++) expect(join(r, `s${i}`, `p${i}`, i)).not.toBe(null);
src/rooms/room.ts +24 −7
@@ -221,17 +221,31 @@ export function uniqueName(room: Room, wanted: unknown): string {
221221 export const memberCount = (room: Room): number => room.members.size;
222222
223223 /**
224 * Which body a person arriving takes: a bot's, on whichever side has fewer
225 * people on it, so two friends joining land opposite each other only once the
226 * teams are even. Null when every body is already held, which is the member cap.
224 * The side somebody asked to sit on, or null for "either". The query parameter
225 * is read at the door in `src/server.ts` and read again here, because `join` is
226 * a function of `unknown` and neither end trusts the other: anything that is not
227 * exactly `t` or `ct` is no wish at all, never an error.
227228 */
228 export function freeSeat(room: Room): number | null {
229 export const sideFrom = (value: unknown): Team | null => (value === "t" || value === "ct" ? value : null);
230
231 /**
232 * Which body a person arriving takes. Asked for a side, a bot's on that side —
233 * so five friends can sit on one side against five bots, which is the point and
234 * is not rebalanced behind their backs — and when that side is all people, a
235 * bot's on the other. Asked for nothing, a bot's on whichever side has fewer
236 * people on it, so two strangers land opposite each other once the teams are
237 * even. Null when every body is already held, which is the member cap.
238 *
239 * Nobody already seated is moved by any of this: a seat is claimed at join and
240 * a wish is only ever about the bots.
241 */
242 export function freeSeat(room: Room, wanted: Team | null = null): number | null {
229243 const held = { t: 0, ct: 0 };
230244 for (const member of room.members.values()) {
231245 const body = room.world.players[member.seat];
232246 if (body !== undefined) held[body.team] += 1;
233247 }
234 const want: Team = held.t <= held.ct ? "t" : "ct";
248 const want: Team = wanted ?? (held.t <= held.ct ? "t" : "ct");
235249 const on = (team: Team): number => room.world.players.findIndex((p) => p.bot && p.team === team);
236250 const first = on(want);
237251 if (first >= 0) return first;
@@ -244,13 +258,16 @@ export function freeSeat(room: Room): number | null {
244258 * its guns and where it is standing, because a round in progress does not stop
245259 * for an arrival — and the seat is theirs until they leave.
246260 *
261 * `side` is the side they asked for (`freeSeat`); a full one is the other side,
262 * and the page reads which it got off the roster and says so.
263 *
247264 * Null is a full room, which is the member cap and is answered as a sentence by
248265 * the caller rather than as a dropped connection.
249266 */
250 export function join(room: Room, id: string, wanted: unknown, now: number): Member | null {
267 export function join(room: Room, id: string, wanted: unknown, now: number, side?: unknown): Member | null {
251268 const existing = room.members.get(id);
252269 if (existing !== undefined) return existing;
253 const seat = freeSeat(room);
270 const seat = freeSeat(room, sideFrom(side));
254271 if (seat === null) return null;
255272 const body = room.world.players[seat] as Player;
256273 const name = uniqueName(room, wanted);
src/server.test.ts +33
@@ -158,6 +158,19 @@ test("a `?match=` that is not an id is a fresh match and a sentence, never an er
158158 }
159159 });
160160
161 test("`/?room=` is a link to a room: a code fills the field, and junk is exactly the page with none", async () => {
162 const plain = await (await fetch(`${base}/`)).text();
163 for (const junk of ["<script>alert(1)</script>", "8UTZQ", "8UTZQRX", "8UTZQ0", "8UTZQ!", "%00", ""]) {
164 const response = await fetch(`${base}/?room=${encodeURIComponent(junk)}`);
165 expect(response.status).toBe(200);
166 expect(await response.text()).toBe(plain);
167 }
168 // A code in any case is the code, as the field itself would read it.
169 const body = await (await fetch(`${base}/?room=8utzqr`)).text();
170 expect(body).toMatch(/<input id="room" [^>]*value="8UTZQR">/);
171 expect(body).toContain(`id="copyroom" href="/?room=8UTZQR">`);
172 });
173
161174 test("`/?map=gen_…` preselects one map by name, and nothing else of a match", async () => {
162175 const name = "gen_14u5j6g";
163176 const body = await (await fetch(`${base}/?map=${name}`)).text();
@@ -1708,6 +1721,26 @@ test("a name longer than 24 characters is cut at the door, before the room ever
17081721 }
17091722 });
17101723
1724 test("`team=` at the door is the side the room seats you on; junk is the balanced seat", async () => {
1725 const room = await openedRoom("sides");
1726 const teamOf = (client: Client): string | undefined => {
1727 const hello = readDown(client.frames[0] as string) as Hello;
1728 return hello.roster[hello.seat]?.team;
1729 };
1730 const a = await connect(`room=${room.code}&name=a&team=t`);
1731 const b = await connect(`room=${room.code}&name=b&team=t`);
1732 // Balanced, the first stranger is t and the second ct: junk is exactly that.
1733 const c = await connect(`room=${room.code}&name=c&team=${encodeURIComponent("<t>")}`);
1734 try {
1735 expect(await until(() => [a, b, c].every((x) => x.frames.length > 0))).toBe(true);
1736 expect(teamOf(a)).toBe("t");
1737 expect(teamOf(b)).toBe("t");
1738 expect(teamOf(c)).toBe("ct");
1739 } finally {
1740 await finish(room.code, a, b, c);
1741 }
1742 });
1743
17111744 test("a text frame on the socket is dropped, and the same bytes as binary are an input", async () => {
17121745 const room = await openedRoom("text frame");
17131746 const client = await connect(`room=${room.code}&name=typist`);
src/server.ts +18 −4
@@ -71,6 +71,8 @@ import { STYLE as WEAPONS_STYLE, weaponsPage, WEAPONS_HREF } from "./pages/weapo
7171 import { policyFor } from "./policy.ts";
7272 import { roomCodeFrom } from "./engine/index.ts";
7373 import { createHub, CLOSE_GONE, MESSAGE_MAX, ROOM_CAP } from "./rooms/hub.ts";
74 import { sideFrom } from "./rooms/room.ts";
75 import type { Team } from "./client/match.ts";
7476 import { roomWriter, type Seated } from "./seated.ts";
7577 import { playedOn } from "./mapname.ts";
7678 import { styleFor, type Linked } from "./pages/shell.ts";
@@ -295,7 +297,10 @@ export const hub = createHub({
295297 * What one connection is: which member, in which room, under what name — and
296298 * as whom, read once at the upgrade the way `/api/match` reads it per post.
297299 */
298 type Joined = Seated;
300 type Joined = Seated & {
301 /** The side the page asked to sit on, validated at the upgrade; null is either. */
302 team: Team | null;
303 };
299304
300305 /**
301306 * The clock every room steps on. One interval for the whole process rather than
@@ -479,7 +484,10 @@ type Render = { extraStyle?: string; draw: (request: Request, script: string, li
479484 * error either: the page says so in a sentence and plays the default, and it
480485 * escapes what it renders anyway, because two checks are the point.
481486 *
482 * Both belong to this game and to no other: `?map=` is read here, in the
487 * `?room=` is the third, and the quietest: a code `roomCodeFrom` accepts fills
488 * the menu's room field, and anything else is as if it were never there.
489 *
490 * All belong to this game and to no other: `?map=` is read here, in the
483491 * shooter's own renderer, and the range and the example never see it.
484492 */
485493 const asked = (request: Request): Asked => {
@@ -493,6 +501,8 @@ const asked = (request: Request): Asked => {
493501 badMatch: wanted !== null && match === null,
494502 map,
495503 badMap: wantedMap !== null && wantedMap !== map,
504 // A room is a code or nothing, and never a sentence: see `Asked.room`.
505 room: roomCodeFrom(query.get("room")),
496506 };
497507 };
498508
@@ -742,7 +752,7 @@ export const server = Bun.serve<Joined, never>({
742752 idleTimeout: 30,
743753 open(ws) {
744754 sockets.set(ws.data.id, ws);
745 const joined = hub.enter(ws.data.code, ws.data.id, ws.data.name, Date.now());
755 const joined = hub.enter(ws.data.code, ws.data.id, ws.data.name, Date.now(), ws.data.team);
746756 if (joined === null) {
747757 ws.close(CLOSE_GONE, "that room is full");
748758 sockets.delete(ws.data.id);
@@ -867,6 +877,9 @@ async function route(request: Request, listener: Bun.Server<Joined>): Promise<Re
867877 * member *is* is the forge's word, verified here once and carried on the
868878 * connection: it is what a finished room's row is written under, and a name
869879 * typed into the page never overrides it.
880 * `team` is the side the page asked to sit on, `t` or `ct`; anything else —
881 * absent, junk, a different case — is no wish, which is the balanced seat, and
882 * never a refusal.
870883 */
871884 if (isRead(request) && url.pathname === SOCKET_PATH) {
872885 if (!sameOrigin(request)) return text("forbidden", 403);
@@ -876,8 +889,9 @@ async function route(request: Request, listener: Bun.Server<Joined>): Promise<Re
876889 const session = readSession(request) ?? newSession();
877890 const id = `${session}.${crypto.randomUUID().slice(0, 8)}`;
878891 const name = (url.searchParams.get("name") ?? "").slice(0, 24);
892 const team = sideFrom(url.searchParams.get("team"));
879893 const upgraded = listener.upgrade(request, {
880 data: { id, code, name, who: who(request, identityKey), session },
894 data: { id, code, name, team, who: who(request, identityKey), session },
881895 headers: { ...SECURITY, "set-cookie": sessionCookie(session) },
882896 });
883897 // A plain GET of this path — a person following a link, a crawler — is not

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs