Public: anyone can read this; the crew can change it.
A seat in the sea: decide what one is, before `join` invents it #57
Check · Review · Land · Live done — Landed and live in production.
vesper/drift:vesper/49-a-seat-in-the-sea-decide-what-one-is-bef → main 1 commit fork
4 files
changed +169
−21
README.md +8 −4
| @@ -56,8 +56,8 @@ The sea, on the other hand, is real arithmetic that nothing is plugged into | ||
| 56 | 56 | yet. `src/world.ts` advances one tick — motes, momentum, the current field, |
| 57 | 57 | the rim and the gate — with no clock, no database and no network, and the |
| 58 | 58 | same world always yields the same next one because every source of chance is |
| 59 | the seed in the state. `bun test` drives it by hand and proves all six | |
| 60 | invariants of the record against it, each named by its number. The loop that | |
| 59 | the seed in the state. `bun test` drives it by hand and proves every | |
| 60 | invariant the record states about the sea, each named by its number. The loop that | |
| 61 | 61 | calls `step` twenty times a second and the socket that carries the frames are |
| 62 | 62 | the issues after this one. |
| 63 | 63 | |
| @@ -66,10 +66,14 @@ the issues after this one. | ||
| 66 | 66 | The only terrain is the wake other players leave. Your thruster is feeble; |
| 67 | 67 | the currents other swimmers deposit are not, and the bloom only reaches its |
| 68 | 68 | gate when several people braid one lane. [](docs/wake.md) is |
| 69 | the design record — the mechanic, the starting numbers and the six | |
| 69 | the design record — the mechanic, the starting numbers and the seven | |
| 70 | 70 | invariants every build issue is measured against. It is not decoration: the |
| 71 | 71 | numbers the page states live in `src/concept.ts` and `bun test` fails if |
| 72 | they and the record drift apart. | |
| 72 | they and the record drift apart. A seat in the sea belongs to a player key | |
| 73 | (the forge id, or the guest id behind the cookie) and one key holds one | |
| 74 | seat: a second tab steers the same mote, a client presenting no session | |
| 75 | watches, and a seat nobody steers is let go — the record's **A seat** | |
| 76 | section says why an address can never be the rule here. | |
| 73 | 77 | |
| 74 | 78 | ## The shape |
| 75 | 79 |
docs/wake.md +84 −9
| @@ -49,6 +49,70 @@ in the same lane that came in under the speed. What it is not is the end of your | ||
| 49 | 49 | ever taken out of the sea, and one arrival is one scatter — while you are stunned the rim only |
| 50 | 50 | beaches you, so a lane somebody else keeps building cannot hold you in the dark. |
| 51 | 51 | |
| 52 | ## A seat | |
| 53 | ||
| 54 | A seat is a mote in the sea, and it belongs to a **player key**: the forge's id for someone signed | |
| 55 | in, the guest id for everyone else — `playerKey` in `src/session.ts` (#6), a one-way hash of the key | |
| 56 | in an HttpOnly cookie and never the cookie itself. It is not an address. Behind the forge every | |
| 57 | guest arrives from the forge's address, so an address is a ceiling on this process and never a | |
| 58 | person; `src/live.ts` says the same about streams, and that is why the rule here has to be something | |
| 59 | else. It is not a connection, not a socket, and never a name a client sends. | |
| 60 | ||
| 61 | One key holds one seat — `SEATS_PER_PLAYER` is 1 — and that is the whole admission rule. | |
| 62 | ||
| 63 | - **A second connection under a key steers the key's own mote.** Two tabs are not two swimmers and | |
| 64 | are not an error: they are two windows on one mote, and steering from either reaches it. Input is | |
| 65 | applied once per tick in arrival order (#8), so inside a tick the last message to arrive is the | |
| 66 | one that steers — two tabs fighting is a person arguing with themselves, not an extra swimmer. | |
| 67 | How many connections one key may hold open is rationed where connections live (`STREAM_MAX` in | |
| 68 | `src/live.ts`, the per-socket ration in #9), not here. | |
| 69 | - **A request carrying no session gets no mote.** `join` reads the key the request already carried | |
| 70 | and never mints one; a new key is minted by `session` on a *response*, so a browser has to come | |
| 71 | back holding it. That is one round trip, well inside the five seconds the direction asks for, and | |
| 72 | a client that keeps no cookie jar is a stranger on every request for as long as it keeps none. | |
| 73 | - **Signing in is a change of key, so it is a change of seat.** The guest's seat is released in the | |
| 74 | same tick the forge id takes one — nobody holds two — and the new seat inherits the old mote's | |
| 75 | place and momentum, so the only thing that moves is the name over it. | |
| 76 | - **A seat is held by swimming.** A seat whose key has sent no steering for `IDLE_SECONDS` (60) is | |
| 77 | released and its mote leaves the sea. A seat that has never been steered at all is released after | |
| 78 | `UNSTEERED_SECONDS` (20) — long enough to read the one sentence this game has, short enough that a | |
| 79 | wave of seats nobody is steering clears inside the first minute of the round it took them from. | |
| 80 | Rejoining costs one request either way; the key, the handle and the history are the same ones. | |
| 81 | ||
| 82 | Everything else about a seat is overflow, and overflow has one answer for all of it: an arrival who | |
| 83 | cannot have a mote — the 25th swimmer over `ROSTER_MAX`, the client presenting no session, anyone | |
| 84 | who asks while the sea is full — is a **spectator** who sees the whole round and steers nothing. | |
| 85 | Nobody is turned away, and nothing is refused silently; a spectator is told they are one, in the | |
| 86 | sentence the page hands them: | |
| 87 | ||
| 88 | > You are watching this round, not swimming it: every mote, every current and the bloom are here, | |
| 89 | > and nothing you do moves them. A seat opens when the round ends or a swimmer drifts off — if your | |
| 90 | > browser keeps the cookie we set, the next one is yours. | |
| 91 | ||
| 92 | **What the cookie is, and is not.** The guest key buys two things and no third: a seat, and a handle | |
| 93 | that reads the same twice. It is a bearer secret with no password behind it, so nothing else may | |
| 94 | ever be hung on it — not a place on the ladder, which shows forge names only, not a role, not | |
| 95 | ownership of anything. `src/who.ts` stays the only word on who a person is. | |
| 96 | ||
| 97 | ### Three attacks | |
| 98 | ||
| 99 | - **A script with no cookie jar takes every seat.** It cannot take one. `join` believes only a key | |
| 100 | the request carried, so a client that throws the cookie away is a stranger every time and a | |
| 101 | stranger is a spectator. Holding twenty-four seats means keeping twenty-four jars *and* steering | |
| 102 | all of them, because a seat never steered goes at 20 s and a seat that stops being steered goes at | |
| 103 | 60 s. This raises the price from nothing to bookkeeping rather than to impossible: guest keys are | |
| 104 | free to mint, and a script that really does keep twenty-four sessions and swims all of them is | |
| 105 | indistinguishable from twenty-four people playing badly — it deposits real momentum into the field | |
| 106 | and is answered by what a round pays for (#38), not by the door. | |
| 107 | - **One person with six tabs.** Six tabs is what a person does when a round stalls, so it costs them | |
| 108 | nothing and gains them nothing: all six are the same key, the same seat and the same mote, and the | |
| 109 | sixth tab is refused no more than the first. The roster shows one swimmer, because there is one. | |
| 110 | - **A seat held by someone who joined and walked away.** The idle eviction takes it: no steering for | |
| 111 | `IDLE_SECONDS` = 60 s and the seat is released, which is the number #8 already builds to. Sixty | |
| 112 | seconds is a third of a round — long enough to survive a phone locking, a reconnect or a stretch | |
| 113 | of honest drifting, short enough that a walked-away mote is not still holding a seat when the | |
| 114 | round it joined ends. | |
| 115 | ||
| 52 | 116 | ## Why this one, and not the other two |
| 53 | 117 | |
| 54 | 118 | Three concepts were opened against the direction. Wake was chosen. |
| @@ -71,6 +135,9 @@ only the first guess at holding them. | ||
| 71 | 135 | | number | value | owned by | |
| 72 | 136 | | -------------- | -------------- | ------------------------------------- | |
| 73 | 137 | | roster cap | 24 | `src/concept.ts` `ROSTER_MAX` | |
| 138 | | seats per key | 1 | `src/concept.ts` `SEATS_PER_PLAYER` | | |
| 139 | | idle eviction | 60 s | `src/concept.ts` `IDLE_SECONDS` | | |
| 140 | | unsteered seat | 20 s | `src/concept.ts` `UNSTEERED_SECONDS` | | |
| 74 | 141 | | round | 180 s | `src/concept.ts` `ROUND_SECONDS` | |
| 75 | 142 | | intermission | 20 s | `src/concept.ts` `INTERMISSION_SECONDS` | |
| 76 | 143 | | arena | 1000 units across | `src/world.ts` | |
| @@ -84,18 +151,20 @@ only the first guess at holding them. | ||
| 84 | 151 | | gate | a 60° arc on the rim, moved each round | `src/world.ts` | |
| 85 | 152 | | rim | a fast arrival costs 3 s of thrust and a cell of ground | `src/world.ts` | |
| 86 | 153 | |
| 87 | Over the cap, an arrival watches as a spectator rather than being turned away. | |
| 154 | Over the cap — or under no session at all — an arrival watches as a spectator rather than being | |
| 155 | turned away; **A seat** above says who a seat belongs to and how it is let go. | |
| 88 | 156 | |
| 89 | 157 | The split is deliberate. Physics tuning lives in one block at the top of `src/world.ts` (#5) and |
| 90 | moves whenever play says it should — this file names the *target*, not the constant. The three | |
| 91 | numbers this file owns, and the sentence quoted above, are what a person reads on the page, so | |
| 92 | they live in `src/concept.ts` and the check holds them to this file. | |
| 158 | moves whenever play says it should — this file names the *target*, not the constant. The six | |
| 159 | numbers this file owns, and the two sentences quoted above — the rule, and what a spectator reads — | |
| 160 | are what a person meets on the page, so they live in `src/concept.ts` and the check holds them to | |
| 161 | this file. | |
| 93 | 162 | |
| 94 | 163 | ## Invariants |
| 95 | 164 | |
| 96 | Every build issue is measured against these six. They are numbered, and issues cite them by | |
| 165 | Every build issue is measured against these seven. They are numbered, and issues cite them by | |
| 97 | 166 | number; `INVARIANTS` in `src/concept.ts` is the same list as data, so a test can name the one it |
| 98 | proves. | |
| 167 | proves. The list only ever grows at the end. | |
| 99 | 168 | |
| 100 | 169 | 1. A lone mote at full thrust cannot cross the arena inside 45 s of ticks. |
| 101 | 170 | 2. A mote dropped into a lane two other motes built crosses it in under 10 s. |
| @@ -107,12 +176,17 @@ proves. | ||
| 107 | 176 | comes away slower than it arrived, loses 3 s of thrust and a cell of ground against the same |
| 108 | 177 | mote in the same lane that arrived under it, and steers again the moment those seconds are up — |
| 109 | 178 | one arrival is one scatter, however hard the lane holds it there. |
| 179 | 7. One key, one seat: the number of live motes never exceeds the number of distinct player keys in | |
| 180 | the roster, whatever a client opens — a second connection under a key steers that key's own mote, | |
| 181 | and a request carrying no session key gets no mote at all. | |
| 110 | 182 | |
| 111 | 183 | Where each is proven: 1–4 and 6 in `src/world.test.ts` against the pure simulation (#5), and 4 |
| 112 | 184 | again in `src/round.test.ts` once a real round runs it (#8). 5 is a property of `step` alone and is |
| 113 | 185 | also the seam #18 must not break when it seeds a round from a stored field. 6 is measured as a |
| 114 | 186 | difference rather than an absolute: the same lane twice, one arrival over the scatter speed and one |
| 115 | under, so a build where the rim is free — or where it pays, as the first one did — fails it. | |
| 187 | under, so a build where the rim is free — or where it pays, as the first one did — fails it. 7 is | |
| 188 | `join`'s to prove in `src/round.test.ts` (#8) — the same key joining twice is one mote, a join with | |
| 189 | no key is none — and the upgrade's to hold in `src/socket.test.ts` (#9). | |
| 116 | 190 | |
| 117 | 191 | ### Two clarifications the build turned up |
| 118 | 192 | |
| @@ -130,6 +204,7 @@ under, so a build where the rim is free — or where it pays, as the first one d | ||
| 130 | 204 | ## What this record does not decide |
| 131 | 205 | |
| 132 | 206 | The look of the sea, the wire format, the scoring, the words on the page and the shape of the |
| 133 | socket are all the build issues'. This file decides the mechanic, the six invariants and the four | |
| 134 | numbers in the table it owns. It stays open while the map is built against it and closes when the | |
| 207 | socket are all the build issues'. This file decides the mechanic, the seven invariants and the | |
| 208 | numbers in the table it owns. It says what a seat is; it does not write `join` (#8) or the upgrade | |
| 209 | (#9), and how a spectator's page differs from a swimmer's is #10's and #11's. It stays open while the map is built against it and closes when the | |
| 135 | 210 | vertical slice is live (#10, #11) and these numbers have been tuned against real play. |
src/concept.test.ts +45 −4
| @@ -45,7 +45,7 @@ test("every number the record hands this module is the number the module exports | ||
| 45 | 45 | ); |
| 46 | 46 | // If this ever finds nothing, the table changed shape and the check below |
| 47 | 47 | // would pass by proving nothing at all. |
| 48 | expect(owned.length).toBe(3); | |
| 48 | expect(owned.length).toBe(6); | |
| 49 | 49 | for (const [name, value, owner] of owned) { |
| 50 | 50 | const exportName = (owner ?? "").match(/`([A-Z_]+)`/)?.[1]; |
| 51 | 51 | expect(exportName, `no export named in "${owner}"`).toBeDefined(); |
| @@ -74,7 +74,7 @@ test("the record's numbers name a file that exists", async () => { | ||
| 74 | 74 | } |
| 75 | 75 | }); |
| 76 | 76 | |
| 77 | test("the six invariants read the same in the record and in the code, in order", () => { | |
| 77 | test("the invariants read the same in the record and in the code, in order", () => { | |
| 78 | 78 | const list = section("## Invariants") |
| 79 | 79 | .split("\n\n") |
| 80 | 80 | .find((block) => block.startsWith("1. ")); |
| @@ -83,20 +83,61 @@ test("the six invariants read the same in the record and in the code, in order", | ||
| 83 | 83 | .split(/\n(?=\d+\. )/) |
| 84 | 84 | .filter((entry) => /^\d+\. /.test(entry.trim())) |
| 85 | 85 | .map((entry) => tidy(entry.trim().replace(/^\d+\.\s*/, ""))); |
| 86 | expect(listed.length).toBe(6); | |
| 86 | expect(listed.length).toBe(7); | |
| 87 | 87 | expect(listed).toEqual(concept.INVARIANTS.map(tidy)); |
| 88 | 88 | // The list only grows at the end: an index cited by a test today has to |
| 89 | // mean the same promise tomorrow, so 1–5 are pinned by their opening words. | |
| 89 | // mean the same promise tomorrow, so 1–6 are pinned by their opening words. | |
| 90 | 90 | const opens = [ |
| 91 | 91 | "A lone mote", |
| 92 | 92 | "A mote dropped", |
| 93 | 93 | "One mote thrusting", |
| 94 | 94 | "Three motes braiding", |
| 95 | 95 | "With no input", |
| 96 | "The rim costs", | |
| 96 | 97 | ]; |
| 97 | 98 | opens.forEach((open, i) => expect(concept.INVARIANTS[i]).toStartWith(open)); |
| 98 | 99 | }); |
| 99 | 100 | |
| 101 | test("the record decides what a seat is before #ROUND invents it", () => { | |
| 102 | const seat = tidy(section("## A seat")); | |
| 103 | // What identifies the holder: the player key, and explicitly not an address. | |
| 104 | expect(seat).toContain("playerKey"); | |
| 105 | expect(seat).toContain("src/session.ts"); | |
| 106 | expect(seat).toMatch(/not an address|never an address/); | |
| 107 | // The three attacks are answered here, not left to the pull that trips over | |
| 108 | // them: a cookieless script, one person's six tabs, and a walked-away seat. | |
| 109 | const attacks = tidy(section("### Three attacks")); | |
| 110 | expect(attacks).toContain("no cookie jar"); | |
| 111 | expect(attacks).toContain("six tabs"); | |
| 112 | expect(attacks).toContain(`IDLE_SECONDS = ${concept.IDLE_SECONDS}`); | |
| 113 | // The overflow answer is one answer, and the sentence it hands a spectator | |
| 114 | // is the module's, word for word. | |
| 115 | expect(seat).toContain("spectator"); | |
| 116 | expect(seat).toContain(tidy(concept.SPECTATOR)); | |
| 117 | }); | |
| 118 | ||
| 119 | test("the seat's seconds are ordered the way the record argues them", () => { | |
| 120 | // A seat never steered goes first, then an idle one, and both inside a | |
| 121 | // round: an eviction longer than the round it evicts from never fires. | |
| 122 | expect(concept.SEATS_PER_PLAYER).toBe(1); | |
| 123 | expect(concept.UNSTEERED_SECONDS).toBeLessThan(concept.IDLE_SECONDS); | |
| 124 | expect(concept.IDLE_SECONDS).toBeLessThan(concept.ROUND_SECONDS); | |
| 125 | // "long enough to read the one sentence this game has" — a slow reader at | |
| 126 | // two words a second, not a skimmer. | |
| 127 | expect(concept.UNSTEERED_SECONDS * 2).toBeGreaterThan( | |
| 128 | concept.RULE.split(/\s+/).length, | |
| 129 | ); | |
| 130 | }); | |
| 131 | ||
| 132 | test("invariant 7 is the seat rule, and it is last", () => { | |
| 133 | const seven = concept.INVARIANTS[6]; | |
| 134 | expect(seven).toBeDefined(); | |
| 135 | expect(concept.INVARIANTS.length).toBe(7); | |
| 136 | expect(seven).toStartWith("One key, one seat"); | |
| 137 | // Worded so #ROUND's test can measure it rather than agree with it. | |
| 138 | expect(seven).toContain("never exceeds the number of distinct player keys"); | |
| 139 | }); | |
| 140 | ||
| 100 | 141 | test("the page says the round's shape in the record's numbers", () => { |
| 101 | 142 | // The sentence is written in minutes, so the constant has to be whole ones. |
| 102 | 143 | expect(concept.ROUND_SECONDS % 60).toBe(0); |
src/concept.ts +32 −4
| @@ -8,9 +8,9 @@ | ||
| 8 | 8 | * Physics tuning is not here. Thrust, field half-life, spread, drag, the |
| 9 | 9 | * arena and the gate are the world's, in one block at the top of |
| 10 | 10 | * `src/world.ts` (#WORLD), and they move whenever play says they should. What |
| 11 | * lives here is the shape of a round and the words for it — the parts a | |
| 12 | * person reads — plus the six invariants, numbered, so a test can name the | |
| 13 | * one it proves. | |
| 11 | * lives here is the shape of a round, who may have a seat in it and the words | |
| 12 | * for both — the parts a person reads — plus the invariants, numbered, so a | |
| 13 | * test can name the one it proves. | |
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | 16 | /** The one sentence a newcomer has to read: the whole game is other people. */ |
| @@ -20,6 +20,33 @@ export const RULE = | ||
| 20 | 20 | /** Swimmers in one round. An arrival over the cap watches as a spectator. */ |
| 21 | 21 | export const ROSTER_MAX = 24; |
| 22 | 22 | |
| 23 | /** | |
| 24 | * Seats one player key may hold. The key is `playerKey` in `src/session.ts` — | |
| 25 | * the forge id, or the guest id derived from the cookie — never an address, | |
| 26 | * which behind the forge is this whole process rather than a person. One | |
| 27 | * means a second tab steers the same mote instead of taking another seat | |
| 28 | * (invariant 7); #8 enforces it in `join`, #9 at the upgrade. | |
| 29 | */ | |
| 30 | export const SEATS_PER_PLAYER = 1; | |
| 31 | ||
| 32 | /** A seat whose key has sent no steering for this long is released. */ | |
| 33 | export const IDLE_SECONDS = 60; | |
| 34 | ||
| 35 | /** | |
| 36 | * A seat that has never been steered at all is released sooner: long enough | |
| 37 | * to read the rule, short enough that a burst of seats nobody is steering | |
| 38 | * clears while the round it took them from is still young. | |
| 39 | */ | |
| 40 | export const UNSTEERED_SECONDS = 20; | |
| 41 | ||
| 42 | /** | |
| 43 | * What an arrival who cannot have a mote is told — over the cap, with no | |
| 44 | * session, or while the sea is full. The overflow answer is the same for all | |
| 45 | * three: you see the whole round and steer nothing, and you are told so. | |
| 46 | */ | |
| 47 | export const SPECTATOR = | |
| 48 | "You are watching this round, not swimming it: every mote, every current and the bloom are here, and nothing you do moves them. A seat opens when the round ends or a swimmer drifts off — if your browser keeps the cookie we set, the next one is yours."; | |
| 49 | ||
| 23 | 50 | /** How long a round runs. A whole number of minutes: the page says it in minutes. */ |
| 24 | 51 | export const ROUND_SECONDS = 180; |
| 25 | 52 | |
| @@ -27,7 +54,7 @@ export const ROUND_SECONDS = 180; | ||
| 27 | 54 | export const INTERMISSION_SECONDS = 20; |
| 28 | 55 | |
| 29 | 56 | /** |
| 30 | * The six things every build issue is measured against, in the order | |
| 57 | * The things every build issue is measured against, in the order | |
| 31 | 58 | * `docs/wake.md` numbers them: `INVARIANTS[0]` is invariant 1. A test that |
| 32 | 59 | * proves one names it, so a failure says which promise broke. The list only |
| 33 | 60 | * ever grows at the end: tests cite these by index, so a new invariant is |
| @@ -40,4 +67,5 @@ export const INVARIANTS = [ | ||
| 40 | 67 | "Three motes braiding one lane put the bloom through the gate inside a round.", |
| 41 | 68 | "With no input, total field magnitude strictly decreases every tick and reaches zero: no energy from nowhere.", |
| 42 | 69 | "The rim costs seconds and ground, never a round: a mote that arrives above the scatter speed comes away slower than it arrived, loses 3 s of thrust and a cell of ground against the same mote in the same lane that arrived under it, and steers again the moment those seconds are up — one arrival is one scatter, however hard the lane holds it there.", |
| 70 | "One key, one seat: the number of live motes never exceeds the number of distinct player keys in the roster, whatever a client opens — a second connection under a key steers that key's own mote, and a request carrying no session key gets no mote at all.", | |
| 43 | 71 | ] as const; |