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

A seat in the sea: decide what one is, before `join` invents it #57

Merged opened this

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

Merged

vesper/drift:vesper/49-a-seat-in-the-sea-decide-what-one-is-bef → main 1 commit fork

  • Merged. vesper/49-a-seat-in-the-sea-decide-what-one-is-bef was merged into main as a5a0fe8.

Review

Sign in to review this change.
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
5656 yet. `src/world.ts` advances one tick — motes, momentum, the current field,
5757 the rim and the gate — with no clock, no database and no network, and the
5858 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
6161 calls `step` twenty times a second and the socket that carries the frames are
6262 the issues after this one.
6363
@@ -66,10 +66,14 @@ the issues after this one.
6666 The only terrain is the wake other players leave. Your thruster is feeble;
6767 the currents other swimmers deposit are not, and the bloom only reaches its
6868 gate when several people braid one lane. [`docs/wake.md`](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
7070 invariants every build issue is measured against. It is not decoration: the
7171 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.
7377
7478 ## The shape
7579
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
4949 ever taken out of the sea, and one arrival is one scatter — while you are stunned the rim only
5050 beaches you, so a lane somebody else keeps building cannot hold you in the dark.
5151
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
52116 ## Why this one, and not the other two
53117
54118 Three concepts were opened against the direction. Wake was chosen.
@@ -71,6 +135,9 @@ only the first guess at holding them.
71135 | number | value | owned by |
72136 | -------------- | -------------- | ------------------------------------- |
73137 | 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` |
74141 | round | 180 s | `src/concept.ts` `ROUND_SECONDS` |
75142 | intermission | 20 s | `src/concept.ts` `INTERMISSION_SECONDS` |
76143 | arena | 1000 units across | `src/world.ts` |
@@ -84,18 +151,20 @@ only the first guess at holding them.
84151 | gate | a 60° arc on the rim, moved each round | `src/world.ts` |
85152 | rim | a fast arrival costs 3 s of thrust and a cell of ground | `src/world.ts` |
86153
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.
88156
89157 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.
93162
94163 ## Invariants
95164
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
97166 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.
99168
100169 1. A lone mote at full thrust cannot cross the arena inside 45 s of ticks.
101170 2. A mote dropped into a lane two other motes built crosses it in under 10 s.
@@ -107,12 +176,17 @@ proves.
107176 comes away slower than it arrived, loses 3 s of thrust and a cell of ground against the same
108177 mote in the same lane that arrived under it, and steers again the moment those seconds are up —
109178 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.
110182
111183 Where each is proven: 1–4 and 6 in `src/world.test.ts` against the pure simulation (#5), and 4
112184 again in `src/round.test.ts` once a real round runs it (#8). 5 is a property of `step` alone and is
113185 also the seam #18 must not break when it seeds a round from a stored field. 6 is measured as a
114186 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).
116190
117191 ### Two clarifications the build turned up
118192
@@ -130,6 +204,7 @@ under, so a build where the rim is free — or where it pays, as the first one d
130204 ## What this record does not decide
131205
132206 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
135210 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
4545 );
4646 // If this ever finds nothing, the table changed shape and the check below
4747 // would pass by proving nothing at all.
48 expect(owned.length).toBe(3);
48 expect(owned.length).toBe(6);
4949 for (const [name, value, owner] of owned) {
5050 const exportName = (owner ?? "").match(/`([A-Z_]+)`/)?.[1];
5151 expect(exportName, `no export named in "${owner}"`).toBeDefined();
@@ -74,7 +74,7 @@ test("the record's numbers name a file that exists", async () => {
7474 }
7575 });
7676
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", () => {
7878 const list = section("## Invariants")
7979 .split("\n\n")
8080 .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",
8383 .split(/\n(?=\d+\. )/)
8484 .filter((entry) => /^\d+\. /.test(entry.trim()))
8585 .map((entry) => tidy(entry.trim().replace(/^\d+\.\s*/, "")));
86 expect(listed.length).toBe(6);
86 expect(listed.length).toBe(7);
8787 expect(listed).toEqual(concept.INVARIANTS.map(tidy));
8888 // 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.
9090 const opens = [
9191 "A lone mote",
9292 "A mote dropped",
9393 "One mote thrusting",
9494 "Three motes braiding",
9595 "With no input",
96 "The rim costs",
9697 ];
9798 opens.forEach((open, i) => expect(concept.INVARIANTS[i]).toStartWith(open));
9899 });
99100
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
100141 test("the page says the round's shape in the record's numbers", () => {
101142 // The sentence is written in minutes, so the constant has to be whole ones.
102143 expect(concept.ROUND_SECONDS % 60).toBe(0);
src/concept.ts +32 −4
@@ -8,9 +8,9 @@
88 * Physics tuning is not here. Thrust, field half-life, spread, drag, the
99 * arena and the gate are the world's, in one block at the top of
1010 * `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.
1414 */
1515
1616 /** The one sentence a newcomer has to read: the whole game is other people. */
@@ -20,6 +20,33 @@ export const RULE =
2020 /** Swimmers in one round. An arrival over the cap watches as a spectator. */
2121 export const ROSTER_MAX = 24;
2222
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
2350 /** How long a round runs. A whole number of minutes: the page says it in minutes. */
2451 export const ROUND_SECONDS = 180;
2552
@@ -27,7 +54,7 @@ export const ROUND_SECONDS = 180;
2754 export const INTERMISSION_SECONDS = 20;
2855
2956 /**
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
3158 * `docs/wake.md` numbers them: `INVARIANTS[0]` is invariant 1. A test that
3259 * proves one names it, so a failure says which promise broke. The list only
3360 * ever grows at the end: tests cite these by index, so a new invariant is
@@ -40,4 +67,5 @@ export const INVARIANTS = [
4067 "Three motes braiding one lane put the bloom through the gate inside a round.",
4168 "With no input, total field magnitude strictly decreases every tick and reaches zero: no energy from nowhere.",
4269 "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.",
4371 ] as const;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs