Public: anyone can read this; the crew can change it.
forked from trevato/cs
The doors the board is written through get their first test files: src/gate.ts and src/run.ts #363
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/347-the-doors-the-board-is-written-through-g → main 1 commit fork
6 files
changed +525
−64
AGENTS.md +1 −1
| @@ -33,7 +33,7 @@ preview at its own address, deployed from your branch as you push. | ||
| 33 | 33 | come from the environment, never the repository; no `eval`, no shell |
| 34 | 34 | built from user text; a private thing needs a session in an HttpOnly cookie. |
| 35 | 35 | Every request body is capped by the bytes actually read (`readBody` in |
| 36 | `src/server.ts`) — never by a Content-Length, which a chunked request does | |
| 36 | `src/gate.ts`) — never by a Content-Length, which a chunked request does | |
| 37 | 37 | not have and a lie does not honour. `HEAD` answers like `GET`. |
| 38 | 38 | An app with an owner or admin is never claimed by whoever visits first: |
| 39 | 39 | the first sign-in needs a secret only the operator holds — a password from |
src/gate.test.ts added +209
| @@ -0,0 +1,209 @@ | ||
| 1 | /** | |
| 2 | * The door every posted score comes in through, held without a server. Each | |
| 3 | * step `src/gate.ts` owns is called here with plain objects — a `Request` built | |
| 4 | * in the test, a `Gate` written in the test, a `Who` typed out — and each | |
| 5 | * refusal is asserted by what a caller sees: the status and the words the | |
| 6 | * server answers with, or the player a score is written as. | |
| 7 | * | |
| 8 | * The HTTP end of these doors — same-origin, the limiter, the cookie, the | |
| 9 | * board — is `src/server.test.ts`'s. This file is what those tests stand on. | |
| 10 | */ | |
| 11 | ||
| 12 | import { expect, test } from "bun:test"; | |
| 13 | import { | |
| 14 | admit, BODY_MAX, type Checked, cleanName, type Gate, integer, playerOf, readBody, type Refused, refused, | |
| 15 | } from "./gate.ts"; | |
| 16 | import { ANON, type Who } from "./who.ts"; | |
| 17 | ||
| 18 | /** Most a test gate's one number may be. */ | |
| 19 | const MOST = 9; | |
| 20 | ||
| 21 | /** | |
| 22 | * A gate that knows no game: its body is `{ n, name }`, with `n` counted up to | |
| 23 | * `MOST`. What it accepts carries the name it was given and writes nothing. | |
| 24 | */ | |
| 25 | const GATE: Gate = { | |
| 26 | path: "/api/test", | |
| 27 | refusal: "bad test", | |
| 28 | accept: (value) => { | |
| 29 | if (typeof value !== "object" || value === null) return null; | |
| 30 | const body = value as Record<string, unknown>; | |
| 31 | const n = integer(body.n, MOST); | |
| 32 | return n === null ? null : { name: cleanName(body.name), write: () => true }; | |
| 33 | }, | |
| 34 | board: () => "<p>board</p>", | |
| 35 | }; | |
| 36 | ||
| 37 | /** A POST whose body is exactly `body`, the way the runtime hands one to `route`. */ | |
| 38 | const post = (body: BodyInit | null): Request => new Request("http://door.test/api/test", { method: "POST", body }); | |
| 39 | ||
| 40 | /** A chunked body: `chunks` pieces of `size` bytes each, and no Content-Length at all. */ | |
| 41 | const chunked = (size: number, chunks: number): Request => { | |
| 42 | let sent = 0; | |
| 43 | const stream = new ReadableStream<Uint8Array>({ | |
| 44 | pull(controller) { | |
| 45 | if (sent === chunks) return controller.close(); | |
| 46 | sent += 1; | |
| 47 | controller.enqueue(new Uint8Array(size).fill(0x20)); | |
| 48 | }, | |
| 49 | }); | |
| 50 | return post(stream); | |
| 51 | }; | |
| 52 | ||
| 53 | /** What the door said, in the two words a caller can see, or "through" and the name. */ | |
| 54 | const said = (came: Checked | Refused): string => | |
| 55 | refused(came) ? `${came.status} ${came.reason}` : `through as ${came.name}`; | |
| 56 | ||
| 57 | // --- the cap on the body | |
| 58 | ||
| 59 | test("a body at the cap is read whole, and one byte past it is too large", async () => { | |
| 60 | // A JSON string exactly BODY_MAX bytes long: read, parsed, and refused only | |
| 61 | // for its shape — which proves the cap let every byte of it through. | |
| 62 | const at = JSON.stringify("x".repeat(BODY_MAX - 2)); | |
| 63 | expect(at.length).toBe(BODY_MAX); | |
| 64 | expect(said(await admit(GATE, post(at)))).toBe("422 bad test"); | |
| 65 | expect(said(await admit(GATE, post(at + " ")))).toBe("413 too large"); | |
| 66 | expect(await readBody(post(at + " "))).toBeUndefined(); | |
| 67 | expect((await readBody(post(at)))?.byteLength).toBe(BODY_MAX); | |
| 68 | }); | |
| 69 | ||
| 70 | test("a chunked body is capped by what arrives, not by a length it never declared", async () => { | |
| 71 | const request = chunked(1024, BODY_MAX / 1024 + 1); | |
| 72 | expect(request.headers.get("content-length")).toBeNull(); | |
| 73 | expect(said(await admit(GATE, request))).toBe("413 too large"); | |
| 74 | // Split into pieces that land exactly on the cap, it is read whole. | |
| 75 | expect((await readBody(chunked(1024, BODY_MAX / 1024)))?.byteLength).toBe(BODY_MAX); | |
| 76 | }); | |
| 77 | ||
| 78 | test("a body that never ends is cut off rather than read for ever", async () => { | |
| 79 | // A stream with no end: without the cut at eight times the cap this await | |
| 80 | // would never settle, and the test would time out rather than pass. | |
| 81 | let pulled = 0; | |
| 82 | const endless = post(new ReadableStream<Uint8Array>({ | |
| 83 | pull(controller) { | |
| 84 | pulled += 1; | |
| 85 | controller.enqueue(new Uint8Array(1024)); | |
| 86 | }, | |
| 87 | })); | |
| 88 | expect(said(await admit(GATE, endless))).toBe("413 too large"); | |
| 89 | expect(pulled * 1024).toBeLessThanOrEqual(BODY_MAX * 8 + 1024 * 2); | |
| 90 | }); | |
| 91 | ||
| 92 | test("a smaller cap is honoured, which is what the plan routes read under", async () => { | |
| 93 | expect(await readBody(post("x".repeat(1025)), 1024)).toBeUndefined(); | |
| 94 | expect((await readBody(post("x".repeat(1024)), 1024))?.byteLength).toBe(1024); | |
| 95 | }); | |
| 96 | ||
| 97 | // --- the JSON | |
| 98 | ||
| 99 | test("a body that is not JSON is bad json, and so is no body at all", async () => { | |
| 100 | for (const body of ["", "{", "n=3", "{'n': 3}", "{\"n\": 3,}", "\u0000"]) { | |
| 101 | expect(said(await admit(GATE, post(body)))).toBe("400 bad json"); | |
| 102 | } | |
| 103 | expect(said(await admit(GATE, post(null)))).toBe("400 bad json"); | |
| 104 | // Bytes that are not UTF-8 decode to replacement characters, which is not JSON. | |
| 105 | expect(said(await admit(GATE, post(new Uint8Array([0xff, 0xfe, 0x7b]))))).toBe("400 bad json"); | |
| 106 | }); | |
| 107 | ||
| 108 | // --- the shape | |
| 109 | ||
| 110 | test("JSON that is not the gate's shape is refused in the gate's own words", async () => { | |
| 111 | for (const body of ["null", "3", "\"n\"", "[]", "[3]", "{}", "{\"m\": 3}", "{\"n\": \"3\"}"]) { | |
| 112 | expect(said(await admit(GATE, post(body)))).toBe("422 bad test"); | |
| 113 | } | |
| 114 | const other: Gate = { ...GATE, refusal: "bad run" }; | |
| 115 | expect(said(await admit(other, post("{}")))).toBe("422 bad run"); | |
| 116 | }); | |
| 117 | ||
| 118 | test("the gate's shape, met, comes through with the name it claimed", async () => { | |
| 119 | expect(said(await admit(GATE, post(JSON.stringify({ n: 3, name: " vesper " }))))).toBe("through as vesper"); | |
| 120 | expect(said(await admit(GATE, post(JSON.stringify({ n: 0 }))))).toBe("through as anon"); | |
| 121 | // What came through is the gate's, whole: its write is the one that is called. | |
| 122 | const came = await admit(GATE, post(JSON.stringify({ n: MOST }))); | |
| 123 | expect(refused(came)).toBe(false); | |
| 124 | }); | |
| 125 | ||
| 126 | // --- a counted number | |
| 127 | ||
| 128 | test("a count is a whole number from 0 to its ceiling, and a fraction is floored", () => { | |
| 129 | expect(integer(0, MOST)).toBe(0); | |
| 130 | expect(integer(MOST, MOST)).toBe(MOST); | |
| 131 | expect(integer(3, MOST)).toBe(3); | |
| 132 | expect(integer(3.9, MOST)).toBe(3); | |
| 133 | expect(integer(MOST + 0.5, MOST)).toBe(MOST); | |
| 134 | }); | |
| 135 | ||
| 136 | test("a number that is not counted is not a count", () => { | |
| 137 | expect(integer(MOST + 1, MOST)).toBeNull(); | |
| 138 | expect(integer(-1, MOST)).toBeNull(); | |
| 139 | expect(integer(-0.5, MOST)).toBeNull(); | |
| 140 | expect(integer(-0, MOST)).toBeNull(); | |
| 141 | for (const value of [Number.NaN, Infinity, -Infinity, "3", "", null, undefined, true, [3], { n: 3 }, 3n]) { | |
| 142 | expect(integer(value, MOST)).toBeNull(); | |
| 143 | } | |
| 144 | }); | |
| 145 | ||
| 146 | test("a count that is not counted is refused at the door, not stored", async () => { | |
| 147 | for (const n of [MOST + 1, -1, 1e308, "3", null, true]) { | |
| 148 | expect(said(await admit(GATE, post(JSON.stringify({ n }))))).toBe("422 bad test"); | |
| 149 | } | |
| 150 | // JSON has no NaN or Infinity, and a body that tries one is not JSON. | |
| 151 | expect(said(await admit(GATE, post("{\"n\": NaN}")))).toBe("400 bad json"); | |
| 152 | expect(said(await admit(GATE, post("{\"n\": 1e999}")))).toBe("422 bad test"); | |
| 153 | }); | |
| 154 | ||
| 155 | // --- a printable name | |
| 156 | ||
| 157 | test("a name is kept as typed, trimmed and cut at 24", () => { | |
| 158 | expect(cleanName("vesper")).toBe("vesper"); | |
| 159 | expect(cleanName(" vesper ")).toBe("vesper"); | |
| 160 | expect(cleanName("x".repeat(30))).toBe("x".repeat(24)); | |
| 161 | expect(cleanName("été \u{1F600}")).toBe("été \u{1F600}"); | |
| 162 | }); | |
| 163 | ||
| 164 | test("what is not printable in a name is a space, and a name of nothing but that is anon", () => { | |
| 165 | expect(cleanName("a\u0000b")).toBe("a b"); | |
| 166 | expect(cleanName("one\r\ntwo")).toBe("one two"); | |
| 167 | expect(cleanName("\u001b[2Jwiped")).toBe("[2Jwiped"); | |
| 168 | expect(cleanName("del\u007fete")).toBe("del ete"); | |
| 169 | // The C1 controls: U+009B is a CSI to a terminal that reads eight-bit | |
| 170 | // controls, and U+0085 is a line break to some. | |
| 171 | expect(cleanName("a\u009b2Jb")).toBe("a 2Jb"); | |
| 172 | expect(cleanName("a\u0085b")).toBe("a b"); | |
| 173 | expect(cleanName("a\u0080b\u009fc")).toBe("a b c"); | |
| 174 | // One line: the two separators JavaScript and text layout both break on. | |
| 175 | expect(cleanName("one\u2028two\u2029three")).toBe("one two three"); | |
| 176 | // Printable neighbours of those ranges are left alone. | |
| 177 | expect(cleanName("~\u00a0¡")).toBe("~\u00a0¡"); | |
| 178 | for (const value of ["", " ", "\t\n", "\u0000\u009b\u2028"]) expect(cleanName(value)).toBe("anon"); | |
| 179 | }); | |
| 180 | ||
| 181 | test("a name that is not a string is anon", () => { | |
| 182 | for (const value of [undefined, null, 7, true, ["vesper"], { name: "vesper" }]) { | |
| 183 | expect(cleanName(value)).toBe("anon"); | |
| 184 | } | |
| 185 | }); | |
| 186 | ||
| 187 | // --- who it belongs to | |
| 188 | ||
| 189 | const VESPER: Who = { role: "user", id: "u-42", name: "vesper" }; | |
| 190 | ||
| 191 | test("a guest is written under the name they claimed, cleaned, and no identity", () => { | |
| 192 | expect(playerOf(ANON, "s-1", " guest\u0000one ")).toEqual({ userId: "", session: "s-1", name: "guest one" }); | |
| 193 | expect(playerOf(ANON, "s-1", "")).toEqual({ userId: "", session: "s-1", name: "anon" }); | |
| 194 | }); | |
| 195 | ||
| 196 | test("a verified identity is written under its own name, whatever the browser claimed", () => { | |
| 197 | expect(playerOf(VESPER, "s-2", "somebody else")).toEqual({ userId: "u-42", session: "s-2", name: "vesper" }); | |
| 198 | const admin: Who = { role: "admin", id: "a-1", name: "root\u009b" }; | |
| 199 | // The forge's name is cleaned too: it is printed on the same board. | |
| 200 | expect(playerOf(admin, "s-3", "vesper")).toEqual({ userId: "a-1", session: "s-3", name: "root" }); | |
| 201 | }); | |
| 202 | ||
| 203 | test("a guest can never be written as a signed-in player's row", () => { | |
| 204 | // Claiming a verified player's name gives a guest that name and nothing | |
| 205 | // else: the row a board keys by identity is a userId, and a guest's is empty. | |
| 206 | const guest = playerOf(ANON, "s-4", VESPER.name); | |
| 207 | expect(guest.userId).toBe(""); | |
| 208 | expect(guest.name).toBe(VESPER.name); | |
| 209 | }); |
src/gate.ts +94 −4
| @@ -16,8 +16,11 @@ | ||
| 16 | 16 | * accepted at it, and who the thing that came through belongs to. |
| 17 | 17 | * |
| 18 | 18 | * Nothing here knows a game. It holds the two primitives every boundary in this |
| 19 | * app needs — a counted number and a printable name — and the two shapes the | |
| 20 | * server calls: `Gate`, one game's door, and `Checked`, what came through it. | |
| 19 | * app needs — a counted number and a printable name — the two shapes the | |
| 20 | * server calls: `Gate`, one game's door, and `Checked`, what came through it — | |
| 21 | * and the steps of the door that are plain functions: `readBody` under its cap, | |
| 22 | * `admit` through JSON and a gate's shape, and `playerOf` for who it belongs | |
| 23 | * to. `src/gate.test.ts` holds each of them without a server. | |
| 21 | 24 | * The games' own gates are assembled in `src/server.ts` beside `RENDER`, for |
| 22 | 25 | * the same reason the pages are: a gate holds functions, and a function is not |
| 23 | 26 | * data in a registry. |
| @@ -25,6 +28,7 @@ | ||
| 25 | 28 | |
| 26 | 29 | import type { Database } from "bun:sqlite"; |
| 27 | 30 | import type { Player } from "./db.ts"; |
| 31 | import type { Who } from "./who.ts"; | |
| 28 | 32 | |
| 29 | 33 | /** |
| 30 | 34 | * A whole number between 0 and `max`, or null. Everything either game posts is |
| @@ -42,10 +46,17 @@ export function integer(value: unknown, max: number): number | null { | ||
| 42 | 46 | return n < 0 || n > max ? null : n; |
| 43 | 47 | } |
| 44 | 48 | |
| 45 | /** A name is one line of printable text, short, and never empty. */ | |
| 49 | /** | |
| 50 | * A name is one line of printable text, short, and never empty. Unprintable is | |
| 51 | * the C0 controls, DEL and the C1 controls — the same class the terminal's | |
| 52 | * `printable` in `src/terminal/screen.ts` blots out on the way out, so U+009B, | |
| 53 | * which a terminal can read as CSI, is not stored as part of a name — and one | |
| 54 | * line also means no U+2028 or U+2029, the two separators that are a line | |
| 55 | * break to JavaScript and to every text layout. | |
| 56 | */ | |
| 46 | 57 | export function cleanName(value: unknown): string { |
| 47 | 58 | if (typeof value !== "string") return "anon"; |
| 48 | const stripped = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 24); | |
| 59 | const stripped = value.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").trim().slice(0, 24); | |
| 49 | 60 | return stripped.length === 0 ? "anon" : stripped; |
| 50 | 61 | } |
| 51 | 62 | |
| @@ -74,3 +85,82 @@ export type Gate = { | ||
| 74 | 85 | /** What a post that landed answers with: this game's board, rendered. */ |
| 75 | 86 | board(db: Database): string; |
| 76 | 87 | }; |
| 88 | ||
| 89 | /** | |
| 90 | * The most a posted score's body may be, in bytes actually read. A match is a | |
| 91 | * dozen fields and a run is eight; sixteen kilobytes is generous for either. | |
| 92 | */ | |
| 93 | export const BODY_MAX = 16 * 1024; | |
| 94 | ||
| 95 | /** | |
| 96 | * A request body is read up to a cap and no further, counting the bytes as | |
| 97 | * they arrive. A declared Content-Length is a claim: a chunked request has | |
| 98 | * none, and a lie is free. Undefined means "too large" — refuse it. Past the | |
| 99 | * cap the rest is drained and dropped so the connection stays usable for | |
| 100 | * the next request; past eight times the cap it is cut off instead. | |
| 101 | */ | |
| 102 | export async function readBody(request: Request, max = BODY_MAX): Promise<Uint8Array | undefined> { | |
| 103 | const reader = request.body?.getReader(); | |
| 104 | if (reader === undefined) return new Uint8Array(); | |
| 105 | const parts: Uint8Array[] = []; | |
| 106 | let size = 0; | |
| 107 | for (;;) { | |
| 108 | const { done, value } = await reader.read(); | |
| 109 | if (done) break; | |
| 110 | size += value.byteLength; | |
| 111 | if (size > max) { | |
| 112 | for (;;) { | |
| 113 | const next = await reader.read(); | |
| 114 | if (next.done) return undefined; | |
| 115 | size += next.value.byteLength; | |
| 116 | if (size > max * 8) { | |
| 117 | await reader.cancel(); | |
| 118 | return undefined; | |
| 119 | } | |
| 120 | } | |
| 121 | } | |
| 122 | parts.push(value); | |
| 123 | } | |
| 124 | const body = new Uint8Array(size); | |
| 125 | let at = 0; | |
| 126 | for (const part of parts) { | |
| 127 | body.set(part, at); | |
| 128 | at += part.byteLength; | |
| 129 | } | |
| 130 | return body; | |
| 131 | } | |
| 132 | ||
| 133 | /** What a door says when it will not take a body: the status, and the words. */ | |
| 134 | export type Refused = { status: 413 | 400 | 422; reason: string }; | |
| 135 | ||
| 136 | /** | |
| 137 | * The body of a post, through one game's door: read under the cap, parsed as | |
| 138 | * JSON and checked by that game's shape. Either what came through, or the | |
| 139 | * refusal the server answers with — "too large", "bad json", or the gate's own | |
| 140 | * words for a body that is JSON and not its game's numbers. | |
| 141 | */ | |
| 142 | export async function admit(gate: Gate, request: Request): Promise<Checked | Refused> { | |
| 143 | const body = await readBody(request); | |
| 144 | if (body === undefined) return { status: 413, reason: "too large" }; | |
| 145 | let parsed: unknown; | |
| 146 | try { | |
| 147 | parsed = JSON.parse(new TextDecoder().decode(body)); | |
| 148 | } catch { | |
| 149 | return { status: 400, reason: "bad json" }; | |
| 150 | } | |
| 151 | return gate.accept(parsed) ?? { status: 422, reason: gate.refusal }; | |
| 152 | } | |
| 153 | ||
| 154 | /** Whether what came back from `admit` is a refusal rather than a score. */ | |
| 155 | export const refused = (came: Checked | Refused): came is Refused => "status" in came; | |
| 156 | ||
| 157 | /** | |
| 158 | * Who a score is recorded as. A verified forge identity plays under its own | |
| 159 | * name and the browser's claim is ignored; everyone else is a guest, whose | |
| 160 | * name is theirs to pick and is shown as a guest's wherever it appears. So | |
| 161 | * nobody who is not signed in can ever land in a signed-in player's row. | |
| 162 | */ | |
| 163 | export const playerOf = (visitor: Who, session: string, claimed: string): Player => | |
| 164 | visitor.role === "anon" | |
| 165 | ? { userId: "", session, name: cleanName(claimed) } | |
| 166 | : { userId: visitor.id, session, name: cleanName(visitor.name) }; |
src/independence.test.ts +8
| @@ -126,6 +126,10 @@ test("every game has the sources this test is about, and the app has none", () = | ||
| 126 | 126 | expect(gameOf("src/pages/range.ts")).toBeNull(); |
| 127 | 127 | expect(gameOf("src/games.ts")).toBeNull(); |
| 128 | 128 | expect(gameOf("scripts/build.ts")).toBeNull(); |
| 129 | // The doors both games' scores come in through are the app's, not a game's. | |
| 130 | expect(gameOf("src/gate.ts")).toBeNull(); | |
| 131 | expect(gameOf("src/run.ts")).toBeNull(); | |
| 132 | expect(gameOf("src/result.ts")).toBeNull(); | |
| 129 | 133 | }); |
| 130 | 134 | |
| 131 | 135 | test("no game's sources import another game's, in either direction", () => { |
| @@ -197,6 +201,10 @@ test("the engine's door, the shell, a sibling and the app are what a game may re | ||
| 197 | 201 | expect(importsAnotherGame("src/server.ts", "./pages/range.ts")).toBeNull(); |
| 198 | 202 | expect(importsAnotherGame("src/pages/games.ts", "../games.ts")).toBeNull(); |
| 199 | 203 | expect(importsAnotherGame("scripts/build.ts", "../src/range/main.ts")).toBeNull(); |
| 204 | // The range's door reads the range's drills: the app knowing a game, which | |
| 205 | // is allowed, and its test reads them the same way. | |
| 206 | expect(importsAnotherGame("src/run.ts", "./range/targets.ts")).toBeNull(); | |
| 207 | expect(importsAnotherGame("src/run.test.ts", "./range/targets.ts")).toBeNull(); | |
| 200 | 208 | }); |
| 201 | 209 | |
| 202 | 210 | test("a shooter file that reached into the range is caught by reading it", () => { |
src/run.test.ts added +208
| @@ -0,0 +1,208 @@ | ||
| 1 | /** | |
| 2 | * What a browser may say about a range run, held from both sides of every | |
| 3 | * ceiling in `src/run.ts`. The limits are the drill's own — every expectation | |
| 4 | * below is read out of `DRILLS` in `src/range/targets.ts`, never typed again — | |
| 5 | * so a tuning that moves a drill moves these tests with it, and a ceiling | |
| 6 | * deleted from `run.ts` turns its own pair red. | |
| 7 | * | |
| 8 | * Each refusal is asserted by what the door does with it: `parseRun` answers | |
| 9 | * null and the server answers "bad run". No server, no database: a real run is | |
| 10 | * played here through the range's own verbs, which takes milliseconds. | |
| 11 | */ | |
| 12 | ||
| 13 | import { expect, test } from "bun:test"; | |
| 14 | import { sub, type Vec3 } from "./engine/index.ts"; | |
| 15 | import { | |
| 16 | centreOf, createRun, DRILL_NAMES, DRILLS, type Drill, type DrillName, EYE, report, shoot, start, step, | |
| 17 | } from "./range/targets.ts"; | |
| 18 | import { | |
| 19 | drillNamed, MAX_SEED, mostPerPlate, mostPlates, mostScore, mostShots, parseRun, type RangeRun, SHOTS_A_SECOND, | |
| 20 | } from "./run.ts"; | |
| 21 | ||
| 22 | const RUN_ID = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"; | |
| 23 | ||
| 24 | /** The range's page steps at 50 Hz; so does the script here. */ | |
| 25 | const TICK = 0.02; | |
| 26 | ||
| 27 | /** | |
| 28 | * Thirty seconds of a drill, played by a script that cannot miss: every tick, | |
| 29 | * every plate standing is shot through its centre from the firing line. This | |
| 30 | * is the most a real run of the drill hits and scores, and it is still less | |
| 31 | * than the ceilings, which are what a run *could* not exceed rather than what | |
| 32 | * one does. | |
| 33 | */ | |
| 34 | function perfect(drill: Drill, seed: number): RangeRun { | |
| 35 | const run = createRun(seed, drill); | |
| 36 | start(run); | |
| 37 | let ticks = 0; | |
| 38 | const centre: Vec3 = [0, 0, 0]; | |
| 39 | while (run.phase === "live") { | |
| 40 | step(run, TICK); | |
| 41 | ticks += 1; | |
| 42 | for (const target of [...run.targets]) shoot(run, EYE, sub(centreOf(target, centre), EYE)); | |
| 43 | } | |
| 44 | const r = report(run); | |
| 45 | return { | |
| 46 | runId: RUN_ID, name: "tester", drill: drill.name, | |
| 47 | score: r.score, hits: r.hits, shots: r.shots, seed, seconds: Math.ceil(ticks * TICK), | |
| 48 | }; | |
| 49 | } | |
| 50 | ||
| 51 | /** A posted body: a real run's numbers, with the named fields changed. */ | |
| 52 | const body = (drill: Drill, over: Record<string, unknown> = {}): Record<string, unknown> => ({ | |
| 53 | runId: RUN_ID, name: "tester", drill: drill.name, | |
| 54 | score: 0, hits: 0, shots: 0, seed: 1, seconds: drill.runTime, | |
| 55 | ...over, | |
| 56 | }); | |
| 57 | ||
| 58 | /** Every drill, so a third one is held to all of this by existing. */ | |
| 59 | const EACH: Drill[] = DRILL_NAMES.map((name) => DRILLS[name]); | |
| 60 | ||
| 61 | /** The most seconds a run of the drill may say it lasted: its clock and one second of slack. */ | |
| 62 | const mostSeconds = (drill: Drill): number => Math.ceil(drill.runTime) + 1; | |
| 63 | ||
| 64 | /** The fullest run a drill admits: every plate raised, hit, at its best, on the last second. */ | |
| 65 | const fullest = (drill: Drill): Record<string, unknown> => | |
| 66 | body(drill, { | |
| 67 | hits: mostPlates(drill), shots: mostShots(drill), score: mostScore(drill), | |
| 68 | seed: MAX_SEED, seconds: mostSeconds(drill), | |
| 69 | }); | |
| 70 | ||
| 71 | // --- the ceilings are the drill's | |
| 72 | ||
| 73 | test("each ceiling is the drill's own numbers, worked the way the module says", () => { | |
| 74 | for (const drill of EACH) { | |
| 75 | // The plates up at the start, one per gap of the clock, and the last tick's. | |
| 76 | expect(mostPlates(drill)).toBe(drill.atOnce + Math.floor(drill.runTime / drill.gap) + 1); | |
| 77 | // The farther end of the lane at the fastest slide. | |
| 78 | expect(mostPerPlate(drill)).toBe( | |
| 79 | Math.max(drill.points.near, drill.points.far) + drill.points.perSpeed * drill.speed.max, | |
| 80 | ); | |
| 81 | expect(mostScore(drill)).toBe(Math.ceil(mostPlates(drill) * mostPerPlate(drill))); | |
| 82 | expect(mostShots(drill)).toBe(Math.ceil(drill.runTime * SHOTS_A_SECOND)); | |
| 83 | } | |
| 84 | }); | |
| 85 | ||
| 86 | // --- the run a real thirty seconds produces is taken | |
| 87 | ||
| 88 | test("a perfect run of every drill, played through the range's own rules, is taken whole", () => { | |
| 89 | for (const drill of EACH) { | |
| 90 | for (const seed of [1, 7, 0xdead_beef, MAX_SEED]) { | |
| 91 | const run = perfect(drill, seed); | |
| 92 | expect(run.hits).toBeGreaterThan(0); | |
| 93 | expect(run.hits).toBeLessThanOrEqual(mostPlates(drill)); | |
| 94 | expect(parseRun(run)).toEqual(run); | |
| 95 | } | |
| 96 | } | |
| 97 | }); | |
| 98 | ||
| 99 | test("the fullest run each drill admits is taken, and a run of nothing is too", () => { | |
| 100 | for (const drill of EACH) { | |
| 101 | expect(parseRun(fullest(drill))).not.toBeNull(); | |
| 102 | expect(parseRun(body(drill))).toEqual({ | |
| 103 | runId: RUN_ID, name: "tester", drill: drill.name, score: 0, hits: 0, shots: 0, seed: 1, seconds: drill.runTime, | |
| 104 | }); | |
| 105 | } | |
| 106 | }); | |
| 107 | ||
| 108 | // --- and one past each ceiling is refused | |
| 109 | ||
| 110 | test("one hit more than the drill can raise plates for is refused", () => { | |
| 111 | for (const drill of EACH) { | |
| 112 | const most = mostPlates(drill); | |
| 113 | expect(parseRun(body(drill, { hits: most, shots: most }))).not.toBeNull(); | |
| 114 | expect(parseRun(body(drill, { hits: most + 1, shots: most + 1 }))).toBeNull(); | |
| 115 | } | |
| 116 | }); | |
| 117 | ||
| 118 | test("one shot fewer than the hits is refused: every hit was a shot", () => { | |
| 119 | for (const drill of EACH) { | |
| 120 | expect(parseRun(body(drill, { hits: 5, shots: 5 }))).not.toBeNull(); | |
| 121 | expect(parseRun(body(drill, { hits: 5, shots: 4 }))).toBeNull(); | |
| 122 | expect(parseRun(body(drill, { hits: 1, shots: 0 }))).toBeNull(); | |
| 123 | } | |
| 124 | }); | |
| 125 | ||
| 126 | test("one shot more than a trigger can be pulled in the clock is refused", () => { | |
| 127 | for (const drill of EACH) { | |
| 128 | expect(parseRun(body(drill, { shots: mostShots(drill) }))).not.toBeNull(); | |
| 129 | expect(parseRun(body(drill, { shots: mostShots(drill) + 1 }))).toBeNull(); | |
| 130 | } | |
| 131 | }); | |
| 132 | ||
| 133 | test("a score the drill cannot reach is refused, overall and for the hits it claims", () => { | |
| 134 | for (const drill of EACH) { | |
| 135 | const most = mostPlates(drill); | |
| 136 | expect(parseRun(body(drill, { hits: most, shots: most, score: mostScore(drill) + 1 }))).toBeNull(); | |
| 137 | // Three hits pay at most three plates' worth, whatever the ceiling overall. | |
| 138 | const three = Math.ceil(3 * mostPerPlate(drill)); | |
| 139 | expect(parseRun(body(drill, { hits: 3, shots: 3, score: three }))).not.toBeNull(); | |
| 140 | expect(parseRun(body(drill, { hits: 3, shots: 3, score: three + 1 }))).toBeNull(); | |
| 141 | // No hits is no score. | |
| 142 | expect(parseRun(body(drill, { score: 1 }))).toBeNull(); | |
| 143 | } | |
| 144 | }); | |
| 145 | ||
| 146 | test("a clock that is negative, or longer than the drill lasts, is refused", () => { | |
| 147 | for (const drill of EACH) { | |
| 148 | expect(parseRun(body(drill, { seconds: 0 }))).not.toBeNull(); | |
| 149 | expect(parseRun(body(drill, { seconds: mostSeconds(drill) }))).not.toBeNull(); | |
| 150 | expect(parseRun(body(drill, { seconds: mostSeconds(drill) + 1 }))).toBeNull(); | |
| 151 | expect(parseRun(body(drill, { seconds: -1 }))).toBeNull(); | |
| 152 | expect(parseRun(body(drill, { seconds: -0 }))).toBeNull(); | |
| 153 | } | |
| 154 | }); | |
| 155 | ||
| 156 | test("a seed that is not a 32-bit whole number is not a seed", () => { | |
| 157 | const drill = DRILLS.popup; | |
| 158 | expect(parseRun(body(drill, { seed: 0 }))?.seed).toBe(0); | |
| 159 | expect(parseRun(body(drill, { seed: MAX_SEED }))?.seed).toBe(MAX_SEED); | |
| 160 | expect(MAX_SEED).toBe(2 ** 32 - 1); | |
| 161 | for (const seed of [MAX_SEED + 1, -1, 2 ** 53, "7", null, undefined, true, Number.NaN, Infinity]) { | |
| 162 | expect(parseRun(body(drill, { seed }))).toBeNull(); | |
| 163 | } | |
| 164 | }); | |
| 165 | ||
| 166 | test("every counted field refuses what is not a count", () => { | |
| 167 | for (const field of ["score", "hits", "shots", "seed", "seconds"]) { | |
| 168 | for (const value of ["1", null, undefined, -1, Number.NaN, Infinity, [1], { n: 1 }]) { | |
| 169 | expect(parseRun(body(DRILLS.popup, { [field]: value }))).toBeNull(); | |
| 170 | } | |
| 171 | } | |
| 172 | }); | |
| 173 | ||
| 174 | // --- the drill, the id, and the name | |
| 175 | ||
| 176 | test("a drill is one of the table's names and nothing else", () => { | |
| 177 | for (const name of DRILL_NAMES) expect(drillNamed(name)).toBe(DRILLS[name]); | |
| 178 | for (const name of ["", "Popup", "sniper", "__proto__", "toString", "constructor", "hasOwnProperty", 1, null]) { | |
| 179 | expect(drillNamed(name)).toBeNull(); | |
| 180 | expect(parseRun(body(DRILLS.popup, { drill: name }))).toBeNull(); | |
| 181 | } | |
| 182 | }); | |
| 183 | ||
| 184 | test("a ceiling is the drill's that was named, not the most generous drill's", () => { | |
| 185 | // Whichever drill raises the fewer plates is refused a hit count the other allows. | |
| 186 | const [low, high] = [...EACH].sort((a, b) => mostPlates(a) - mostPlates(b)) as [Drill, Drill]; | |
| 187 | if (mostPlates(low) === mostPlates(high)) return; | |
| 188 | const hits = mostPlates(high); | |
| 189 | expect(parseRun(body(high, { hits, shots: hits }))).not.toBeNull(); | |
| 190 | expect(parseRun(body(low, { hits, shots: hits }))).toBeNull(); | |
| 191 | }); | |
| 192 | ||
| 193 | test("a run id is the app's id shape, and a run that is not an object is no run", () => { | |
| 194 | expect(parseRun(body(DRILLS.popup, { runId: "abc" }))).toBeNull(); | |
| 195 | expect(parseRun(body(DRILLS.popup, { runId: "g".repeat(16) }))).toBeNull(); | |
| 196 | expect(parseRun(body(DRILLS.popup, { runId: 12345678 }))).toBeNull(); | |
| 197 | for (const value of [null, undefined, "run", 3, true, []]) expect(parseRun(value)).toBeNull(); | |
| 198 | }); | |
| 199 | ||
| 200 | test("the name is cleaned at the door, and nothing the body invented comes through", () => { | |
| 201 | const drill: DrillName = "popup"; | |
| 202 | const parsed = parseRun({ ...body(DRILLS[drill], { name: "\u0000 vesper\n" }), accuracy: 1, admin: true }); | |
| 203 | expect(parsed?.name).toBe("vesper"); | |
| 204 | expect(Object.keys(parsed ?? {}).sort()).toEqual( | |
| 205 | ["runId", "name", "drill", "score", "hits", "shots", "seed", "seconds"].sort(), | |
| 206 | ); | |
| 207 | expect(parseRun(body(DRILLS[drill], { name: 7 }))?.name).toBe("anon"); | |
| 208 | }); |
src/server.ts +5 −59
| @@ -53,8 +53,8 @@ import { buildBundles, bundlesDir, buildSummary } from "./bundles.ts"; | ||
| 53 | 53 | import { kB } from "./bytes.ts"; |
| 54 | 54 | import { acceptsGzip, type Body, weigh, weighText } from "./compress.ts"; |
| 55 | 55 | import { measureExample } from "./cost.ts"; |
| 56 | import { openDatabase, type Player, saveMatch, saveRun } from "./db.ts"; | |
| 57 | import { type Gate } from "./gate.ts"; | |
| 56 | import { openDatabase, saveMatch, saveRun } from "./db.ts"; | |
| 57 | import { admit, type Gate, playerOf, readBody, refused } from "./gate.ts"; | |
| 58 | 58 | import { GAMES } from "./games.ts"; |
| 59 | 59 | import { createLimiter } from "./limit.ts"; |
| 60 | 60 | import { examplePage } from "./pages/example.ts"; |
| @@ -73,7 +73,7 @@ import { roomCodeFrom } from "./engine/index.ts"; | ||
| 73 | 73 | import { createHub, CLOSE_GONE, MESSAGE_MAX, ROOM_CAP } from "./rooms/hub.ts"; |
| 74 | 74 | import { playedOn } from "./mapname.ts"; |
| 75 | 75 | import { styleFor, type Linked } from "./pages/shell.ts"; |
| 76 | import { cleanName, parseResult } from "./result.ts"; | |
| 76 | import { parseResult } from "./result.ts"; | |
| 77 | 77 | import { parseRun } from "./run.ts"; |
| 78 | 78 | import { who, type Who } from "./who.ts"; |
| 79 | 79 | |
| @@ -176,44 +176,8 @@ const sameOrigin = (request: Request): boolean => { | ||
| 176 | 176 | return origin === null || origin === publicOrigin; |
| 177 | 177 | }; |
| 178 | 178 | |
| 179 | // A request body is read up to a cap and no further, counting the bytes as | |
| 180 | // they arrive. A declared Content-Length is a claim: a chunked request has | |
| 181 | // none, and a lie is free. Undefined means "too large" — refuse it. Past the | |
| 182 | // cap the rest is drained and dropped so the connection stays usable for | |
| 183 | // the next request; past eight times the cap it is cut off instead. | |
| 184 | const BODY_MAX = 16 * 1024; | |
| 185 | 179 | /** A situation is a map name and five numbers; a kilobyte is already generous. */ |
| 186 | 180 | const PLAN_BODY_MAX = 1024; |
| 187 | async function readBody(request: Request, max = BODY_MAX): Promise<Uint8Array | undefined> { | |
| 188 | const reader = request.body?.getReader(); | |
| 189 | if (reader === undefined) return new Uint8Array(); | |
| 190 | const parts: Uint8Array[] = []; | |
| 191 | let size = 0; | |
| 192 | for (;;) { | |
| 193 | const { done, value } = await reader.read(); | |
| 194 | if (done) break; | |
| 195 | size += value.byteLength; | |
| 196 | if (size > max) { | |
| 197 | for (;;) { | |
| 198 | const next = await reader.read(); | |
| 199 | if (next.done) return undefined; | |
| 200 | size += next.value.byteLength; | |
| 201 | if (size > max * 8) { | |
| 202 | await reader.cancel(); | |
| 203 | return undefined; | |
| 204 | } | |
| 205 | } | |
| 206 | } | |
| 207 | parts.push(value); | |
| 208 | } | |
| 209 | const body = new Uint8Array(size); | |
| 210 | let at = 0; | |
| 211 | for (const part of parts) { | |
| 212 | body.set(part, at); | |
| 213 | at += part.byteLength; | |
| 214 | } | |
| 215 | return body; | |
| 216 | } | |
| 217 | 181 | |
| 218 | 182 | // A session is only ever a name for "the browser that started this match", so |
| 219 | 183 | // one browser cannot overwrite another's result. It carries nothing else. |
| @@ -334,16 +298,6 @@ type Joined = { id: string; code: string; name: string }; | ||
| 334 | 298 | const roomClock = setInterval(() => hub.advance(Date.now()), 10); |
| 335 | 299 | roomClock.unref(); |
| 336 | 300 | |
| 337 | /** | |
| 338 | * Who a result is recorded as. A verified forge identity plays under its own | |
| 339 | * name and the browser's claim is ignored; everyone else is a guest, whose | |
| 340 | * name is theirs to pick and is shown as a guest's wherever it appears. So | |
| 341 | * nobody who is not signed in can ever land in a signed-in player's row. | |
| 342 | */ | |
| 343 | const playerOf = (visitor: Who, session: string, claimed: string): Player => | |
| 344 | visitor.role === "anon" | |
| 345 | ? { userId: "", session, name: cleanName(claimed) } | |
| 346 | : { userId: visitor.id, session, name: cleanName(visitor.name) }; | |
| 347 | 301 | |
| 348 | 302 | /** |
| 349 | 303 | * The build, before anything is resolved and long before anything is served: |
| @@ -817,16 +771,8 @@ async function route(request: Request, listener: Bun.Server<Joined>): Promise<Re | ||
| 817 | 771 | const held = readSession(request); |
| 818 | 772 | if (!mayWrite(visitor, held, listener.requestIP(request)?.address)) |
| 819 | 773 | return text("slow down", 429, { "retry-after": String(Math.ceil(MATCH_LIMIT.per / 1000)) }); |
| 820 | const body = await readBody(request); | |
| 821 | if (body === undefined) return text("too large", 413); | |
| 822 | let parsed: unknown; | |
| 823 | try { | |
| 824 | parsed = JSON.parse(new TextDecoder().decode(body)); | |
| 825 | } catch { | |
| 826 | return text("bad json", 400); | |
| 827 | } | |
| 828 | const checked = gate.accept(parsed); | |
| 829 | if (checked === null) return text(gate.refusal, 422); | |
| 774 | const checked = await admit(gate, request); | |
| 775 | if (refused(checked)) return text(checked.reason, checked.status); | |
| 830 | 776 | const session = held ?? newSession(); |
| 831 | 777 | const stored = checked.write(db, playerOf(visitor, session, checked.name)); |
| 832 | 778 | if (!stored) return text("not yours", 409); |
new above
offline