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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/315-a-room-is-a-link-you-send-and-your-frien → main 3 commits fork
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. | ||
| 1665 | 1665 | ## Playing with friends: rooms |
| 1666 | 1666 | |
| 1667 | 1667 | 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 | |
| 1670 | 1691 | own 64 Hz — `src/rooms/room.ts` runs the shooter's own `createWorld` and |
| 1671 | 1692 | `updateWith` — and every member is sent the same 544-byte packed snapshot sixteen |
| 1672 | 1693 | 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 | |
| 1674 | 1695 | leaving hands the body straight back to the bot with the money and guns it had. |
| 1675 | 1696 | |
| 1676 | 1697 | 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: | ||
| 1688 | 1709 | after ninety seconds (`src/rooms/hub.ts`). |
| 1689 | 1710 | - Not yet: your own movement is **not** predicted, so it answers on the round |
| 1690 | 1711 | 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 | |
| 1692 | 1715 | in this process only — a deploy ends them. |
| 1693 | 1716 | |
| 1694 | 1717 | ### Whose result a room's match is |
src/client/main.test.ts +81 −1
| @@ -31,7 +31,7 @@ | ||
| 31 | 31 | import { expect, test } from "bun:test"; |
| 32 | 32 | import { generateLevel, nameOf, seedFrom, seedOf } from "./generate.ts"; |
| 33 | 33 | 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"; | |
| 35 | 35 | import type { Socket } from "./session.ts"; |
| 36 | 36 | import { NO_PICTURE } from "../engine/index.ts"; |
| 37 | 37 | import { HINT } from "./buy.ts"; |
| @@ -1791,6 +1791,86 @@ test("a browser that crosses midnight picks up the new day's match, not yesterda | ||
| 1791 | 1791 | expect(stage.by("willplay").textContent).toBe(after.map); |
| 1792 | 1792 | }); |
| 1793 | 1793 | |
| 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 | ||
| 1794 | 1874 | // --- a room posts nothing ------------------------------------------------------ |
| 1795 | 1875 | |
| 1796 | 1876 | 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"; | ||
| 103 | 103 | import { buy, canBuy, priceOf, type BuyItem } from "./economy.ts"; |
| 104 | 104 | import { atRound, createLoadout, noteBuy, rebuy, saidRebuy } from "./loadout.ts"; |
| 105 | 105 | 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"; | |
| 107 | 108 | import { SWITCH_KNIFE, SWITCH_NONE, SWITCH_PRIMARY, SWITCH_SECONDARY } from "../rooms/wire.ts"; |
| 108 | 109 | import type { BuyItem as Item } from "./economy.ts"; |
| 109 | 110 | 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 | ||
| 161 | 162 | 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."; |
| 162 | 163 | |
| 163 | 164 | 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"; | |
| 164 | 171 | export const NOT_COPIED = "Copy failed — the link still works"; |
| 165 | 172 | |
| 166 | 173 | /** 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 { | ||
| 398 | 405 | const playingId = by("playing"); |
| 399 | 406 | const mapName = by<Toggle>("mapname"); |
| 400 | 407 | const share = by<Toggle>("share"); |
| 408 | const shareLabel = share.textContent; | |
| 401 | 409 | const pauseId = by("pauseid"); |
| 402 | 410 | const onMap = by("onmap"); |
| 403 | 411 | // What that line said before anything was typed, kept for the same reason |
| @@ -425,6 +433,11 @@ export function boot(host: Host): Frame { | ||
| 425 | 433 | const joinRoom = by<Panel & { disabled: boolean }>("joinroom"); |
| 426 | 434 | const makeRoom = by<Panel & { disabled: boolean }>("makeroom"); |
| 427 | 435 | 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; | |
| 428 | 441 | // Where this game's weights are: the shell put the link in the head with the |
| 429 | 442 | // content-hashed URL on it, so this is the only place the path is known. |
| 430 | 443 | const weightsLink = doc.getElementById("weights") as Link | null; |
| @@ -674,6 +687,10 @@ export function boot(host: Host): Frame { | ||
| 674 | 687 | let session: Session | null = null; |
| 675 | 688 | /** One item asked for, sent on the next frame and then forgotten. */ |
| 676 | 689 | 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 = ""; | |
| 677 | 694 | let lastSound = 0; |
| 678 | 695 | let frameAverage = 16.7; |
| 679 | 696 | /** Whether a frame has actually been drawn, so the average above means something. */ |
| @@ -749,6 +766,10 @@ export function boot(host: Host): Frame { | ||
| 749 | 766 | joinRoom.disabled = !canJoin; |
| 750 | 767 | makeRoom.disabled = !canJoin; |
| 751 | 768 | 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(); | |
| 752 | 773 | } |
| 753 | 774 | |
| 754 | 775 | // --- the buy menu, rebuilt whenever it opens so the prices are honest |
| @@ -1192,6 +1213,7 @@ export function boot(host: Host): Frame { | ||
| 1192 | 1213 | mapName.setAttribute("href", mapLink(mapFrom(level.name))); |
| 1193 | 1214 | pauseId.textContent = `Match ${matchId} on ${level.name}.`; |
| 1194 | 1215 | share.setAttribute("href", `/?match=${matchId}&map=${choice}`); |
| 1216 | share.textContent = shareLabel; | |
| 1195 | 1217 | world = createWorld(level, rules, name, team, 5, seed); |
| 1196 | 1218 | // A fresh match buys from scratch: the last match's loadout is not this |
| 1197 | 1219 | // match's first rebuy. |
| @@ -1231,11 +1253,20 @@ export function boot(host: Host): Frame { | ||
| 1231 | 1253 | if (renderer === null || !policyIn || host.connect === undefined) return; |
| 1232 | 1254 | const name = nameField.value.trim().slice(0, 24) || "you"; |
| 1233 | 1255 | 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; | |
| 1234 | 1264 | session?.close(); |
| 1235 | 1265 | world = null; |
| 1236 | 1266 | session = createSession({ |
| 1237 | 1267 | 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}`), | |
| 1239 | 1270 | code, |
| 1240 | 1271 | now: () => win.performance.now(), |
| 1241 | 1272 | build: (hello) => { |
| @@ -1246,7 +1277,7 @@ export function boot(host: Host): Frame { | ||
| 1246 | 1277 | } |
| 1247 | 1278 | playingId.textContent = `room ${code}`; |
| 1248 | 1279 | mapName.textContent = level.name; |
| 1249 | pauseId.textContent = `Room ${code} on ${level.name}.`; | |
| 1280 | roomMap = level.name; | |
| 1250 | 1281 | return createWorld(level, hello.rules === "long" ? COMPETITIVE : SHORT, name, "ct", 5, hello.seed); |
| 1251 | 1282 | }, |
| 1252 | 1283 | }); |
| @@ -1270,6 +1301,39 @@ export function boot(host: Host): Frame { | ||
| 1270 | 1301 | void takePointer(); |
| 1271 | 1302 | } |
| 1272 | 1303 | |
| 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 | ||
| 1273 | 1337 | joinRoom.addEventListener("click", () => { |
| 1274 | 1338 | const code = roomCodeFrom(roomField.value); |
| 1275 | 1339 | if (code === null) { |
| @@ -1306,6 +1370,7 @@ export function boot(host: Host): Frame { | ||
| 1306 | 1370 | return; |
| 1307 | 1371 | } |
| 1308 | 1372 | roomField.value = code; |
| 1373 | showRoomLink(code); | |
| 1309 | 1374 | startRoom(code); |
| 1310 | 1375 | }, |
| 1311 | 1376 | () => void (roomSaid.textContent = NO_MAKE), |
| @@ -1521,8 +1586,11 @@ export function boot(host: Host): Frame { | ||
| 1521 | 1586 | // the first snapshot has described it. |
| 1522 | 1587 | if (session !== null) { |
| 1523 | 1588 | 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}.`; | |
| 1526 | 1594 | } |
| 1527 | 1595 | if (world === null || !running) return; |
| 1528 | 1596 |
src/client/session.test.ts +27 −2
| @@ -30,7 +30,7 @@ import { | ||
| 30 | 30 | import { DE_FORGE } from "./map.ts"; |
| 31 | 31 | import { SHORT } from "./match.ts"; |
| 32 | 32 | 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, | |
| 34 | 34 | } from "./session.ts"; |
| 35 | 35 | import { createWorld, NO_INPUT, type Player, type World } from "./world.ts"; |
| 36 | 36 | |
| @@ -208,7 +208,7 @@ test("the session connects to the url it was given and asks for bytes it can rea | ||
| 208 | 208 | expect(s.session.world()).toBeNull(); |
| 209 | 209 | expect(s.session.seat()).toBe(-1); |
| 210 | 210 | 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, | |
| 212 | 212 | }); |
| 213 | 213 | }); |
| 214 | 214 | |
| @@ -289,6 +289,30 @@ test("a later roster is the one that counts, and our body stays ours", () => { | ||
| 289 | 289 | expect(world.me).toBe(body(world, SEAT)); |
| 290 | 290 | }); |
| 291 | 291 | |
| 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 | ||
| 292 | 316 | test("a roster line for a seat this room has no body for changes nothing", () => { |
| 293 | 317 | const s = stage(); |
| 294 | 318 | s.join(); |
| @@ -327,6 +351,7 @@ test("a snapshot moves the bodies and the round with them", () => { | ||
| 327 | 351 | expect(world.match.round).toBe(3); |
| 328 | 352 | expect(s.session.standing(5000)).toEqual({ |
| 329 | 353 | 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", | |
| 330 | 355 | }); |
| 331 | 356 | // `behind` is the milliseconds since it landed, and nothing else. |
| 332 | 357 | expect(s.session.standing(5120).behind).toBe(120); |
src/client/session.ts +43 −1
| @@ -50,8 +50,15 @@ export type Standing = { | ||
| 50 | 50 | line: string; |
| 51 | 51 | /** How many people are in the room, bots not counted. */ |
| 52 | 52 | 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; | |
| 53 | 57 | }; |
| 54 | 58 | |
| 59 | /** One person in a room, as the room line says them. */ | |
| 60 | export type Here = { name: string; team: "t" | "ct"; you: boolean }; | |
| 61 | ||
| 55 | 62 | export type Session = { |
| 56 | 63 | /** The world the snapshots are filling, or null until the first `hello`. */ |
| 57 | 64 | world(): World | null; |
| @@ -94,6 +101,8 @@ export function createSession(options: { | ||
| 94 | 101 | }): Session { |
| 95 | 102 | let seat = -1; |
| 96 | 103 | let people = 0; |
| 104 | let here: Here[] = []; | |
| 105 | let side: "t" | "ct" | null = null; | |
| 97 | 106 | |
| 98 | 107 | const member = createMember<World, Down, Omit<Pressed, "tick">, Snapshot>({ |
| 99 | 108 | connect: options.connect, |
| @@ -147,12 +156,16 @@ export function createSession(options: { | ||
| 147 | 156 | /** Names and bots off the roster, onto the bodies they belong to. */ |
| 148 | 157 | function apply(world: World, roster: Hello["roster"]): void { |
| 149 | 158 | people = 0; |
| 159 | here = []; | |
| 150 | 160 | for (const seated of roster) { |
| 151 | 161 | const body = world.players[seated.seat]; |
| 152 | 162 | if (body === undefined) continue; |
| 153 | 163 | body.name = seated.name; |
| 154 | 164 | 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 }); | |
| 156 | 169 | } |
| 157 | 170 | // Ours is ours whatever the roster says about the body: the camera follows it. |
| 158 | 171 | if (world.players[seat] !== undefined) world.me = world.players[seat] as Player; |
| @@ -163,8 +176,37 @@ export function createSession(options: { | ||
| 163 | 176 | seat: () => seat, |
| 164 | 177 | standing: (now) => ({ |
| 165 | 178 | code: options.code, live: member.live(), behind: member.behind(now), line: member.line(now), people, |
| 179 | here, side, | |
| 166 | 180 | }), |
| 167 | 181 | press: member.press, |
| 168 | 182 | close: member.close, |
| 169 | 183 | }; |
| 170 | 184 | } |
| 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 = { | ||
| 165 | 165 | * contract between the page and this file, so `src/server.test.ts` checks the |
| 166 | 166 | * served page against this same list rather than a second copy of it. |
| 167 | 167 | */ |
| 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"]; | |
| 169 | 169 | |
| 170 | 170 | /** The ones the server renders with `hidden` on them: the page starts at the menu. */ |
| 171 | 171 | 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. | ||
| 763 | 763 | functions — deliver these bytes or this line of text to that member, close that |
| 764 | 764 | member with a code and a sentence — so nothing in here is a socket, and a test |
| 765 | 765 | 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. | |
| 771 | 773 | The room type is yours; the type the hub needs it to be is `HubRoom`, which is |
| 772 | 774 | three fields — the code, the members by id, and when somebody was last in it — |
| 773 | 775 | 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> = { | ||
| 126 | 126 | snapshotHz: number; |
| 127 | 127 | /** A fresh room under that code, opened with the game's own options. */ |
| 128 | 128 | 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; | |
| 131 | 135 | /** And stands up again. Null is somebody who was not in it. */ |
| 132 | 136 | leave(room: R, id: string, now: number): M | null; |
| 133 | 137 | /** 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> = { | ||
| 181 | 185 | open(options: O, now: number): R | null; |
| 182 | 186 | find(code: string): R | undefined; |
| 183 | 187 | /** 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; | |
| 185 | 189 | /** And leaves it, whichever room they were in. */ |
| 186 | 190 | exit(id: string, now: number): void; |
| 187 | 191 | /** 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>( | ||
| 238 | 242 | return room; |
| 239 | 243 | }, |
| 240 | 244 | find: (code) => rooms.get(code), |
| 241 | enter(code, id, name, now) { | |
| 245 | enter(code, id, name, now, asked) { | |
| 242 | 246 | const room = rooms.get(code); |
| 243 | 247 | if (room === undefined) return null; |
| 244 | const member = game.join(room, id, name, now); | |
| 248 | const member = game.join(room, id, name, now, asked); | |
| 245 | 249 | if (member === null) return null; |
| 246 | 250 | where.set(id, { code, heardAt: now }); |
| 247 | 251 | 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"; | ||
| 34 | 34 | import { ALL, damageRows, gunsOf } from "../sheet.ts"; |
| 35 | 35 | import { GAMES } from "../games.ts"; |
| 36 | 36 | 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"; | |
| 38 | 41 | import { WEAPONS_HREF, WEAPONS_SECTION, WEAPONS_SUMMARY } from "./weapons.ts"; |
| 39 | 42 | import { escape, SUPPORT } from "./shell.ts"; |
| 40 | 43 | |
| @@ -225,6 +228,38 @@ test("a link that asked for one map by name opens on it, before Play and as a li | ||
| 225 | 228 | expect(bad).not.toMatch(/id="mapseed"[^>]*value=/); |
| 226 | 229 | }); |
| 227 | 230 | |
| 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 | ||
| 228 | 263 | test("the id's sentence promises what an id keeps, and says the link is what keeps the map", () => { |
| 229 | 264 | // #215: the page used to say an id was "the whole match: the same map, …", |
| 230 | 265 | // 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"; | ||
| 27 | 27 | import { CARD, type Card } from "../client/weights.ts"; |
| 28 | 28 | import { mapFrom, mapLink, type MapChoice } from "../mapname.ts"; |
| 29 | 29 | import { LONGEST_PASTE } from "../matchlink.ts"; |
| 30 | import { roomLink } from "../roomlink.ts"; | |
| 30 | 31 | import type { Who } from "../who.ts"; |
| 31 | 32 | import { figure } from "./figure.ts"; |
| 32 | 33 | import { navFor, noscriptFor, othersHtml } from "./games.ts"; |
| @@ -131,6 +132,13 @@ export type Asked = { | ||
| 131 | 132 | map: MapChoice; |
| 132 | 133 | /** A `?map=` was there and was not a map, which is de_forge and a sentence. */ |
| 133 | 134 | 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; | |
| 134 | 142 | }; |
| 135 | 143 | |
| 136 | 144 | /** A link with nothing on it: no id asked for, no map asked for, a fresh match. */ |
| @@ -182,6 +190,8 @@ export const STYLE = ` | ||
| 182 | 190 | a.share { display:inline-flex; align-items:center; min-height:44px; padding:0 .7rem; border-radius:.45rem; |
| 183 | 191 | background:#1b2230; border:1px solid var(--line); color:var(--ct); text-decoration:none; } |
| 184 | 192 | 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; } | |
| 185 | 195 | #thismatch p { margin:.35rem 0; } |
| 186 | 196 | /* The thumb's own sensitivity is a field only where there is a thumb: a mouse |
| 187 | 197 | 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"; | ||
| 260 | 270 | */ |
| 261 | 271 | export const UNPLANNED = "A plan of it is drawn here as soon as the game has booted."; |
| 262 | 272 | |
| 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 | ||
| 263 | 276 | /** The link that plays this match again: the id it names and the map it was on. */ |
| 264 | 277 | export const shareHref = (asked: Asked): string => |
| 265 | 278 | asked.match === null ? "/" : `/?match=${asked.match}&map=${asked.map}`; |
| @@ -387,13 +400,18 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => ` | ||
| 387 | 400 | <div class="row"><button type="button" class="primary" id="play" disabled>Play</button></div> |
| 388 | 401 | <div class="row"> |
| 389 | 402 | <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> | |
| 391 | 405 | <button type="button" id="joinroom" disabled>Join room</button> |
| 392 | 406 | <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> | |
| 393 | 408 | </div> |
| 394 | <p class="hint" id="roomsaid">Play with friends: make a room, read the code out, and they join it. A room'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'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'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's match ends, the server puts it on the leaderboard for everyone | |
| 397 | 415 | still seated — leave before the end and it is not counted for you. Rooms live in this process: a deploy |
| 398 | 416 | ends them, and an empty one is forgotten after ninety seconds.</p> |
| 399 | 417 | <p class="hint" id="prep">Preparing textures…</p> |
src/roomlink.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | /** | |
| 2 | * The link that is a room: `/?room=CODE`. Open it and the menu has the code in | |
| 3 | * its field and Join under the focus. It never joins by itself — pointer lock | |
| 4 | * needs a press, and a link that drops somebody into a live round is one nobody | |
| 5 | * sends twice. | |
| 6 | * | |
| 7 | * One function in one place because two ends build it: the page renders it for | |
| 8 | * a `?room=` it was opened with (`src/pages/game.ts`), and the menu rewrites it | |
| 9 | * for a room just made or a code just typed (`src/client/main.ts`). The code is | |
| 10 | * `roomCodeFrom`'s by the time it gets here, so there is nothing in it to | |
| 11 | * encode; the page escapes it anyway. | |
| 12 | */ | |
| 13 | export const roomLink = (code: string): string => `/?room=${code}`; |
src/rooms/hub.test.ts +11
| @@ -31,6 +31,17 @@ import { createRoom, isIdle, isOver, join as sit, type Finished, type Room } fro | ||
| 31 | 31 | import { ENDING_TICKS, forward, lastRound, opened, spy } from "./stage.test-helper.ts"; |
| 32 | 32 | import { type Player } from "../client/world.ts"; |
| 33 | 33 | |
| 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 | ||
| 34 | 45 | test("a hub holds its rooms, hears one member and never the other room", () => { |
| 35 | 46 | const wire = spy(); |
| 36 | 47 | const hub = createHub(wire, { now: 0 }); |
src/rooms/room.test.ts +44 −2
| @@ -13,8 +13,8 @@ | ||
| 13 | 13 | |
| 14 | 14 | import { expect, test } from "bun:test"; |
| 15 | 15 | 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, | |
| 18 | 18 | } from "./room.ts"; |
| 19 | 19 | import { writeInput, SWITCH_PRIMARY, SWITCH_SECONDARY } from "./wire.ts"; |
| 20 | 20 | // 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", () | ||
| 63 | 63 | expect(r.members.size).toBe(2); |
| 64 | 64 | }); |
| 65 | 65 | |
| 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 | ||
| 66 | 108 | test("a room fills up, and the eleventh person is refused rather than dropped", () => { |
| 67 | 109 | const r = room(); |
| 68 | 110 | 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 { | ||
| 221 | 221 | export const memberCount = (room: Room): number => room.members.size; |
| 222 | 222 | |
| 223 | 223 | /** |
| 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. | |
| 227 | 228 | */ |
| 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 { | |
| 229 | 243 | const held = { t: 0, ct: 0 }; |
| 230 | 244 | for (const member of room.members.values()) { |
| 231 | 245 | const body = room.world.players[member.seat]; |
| 232 | 246 | if (body !== undefined) held[body.team] += 1; |
| 233 | 247 | } |
| 234 | const want: Team = held.t <= held.ct ? "t" : "ct"; | |
| 248 | const want: Team = wanted ?? (held.t <= held.ct ? "t" : "ct"); | |
| 235 | 249 | const on = (team: Team): number => room.world.players.findIndex((p) => p.bot && p.team === team); |
| 236 | 250 | const first = on(want); |
| 237 | 251 | if (first >= 0) return first; |
| @@ -244,13 +258,16 @@ export function freeSeat(room: Room): number | null { | ||
| 244 | 258 | * its guns and where it is standing, because a round in progress does not stop |
| 245 | 259 | * for an arrival — and the seat is theirs until they leave. |
| 246 | 260 | * |
| 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 | * | |
| 247 | 264 | * Null is a full room, which is the member cap and is answered as a sentence by |
| 248 | 265 | * the caller rather than as a dropped connection. |
| 249 | 266 | */ |
| 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 { | |
| 251 | 268 | const existing = room.members.get(id); |
| 252 | 269 | if (existing !== undefined) return existing; |
| 253 | const seat = freeSeat(room); | |
| 270 | const seat = freeSeat(room, sideFrom(side)); | |
| 254 | 271 | if (seat === null) return null; |
| 255 | 272 | const body = room.world.players[seat] as Player; |
| 256 | 273 | 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 | ||
| 158 | 158 | } |
| 159 | 159 | }); |
| 160 | 160 | |
| 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 | ||
| 161 | 174 | test("`/?map=gen_…` preselects one map by name, and nothing else of a match", async () => { |
| 162 | 175 | const name = "gen_14u5j6g"; |
| 163 | 176 | 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 | ||
| 1708 | 1721 | } |
| 1709 | 1722 | }); |
| 1710 | 1723 | |
| 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 | ||
| 1711 | 1744 | test("a text frame on the socket is dropped, and the same bytes as binary are an input", async () => { |
| 1712 | 1745 | const room = await openedRoom("text frame"); |
| 1713 | 1746 | 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 | ||
| 71 | 71 | import { policyFor } from "./policy.ts"; |
| 72 | 72 | import { roomCodeFrom } from "./engine/index.ts"; |
| 73 | 73 | 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"; | |
| 74 | 76 | import { roomWriter, type Seated } from "./seated.ts"; |
| 75 | 77 | import { playedOn } from "./mapname.ts"; |
| 76 | 78 | import { styleFor, type Linked } from "./pages/shell.ts"; |
| @@ -295,7 +297,10 @@ export const hub = createHub({ | ||
| 295 | 297 | * What one connection is: which member, in which room, under what name — and |
| 296 | 298 | * as whom, read once at the upgrade the way `/api/match` reads it per post. |
| 297 | 299 | */ |
| 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 | }; | |
| 299 | 304 | |
| 300 | 305 | /** |
| 301 | 306 | * 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 | ||
| 479 | 484 | * error either: the page says so in a sentence and plays the default, and it |
| 480 | 485 | * escapes what it renders anyway, because two checks are the point. |
| 481 | 486 | * |
| 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 | |
| 483 | 491 | * shooter's own renderer, and the range and the example never see it. |
| 484 | 492 | */ |
| 485 | 493 | const asked = (request: Request): Asked => { |
| @@ -493,6 +501,8 @@ const asked = (request: Request): Asked => { | ||
| 493 | 501 | badMatch: wanted !== null && match === null, |
| 494 | 502 | map, |
| 495 | 503 | 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")), | |
| 496 | 506 | }; |
| 497 | 507 | }; |
| 498 | 508 | |
| @@ -742,7 +752,7 @@ export const server = Bun.serve<Joined, never>({ | ||
| 742 | 752 | idleTimeout: 30, |
| 743 | 753 | open(ws) { |
| 744 | 754 | 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); | |
| 746 | 756 | if (joined === null) { |
| 747 | 757 | ws.close(CLOSE_GONE, "that room is full"); |
| 748 | 758 | sockets.delete(ws.data.id); |
| @@ -867,6 +877,9 @@ async function route(request: Request, listener: Bun.Server<Joined>): Promise<Re | ||
| 867 | 877 | * member *is* is the forge's word, verified here once and carried on the |
| 868 | 878 | * connection: it is what a finished room's row is written under, and a name |
| 869 | 879 | * 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. | |
| 870 | 883 | */ |
| 871 | 884 | if (isRead(request) && url.pathname === SOCKET_PATH) { |
| 872 | 885 | if (!sameOrigin(request)) return text("forbidden", 403); |
| @@ -876,8 +889,9 @@ async function route(request: Request, listener: Bun.Server<Joined>): Promise<Re | ||
| 876 | 889 | const session = readSession(request) ?? newSession(); |
| 877 | 890 | const id = `${session}.${crypto.randomUUID().slice(0, 8)}`; |
| 878 | 891 | const name = (url.searchParams.get("name") ?? "").slice(0, 24); |
| 892 | const team = sideFrom(url.searchParams.get("team")); | |
| 879 | 893 | 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 }, | |
| 881 | 895 | headers: { ...SECURITY, "set-cookie": sessionCookie(session) }, |
| 882 | 896 | }); |
| 883 | 897 | // A plain GET of this path — a person following a link, a crawler — is not |
new above
offline