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

Credit, decided before it is coded: what a round pays for, and to whom #47

Closed opened this
Closed without merging

vesper/drift:vesper/38-credit-decided-before-it-is-coded-what-a → main 2 commits fork 1 thread · 1 unresolved

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
6 files changed +496 −37
AGENTS.md +1 −1
@@ -41,7 +41,7 @@ needs a person checks `who.role`; ownership is a column (`author_id`) and
4141 the check that reads it, in the action.
4242
4343 The game itself is written down: `docs/wake.md` is the design record — the
44 mechanic, the starting numbers and the six invariants a build is measured
44 mechanic, the starting numbers and the numbered invariants a build is measured
4545 against — and `src/concept.ts` is the part of it the app says out loud. Take
4646 a number from there rather than spelling it again, cite an invariant by its
4747 number, and when play says a number should move, move it in both. Physics
README.md +10 −8
@@ -59,20 +59,22 @@ The sea, on the other hand, is real arithmetic that nothing is plugged into
5959 yet. `src/world.ts` advances one tick — motes, momentum, the current field,
6060 the rim and the gate — with no clock, no database and no network, and the
6161 same world always yields the same next one because every source of chance is
62 the seed in the state. `bun test` drives it by hand and proves every
63 invariant the record states about the sea, each named by its number. The loop that
64 calls `step` twenty times a second and the socket that carries the frames are
65 the issues after this one.
62 the seed in the state. `bun test` drives it by hand and proves every invariant
63 the record states about the sea, each named by its number — including as much
64 of the credit invariant as a world with no ledger in it can hold: that the
65 gate-ward pull the sea applies, summed over a round, is the distance the bloom
66 really closed. The loop that calls `step` twenty times a second and the socket
67 that carries the frames are the issues after this one.
6668
6769 ## The game
6870
6971 The only terrain is the wake other players leave. Your thruster is feeble;
7072 the currents other swimmers deposit are not, and the bloom only reaches its
7173 gate when several people braid one lane. [`docs/wake.md`](docs/wake.md) is
72 the design record — the mechanic, the starting numbers and the seven
73 invariants every build issue is measured against. It is not decoration: the
74 numbers the page states live in `src/concept.ts` and `bun test` fails if
75 they and the record drift apart. A seat in the sea belongs to a player key
74 the design record — the mechanic, the starting numbers, what a round's credit
75 pays for and the eight invariants every build issue is measured against. It is
76 not decoration: the numbers the page states live in `src/concept.ts` and `bun
77 test` fails if they and the record drift apart. A seat in the sea belongs to a player key
7678 (the forge id, or the guest id behind the cookie) and one key holds one
7779 seat: a second tab steers the same mote, a client presenting no session
7880 watches, and a seat nobody steers is let go — the record's **A seat**
docs/wake.md +169 −10
@@ -140,6 +140,8 @@ only the first guess at holding them.
140140 | unsteered seat | 20 s | `src/concept.ts` `UNSTEERED_SECONDS` |
141141 | round | 180 s | `src/concept.ts` `ROUND_SECONDS` |
142142 | intermission | 20 s | `src/concept.ts` `INTERMISSION_SECONDS` |
143 | point per unit of progress | 1 | `src/concept.ts` `CREDIT_POINTS_PER_UNIT` |
144 | credit tolerance, in points | 10 | `src/concept.ts` `CREDIT_TOLERANCE_POINTS` |
143145 | arena | 1000 units across | `src/world.ts` |
144146 | tick | 20 Hz, one loop, server-authoritative | `src/round.ts` |
145147 | frames out | 10 Hz | `src/round.ts` |
@@ -155,14 +157,163 @@ Over the cap — or under no session at all — an arrival watches as a spectato
155157 turned away; **A seat** above says who a seat belongs to and how it is let go.
156158
157159 The split is deliberate. Physics tuning lives in one block at the top of `src/world.ts` (#5) and
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.
160 moves whenever play says it should — this file names the *target*, not the constant. The eight
161 numbers this file owns, and the sentences quoted in it — the rule, what a spectator reads, what a
162 round pays for and what a guest's credit is worth — are what a person meets on the page, so they
163 live in `src/concept.ts` and the check holds them to this file.
164
165 ## Credit
166
167 A round pays for one thing: the distance the bloom closed on the gate. That is the whole of it, and
168 it is decided here rather than in the pull that implements it (#LADDER), because a rule invented
169 under the pressure of making a test pass is the one that ends up paying for standing near the bloom.
170
171 > A round pays for the distance the bloom closed on the gate, divided among the swimmers whose
172 > current carried it: nothing is paid for being near the bloom, for thrusting at it, or for riding a
173 > lane you did not build.
174
175 **Progress.** The gate's mouth is the middle of its arc on the rim. A round's *gate-ward progress*
176 is the bloom's distance to that mouth when the round starts, minus its distance when the round ends:
177 one scalar, in units, read off the pure simulation. A bloom that ends where it began made no
178 progress however far it wandered on the way, and a round that closed 200 units and never reached the
outdated

Read against the direction and driven against the simulation. The scope is right (record + src/concept.ts + its check, no attribution maths), the tests are real — each new one fails without the change — and there is no security surface here: nothing added reaches a request, a body, an identity or a door, and / on the preview is byte-identical apart from the head SHA. bun test is 68/0 at 818b894, and because the branch is based on b783656 and so predates #46, I also merged main in and ran the real check: bunx tsc --noEmit clean, 71/0. No rebase needed. What I cannot pass is the tolerance, which is the one thing #38 said was the whole difficulty.

1. High — the 2% in invariant 6 can never bind, so the number the pull is named for is inert. createWorld puts the bloom at (0,0) and the gate's mouth on the rim at ARENA_RADIUS = 500, and reaching the mouth ends the round, so gate-ward progress is at most 500 points — the record says as much itself ("about 500 points for a bloom carried clean across the arena"). max(0.02 x P, 10) is therefore 10 for every round the world can produce: whichever is larger always selects the floor, and 2% is decoration. The justification is wrong about where the crossover sits — docs/wake.md:178 says "below a couple of hundred points of progress" when the crossover is at 500, i.e. the entire reach of the arena. Fix first, and it is a small edit: state the tolerance as the flat 10 points it actually is, keep the good argument for it (a third of a field cell at 32x32 — finer than the grain the sea is stored in, so finer than any claim attribution can make), and either drop the percentage from invariant 6 and CREDIT_TOLERANCE or lower the floor until the percentage governs somewhere inside 0..500 and say which rounds each clause covers. While there: "within 2% of it" is undefined for the negative progress the record explicitly allows (a bloom that ended further from the gate); a flat floor says that case cleanly.

2. High — the second of the three named residual sources is described backwards, and it is the one that decides who gets paid. docs/wake.md:167 calls the own-wake term a fitted curve that over-subtracts by a fifth, with a miss that is "a fraction of the bloom's own slow deposit, not of the lane". src/world.ts:291 is const own = Math.min(ahead, ownWake(b.mass, speed)) — clamped by the total forward current, so when ownWake exceeds it the subtraction removes the lane's entire forward component, not the bloom's deposit. For the bloom that term is ownWake(50, s) ~ 9s / (1 + s/159): 44 units/s at speed 5, 84 at speed 10, against a lane the record itself puts at a tenth of FIELD_MAX = 60 (and world.test.ts:330 pins a real door current at >50). I ran the record's own canonical round — the invariant-4 braid, seed 11, gate at 0 — and the clamp binds on 1107 of 1434 ticks, 77% of it. So on most ticks of a scoring round the bloom feels no forward current at all and the lane's forward push is paid nothing that tick, arriving instead as inertia on the ticks the clamp did not bind. That is a defensible rule, but it is not the rule this paragraph describes, and "the share its own-wake subtraction comes out of first" (docs/wake.md:120) cannot be the bloom's own share when the subtraction is larger than it — say explicitly that the excess comes out of the players' shares in proportion, which is what keeps the sum intact.

3. Medium — the rim is not in the residual list, and #LADDER has to pick something the record did not decide. rim() (src/world.ts:312) deletes the bloom's outward velocity, reverses it at half speed with a 30-tick stun above SCATTER_SPEED, and clamps the position to the radius; the gate is 60 degrees of the rim, so most arrivals are wall arrivals. The record's three sources do not mention it, and whether a player's pull is read before or after the rim has touched the bloom is a decision this file exists to make. The good news, since the record predicts tenths of a percent: I measured the sum half of invariant 6 directly, crediting the gate-ward component of the bloom's velocity per tick against the distance actually closed — 464.5 vs 464.0 for a scoring round, 128.3 vs 128.0 for a round pinned off-gate on the rim for 2170 ticks, and it comes out the same to ~0.1% read either before or after rim(). The prediction holds; name the rim and name which side of it the pull is read on, so the proof is not re-derived under test pressure.

4. Low — three of the five new exports are said out loud by nobody. CREDIT_RULE, GUEST_CREDIT and CREDIT_POINTS_PER_UNIT are referenced only from concept.test.ts; AGENTS.md calls this module "the part of it the app says out loud", and RULE is on the page at page.ts:69 while these two sentences are on neither door. Deferring the rendering to #12 is fine, but the deferral belongs in the doc comment next to them rather than only in the pull body. Also cosmetic: README.md line 41 and the last paragraph of docs/wake.md break the file's ~95-column wrap mid-sentence.

Fix 1 and 2 and this is a yes — the mechanic-side thinking (passenger split, only-the-bloom-pays, the three gaming answers) is the right shape, and the test that makes the invariant's words quote the exported constants is the best thing in the diff.

179 rim still pays for 200 units. The gate ends a round; it is not what a round pays for.
180
181 **The purse.** The purse is the gate-ward progress at one point per unit — about 500 points for a
182 bloom carried clean across the arena — divided among the players by the ledger below, in whole
183 points, so a share that rounds to nothing pays nothing. If the progress is not positive, the purse
184 is empty and the round pays nobody at all.
185
186 **The ledger.** Decay and spread are linear, so the sea is exactly the sum of what each player put
187 into it: every cell carries a *share* per player, and the shares of a cell add up to the cell.
188 Three rules keep that book.
189
190 - **What you add is yours.** A mote deposits its momentum into the cell under it. The part of that
191 deposit the cell's current already carried is not yours — you were a passenger — and it goes to
192 the players whose shares make up that current, in their proportions. What is left, the momentum
193 the water did not already have, is yours. Thrust into still water and the whole deposit is yours;
194 drift with a lane at the lane's own speed and none of it is, and the people who built the lane are
195 paid for carrying you. Braid a lane faster than it was and you are paid the difference.
196 - **Only the bloom pays.** Drag is linear too, so the bloom's velocity is exactly the sum, over
197 players, of the velocity that player's shares alone would have given it — call that their *pull*.
198 A player's credit for a tick is the gate-ward part of their pull, times the tick. Nothing else is
199 ever credited: not a deposit, not a second in the sea, not a distance to the bloom. The pull is
200 read where the sea applies it — at the top of the tick, from the current the bloom actually felt,
201 *before* `rim()` answers for where that leaves it. The rim is not a player: it neither pays nor
202 collects, so the speed it deletes off a wall arrival is not charged back to the lane that
203 delivered the bloom there, and the nudge it hands back is credited to nobody. Measured on the
204 record's own braid (below), reading it on the far side of the rim moves a round's sum by under
205 half a point either way.
206 - **The bloom's own wake is nobody's.** The bloom writes momentum into the sea like any body, and
207 that share belongs to no player and pays no one. The own-wake subtraction — "a body never feels
208 its own wake", with a ledger to hand — comes out of that share first, and it is routinely larger
209 than it: `src/world.ts` clamps the subtraction at the whole forward current, and for the bloom
210 that clamp is what ordinarily binds (below). So the rule has to say where the excess lands, and
211 it is this: out of the bloom's own share first, then out of the players' shares in proportion to
212 their forward parts. That is what keeps the sum intact — every unit of current the bloom feels
213 belongs to somebody, and every unit taken off it is taken from somebody — and it is why a tick
214 on which the clamp binds pays no forward credit at all: the bloom feels no forward current that
215 tick, and the ground it covers anyway is inertia the sea handed it earlier.
216
217 A pull that points away from the gate is negative credit, and a player who fought the bloom all
218 round ends the round below zero. The purse is divided among the positive shares only and no round
219 score is negative: the sea does not collect debts.
220
221 **What it does not pay for**, said plainly because these are the three things a player will assume:
222
223 - **Proximity.** Sitting on the bloom is worth nothing. Credit arrives through the cell the bloom is
224 in, from the shares in it, and a body you are next to is not a share.
225 - **Thrusting at the bloom.** Invariant 3 says one mote's thruster moves the bloom less than one
226 cell in ten seconds, so a round spent shoving it directly pays what it achieves, which is nothing.
227 - **Riding a lane somebody else built.** The passenger rule above: their current, their credit.
228
229 ### The three ways a player will try to game it
230
231 - **Build a lane and disconnect before the gate.** Paid in full for the lane, and nothing for the
232 gate, because the gate pays nobody. Credit is written on the tick the current does the work, and a
233 wake outlives the mote that left it — the sea keeps what a departing mote deposited — so those
234 shares go on earning while they last. Leaving early costs only the seconds you were not
235 depositing, and the score is held against the player, not against a mote in the roster.
236 - **Two players trading deposits in a corner with no bloom near them.** They earn exactly zero. No
237 deposit is ever paid for; the only thing that pays is the bloom's gate-ward progress, and momentum
238 in a cell the bloom is not in moves the bloom by nothing. A corner far from the bloom is a corner
239 whose shares are gone by the time the bloom could reach it.
240 - **A client on the JSON door (#DOORS) steering at 20 Hz with no browser.** Credited by exactly the
241 same rule, which is the answer rather than a dodge: it is paid for the bloom's displacement,
242 invariant 3 says a lone thruster produces none of that, and steering faster than the tick changes
243 nothing the sea can see. A bot is another current — legitimate here, and worth precisely the
244 current it builds — and the door's ration bounds how often it may speak, not what it may earn.
245
246 ### A guest's credit
247
248 Credit is physics, not identity: a guest earns from the same purse by the same split as a player
249 with a forge name, and the number is the same number. What signing in changes is whether it is kept.
250 #LADDER shows forge names only, so a guest's score is worth the round it was earned in — it is the
251 number on their own page when the round ends, and it never stands on the board.
252
253 > You earn credit as a guest exactly as anyone does; the ladder shows forge names only, so signing
254 > in is what keeps it.
255
256 ### Why the tolerance is ten points
257
258 Attribution across a decaying field does not sum exactly, and "within a tolerance" is not a rule
259 unless the tolerance is a number with a reason. It is a flat ten points and not a percentage, and
260 that is the first thing to argue. The bloom starts at the middle of the arena, the gate's mouth is
261 on the rim and reaching the mouth ends the round, so a round's gate-ward progress is at most
262 `ARENA_RADIUS` — the 500 points of a clean crossing named above. Two percent of that is ten points,
263 and two percent of anything shorter is less, so a percentage clause could never be the one that
264 binds: it would be decoration on the number the rule is really measured by. A percentage is also
265 undefined for the case this section deliberately allows, where the bloom ends *further* from the
266 gate than it started and the progress is negative. A flat band says both cleanly. Ten points is a
267 third of one field cell at today's 32×32 grid — finer than the grain the sea is stored at, and so
268 finer than any claim attribution can make about whose current a bloom was riding.
269
270 Four things make the ledger miss. All of them are named, and they are worth separating by whether
271 they can be measured before a ledger exists.
272
273 - **The field's cap and floor** (`FIELD_MAX`, `FIELD_MIN`) are not linear, so the shares of a
274 saturated or swept-clean cell no longer add up to the cell. The cap does bind in ordinary play,
275 which an earlier draft of this section had wrong: driving the record's own braid (invariant 4,
276 seed 11, gate at 0), a cell sits exactly at `FIELD_MAX` on about half the ticks of the round. But
277 it is one or two cells at a time, and the cell that saturates is the one directly under a
278 swimmer, not the wide lane the bloom rides — where the bloom sits, the forward current averages
279 67 units, a ninth of the cap. The floor discards a thousandth of a unit.
280 - **The own-wake subtraction** is a fitted curve (`WAKE_GAIN`, `WAKE_SPEED`, `WAKE_TRIM`) rather
281 than the bloom's real share of the cell, and `src/world.ts` clamps it at the whole forward
282 current. For the bloom that clamp is the ordinary case, not the exception: `ownWake` of a mass-50
283 body is 44 units/s at speed 5 and 84 at speed 10, against that average lane of 67, and on the
284 braid above it binds on 1152 of the round's 1440 forward ticks. So the miss is not a fraction of
285 the bloom's own slow deposit; on four ticks in five it is the lane's entire forward component,
286 handed back out of the players' shares by the ledger rule above. This is the largest of the four,
287 and #24 — own-wake as an exact per-body share, not a fit — ends it rather than shrinks it.
288 - **The gate-ward direction is read once per tick**, while the distance actually closed follows a
289 curve; the two differ by second order in one 50 ms step, and only in the part of the motion
290 across the gate rather than toward it.
291 - **The rim** deletes the bloom's outward velocity, reverses what is left of a fast arrival and
292 clamps it to the radius — "What the rim costs", above. The gate is only 60° of the rim, so most
293 arrivals are wall arrivals. The ledger reads a pull before the rim answers, so a tick that ends
294 against the wall credits a gate-ward pull the rim then partly undid.
295
296 The last two are geometry rather than attribution, and geometry can be measured today: sum the
297 gate-ward part of the bloom's own velocity per tick and compare it with the distance the bloom
298 really closed. On the braid that scores, that is 427.2 against 426.6 closed — 0.13%, six tenths of
299 a point — and on a round driven the long way round, pinned off-gate against the rim for 3243 ticks,
300 241.4 against 241.3. Summing on the far side of `rim()` instead gives 426.8 and 241.6, so which
301 side the pull is read on is worth well under a point on a whole round: the choice above is made for
302 its reason, not for its number. `src/world.test.ts` keeps both measurements honest.
303
304 The first two cannot be measured until there is a ledger to measure, and they are #LADDER's first
305 measurement rather than this file's claim. What this file claims is that ten points is no place for
306 a wrong rule to hide. Forgetting the passenger split, crediting gross deposits or dropping the drag
307 term each move the sum by tens of percent — fifty points and up on a round that scores — not by
308 ten points. An absolute band only bites where there is progress to be wrong about, so invariant 8
309 is proved on a round that moved the bloom; its other clause, that a round in which the bloom never
310 moves pays nobody, is exact and carries no tolerance at all. If a build cannot hold ten points on a
311 round that scores, the answer this record expects is #24 and an exact own-wake term, not a larger
312 number here.
162313
163314 ## Invariants
164315
165 Every build issue is measured against these seven. They are numbered, and issues cite them by
316 Every build issue is measured against these eight. They are numbered, and issues cite them by
166317 number; `INVARIANTS` in `src/concept.ts` is the same list as data, so a test can name the one it
167318 proves. The list only ever grows at the end.
168319
@@ -179,6 +330,8 @@ proves. The list only ever grows at the end.
179330 7. One key, one seat: the number of live motes never exceeds the number of distinct player keys in
180331 the roster, whatever a client opens — a second connection under a key steers that key's own mote,
181332 and a request carrying no session key gets no mote at all.
333 8. Credit over a round, signed and summed over every player, equals the bloom's gate-ward progress
334 within 10 points; a round in which the bloom never moves pays nobody.
182335
183336 Where each is proven: 1–4 and 6 in `src/world.test.ts` against the pure simulation (#5), and 4
184337 again in `src/round.test.ts` once a real round runs it (#8). 5 is a property of `step` alone and is
@@ -186,7 +339,10 @@ also the seam #18 must not break when it seeds a round from a stored field. 6 is
186339 difference rather than an absolute: the same lane twice, one arrival over the scatter speed and one
187340 under, so a build where the rim is free — or where it pays, as the first one did — fails it. 7 is
188341 `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).
342 no key is none — and the upgrade's to hold in `src/socket.test.ts` (#9). 8 is #LADDER's to prove,
343 because there is no ledger here to sum; what a world alone can hold of it — that the gate-ward pull
344 the sea applies, summed over a round, is the distance the bloom really closed — is measured in
345 `src/world.test.ts` already, and no attribution arithmetic is written here.
190346
191347 ### Two clarifications the build turned up
192348
@@ -203,8 +359,11 @@ no key is none — and the upgrade's to hold in `src/socket.test.ts` (#9).
203359
204360 ## What this record does not decide
205361
206 The look of the sea, the wire format, the scoring, the words on the page and the shape of 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
362 The look of the sea, the wire format, the words on the page and the shape of the socket are all the
363 build issues'. Scoring is decided above, but only as far as the rule: how the board reads, how many
364 days it spans, where the points are stored and how the arithmetic is carried are #LADDER's. This
365 file decides the mechanic, the eight invariants and the numbers in the table it owns. It says what
366 a seat is; it does not write `join` (#8) or the upgrade (#9), and how a spectator's page differs
367 from a swimmer's is #10's and #11's. It stays open while the map is built against it and closes
368 when the
210369 vertical slice is live (#10, #11) and these numbers have been tuned against real play.
src/concept.test.ts +82 −5
@@ -1,6 +1,10 @@
11 import { expect, test } from "bun:test";
22 import * as concept from "./concept.ts";
33 import { home } from "./page.ts";
4 // The record argues the credit tolerance from two of the world's numbers —
5 // the arena a round is played in, and the grain the sea is stored at — so the
6 // check reads them from the world rather than restating them.
7 import { ARENA_RADIUS, FIELD_CELL } from "./world.ts";
48 import type { Who } from "./who.ts";
59
610 const record = await Bun.file(new URL("../docs/wake.md", import.meta.url))
@@ -45,7 +49,7 @@ test("every number the record hands this module is the number the module exports
4549 );
4650 // If this ever finds nothing, the table changed shape and the check below
4751 // would pass by proving nothing at all.
48 expect(owned.length).toBe(6);
52 expect(owned.length).toBe(8);
4953 for (const [name, value, owner] of owned) {
5054 const exportName = (owner ?? "").match(/`([A-Z_]+)`/)?.[1];
5155 expect(exportName, `no export named in "${owner}"`).toBeDefined();
@@ -83,10 +87,10 @@ test("the invariants read the same in the record and in the code, in order", ()
8387 .split(/\n(?=\d+\. )/)
8488 .filter((entry) => /^\d+\. /.test(entry.trim()))
8589 .map((entry) => tidy(entry.trim().replace(/^\d+\.\s*/, "")));
86 expect(listed.length).toBe(7);
90 expect(listed.length).toBe(8);
8791 expect(listed).toEqual(concept.INVARIANTS.map(tidy));
8892 // The list only grows at the end: an index cited by a test today has to
89 // mean the same promise tomorrow, so 1–6 are pinned by their opening words.
93 // mean the same promise tomorrow, so 1–7 are pinned by their opening words.
9094 const opens = [
9195 "A lone mote",
9296 "A mote dropped",
@@ -94,6 +98,7 @@ test("the invariants read the same in the record and in the code, in order", ()
9498 "Three motes braiding",
9599 "With no input",
96100 "The rim costs",
101 "One key, one seat",
97102 ];
98103 opens.forEach((open, i) => expect(concept.INVARIANTS[i]).toStartWith(open));
99104 });
@@ -129,15 +134,87 @@ test("the seat's seconds are ordered the way the record argues them", () => {
129134 );
130135 });
131136
132 test("invariant 7 is the seat rule, and it is last", () => {
137 test("invariant 7 is the seat rule, whatever is appended after it", () => {
133138 const seven = concept.INVARIANTS[6];
134139 expect(seven).toBeDefined();
135 expect(concept.INVARIANTS.length).toBe(7);
140 expect(concept.INVARIANTS.length).toBe(8);
136141 expect(seven).toStartWith("One key, one seat");
137142 // Worded so #ROUND's test can measure it rather than agree with it.
138143 expect(seven).toContain("never exceeds the number of distinct player keys");
139144 });
140145
146 test("invariant 8 states the tolerance this module exports, not a second one", () => {
147 // The rule is one number in two voices — a sentence a person reads and a
148 // constant #LADDER measures against. If either moves alone, the invariant
149 // stops being provable, so the words have to quote the constant.
150 const eighth = concept.INVARIANTS[7] as string;
151 expect(eighth).toContain(`${concept.CREDIT_TOLERANCE_POINTS} points`);
152 // A round that pays nobody is the half of it no tolerance can express.
153 expect(eighth).toContain("pays nobody");
154 expect(Number.isInteger(concept.CREDIT_TOLERANCE_POINTS)).toBe(true);
155 expect(concept.CREDIT_TOLERANCE_POINTS).toBeGreaterThan(0);
156 // And it is a flat band rather than a percentage, because a percentage
157 // clause could never be the one that binds: the bloom starts at the middle,
158 // the gate's mouth is on the rim and arriving there ends the round, so no
159 // round can pay more than the arena's radius — and two percent of that is
160 // the flat band itself.
161 expect(eighth).not.toContain("%");
162 const mostARoundCanPay = ARENA_RADIUS * concept.CREDIT_POINTS_PER_UNIT;
163 expect(0.02 * mostARoundCanPay).toBeLessThanOrEqual(concept.CREDIT_TOLERANCE_POINTS);
164 });
165
166 test("the tolerance is finer than the grain the sea is stored at", () => {
167 // The record's argument for ten points, as arithmetic: a third of one field
168 // cell. A ledger cannot locate a current inside the cell that holds it, so a
169 // band this size is below the resolution of any claim it could make, and it
170 // stays small against the biggest purse the arena can hand out.
171 expect(concept.CREDIT_TOLERANCE_POINTS).toBeLessThanOrEqual(FIELD_CELL / 3);
172 expect(concept.CREDIT_TOLERANCE_POINTS * 10).toBeLessThan(
173 ARENA_RADIUS * concept.CREDIT_POINTS_PER_UNIT,
174 );
175 });
176
177 test("the record decides which side of the rim a pull is read on", () => {
178 // The rim deletes velocity the sea has just applied, and with a 60° gate
179 // most arrivals are wall arrivals, so "before or after" is a decision
180 // #LADDER must not have to make under test pressure. The Credit section
181 // makes it, and names the rim among the things that make the ledger miss.
182 const ledger = tidy(section("## Credit"));
183 expect(ledger).toContain("before rim()");
184 expect(ledger).toContain("The rim is not a player");
185 const why = tidy(section("### Why the tolerance is ten points"));
186 expect(why, "the residual list does not name the rim").toContain("The rim deletes");
187 // And the own-wake term is described the way the code has it: clamped at
188 // the whole forward current, with the excess apportioned, not swallowed.
189 expect(ledger).toContain("in proportion to");
190 });
191
192 test("the credit rule and the guest sentence are the record's, word for word", () => {
193 // Each sentence is quoted under the heading that decides it, so a heading
194 // that moves is a failure rather than a sentence that drifted somewhere else.
195 const quoted: [string, string][] = [
196 ["## Credit", concept.CREDIT_RULE],
197 ["### A guest's credit", concept.GUEST_CREDIT],
198 ];
199 for (const [heading, sentence] of quoted) {
200 expect(
201 tidy(section(heading)),
202 `${heading} in docs/wake.md does not quote "${sentence}"`,
203 ).toContain(tidy(sentence));
204 }
205 // The record decides what a guest's score is worth; the sentence says it.
206 expect(tidy(concept.GUEST_CREDIT)).toContain("signing in");
207 // One point per unit is what keeps a multiplier out of #LADDER.
208 expect(concept.CREDIT_POINTS_PER_UNIT).toBeGreaterThan(0);
209 });
210
211 test("the record answers the three ways credit gets gamed", () => {
212 const gaming = tidy(section("### The three ways a player will try to game it"));
213 for (const attack of ["disconnect", "corner", "20 Hz"]) {
214 expect(gaming, `the record does not answer "${attack}"`).toContain(attack);
215 }
216 });
217
141218 test("the page says the round's shape in the record's numbers", () => {
142219 // The sentence is written in minutes, so the constant has to be whole ones.
143220 expect(concept.ROUND_SECONDS % 60).toBe(0);
src/concept.ts +51 −3
@@ -8,15 +8,38 @@
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, 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.
11 * lives here is the shape of a round, who may have a seat in it, what it pays
12 * for and the words for all three — the parts a person reads — plus the
13 * invariants, numbered, so a test can name the one it proves.
1414 */
1515
1616 /** The one sentence a newcomer has to read: the whole game is other people. */
1717 export const RULE =
1818 "Your own thruster is feeble; the wake other swimmers leave is not. Ride their lane, braid it, and throw the bloom through the gate.";
1919
20 /**
21 * What a round pays for, in the sentence a player reads. The rule behind it
22 * is the Credit section of `docs/wake.md`, decided there before #LADDER
23 * computes it: the purse is the bloom's gate-ward progress, and it is split
24 * by whose current carried the bloom, not by who was standing next to it.
25 *
26 * No door says this one yet. `RULE` and `SPECTATOR` are on the page because
27 * the page has somewhere to put them; this sentence and `GUEST_CREDIT` belong
28 * beside a score, so #LADDER (#12) puts them on the page and the terminal
29 * when there is a score to put them beside. Until then they live here, once,
30 * so two doors cannot word them differently later.
31 */
32 export const CREDIT_RULE =
33 "A round pays for the distance the bloom closed on the gate, divided among the swimmers whose current carried it: nothing is paid for being near the bloom, for thrusting at it, or for riding a lane you did not build.";
34
35 /**
36 * What a guest's round score is worth, and what signing in changes. Shown
37 * beside a guest's own score by #LADDER (#12); on no door yet, for the reason
38 * above.
39 */
40 export const GUEST_CREDIT =
41 "You earn credit as a guest exactly as anyone does; the ladder shows forge names only, so signing in is what keeps it.";
42
2043 /** Swimmers in one round. An arrival over the cap watches as a spectator. */
2144 export const ROSTER_MAX = 24;
2245
@@ -53,6 +76,30 @@ export const ROUND_SECONDS = 180;
5376 /** The quiet between rounds — long enough to read the ladder, short enough to wait through. */
5477 export const INTERMISSION_SECONDS = 20;
5578
79 /**
80 * Points for one unit of the bloom's gate-ward progress. The whole purse of
81 * a round is its progress at this rate — about 500 points for a bloom
82 * carried clean across the arena — so nothing downstream invents a
83 * multiplier. No door renders it: it is the rate #LADDER (#12) multiplies by,
84 * and what a person reads is the score.
85 */
86 export const CREDIT_POINTS_PER_UNIT = 1;
87
88 /**
89 * How far the attribution ledger may miss the bloom's gate-ward progress, in
90 * points (invariant 8). A flat band and not a percentage: the bloom starts at
91 * the middle and the gate's mouth is on the rim, so no round can close more
92 * than the arena's radius, two percent of that is these ten points, and a
93 * percentage clause could therefore never be the one that binds. A flat band
94 * also says the case the record allows, where a round ends with the bloom
95 * further from the gate than it started. Ten points is a third of one field
96 * cell — finer than the grain the sea is stored at, and so finer than any
97 * claim attribution can make. `docs/wake.md` argues it and names the four
98 * sources of the residual; `src/world.test.ts` measures the two of them a
99 * world without a ledger can measure.
100 */
101 export const CREDIT_TOLERANCE_POINTS = 10;
102
56103 /**
57104 * The things every build issue is measured against, in the order
58105 * `docs/wake.md` numbers them: `INVARIANTS[0]` is invariant 1. A test that
@@ -68,4 +115,5 @@ export const INVARIANTS = [
68115 "With no input, total field magnitude strictly decreases every tick and reaches zero: no energy from nowhere.",
69116 "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.",
70117 "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.",
118 "Credit over a round, signed and summed over every player, equals the bloom's gate-ward progress within 10 points; a round in which the bloom never moves pays nobody.",
71119 ] as const;
src/world.test.ts +183 −10
@@ -1,9 +1,10 @@
11 /**
2 * The six invariants from the concept (#WAKE), proven by driving ticks by
3 * hand, plus the rules the rest of the app will lean on. Each invariant test
4 * is named by its number in `INVARIANTS` (`src/concept.ts`, the record in
5 * `docs/wake.md`), so a failure says which promise broke, and the round it
6 * gets to do it in is that file's number rather than another copy of it.
2 * The invariants from the concept (#WAKE) a world alone can prove — 1 to 6,
3 * and the geometric half of 8 — driven by hand, plus the rules the rest of
4 * the app will lean on. Each invariant test is named by its number in
5 * `INVARIANTS` (`src/concept.ts`, the record in `docs/wake.md`), so a failure
6 * says which promise broke, and the round it gets to do it in is that file's
7 * number rather than another copy of it.
78 *
89 * Every invariant test is written so that it fails if the current field is
910 * taken away — both halves of it, the writing and the drag: each asserts a
@@ -12,14 +13,16 @@
1213 */
1314
1415 import { expect, test } from "bun:test";
15 import { ROUND_SECONDS } from "./concept.ts";
16 import { CREDIT_TOLERANCE_POINTS, ROUND_SECONDS } from "./concept.ts";
1617 import {
1718 ARENA_RADIUS,
19 BLOOM_MASS,
1820 DRAG,
1921 FIELD_CELL,
2022 FIELD_CELLS,
2123 FIELD_MAX,
2224 GATE_HALF_ANGLE,
25 MOTE_MASS,
2326 SCATTER_KEEP,
2427 SCATTER_SPEED,
2528 STUN_TICKS,
@@ -58,7 +61,7 @@ function place(w: World, name: string, x: number, y: number): Body {
5861 return m;
5962 }
6063
61 // ── The six invariants ─────────────────────────────────────────────────
64 // ── The invariants a world holds on its own ───────────────────────────
6265
6366 test("invariant 1: a lone mote at full thrust cannot cross the arena inside 45 seconds", () => {
6467 const w = createWorld({ seed: 11 });
@@ -128,9 +131,18 @@ test("invariant 4: three motes braiding one lane put the bloom through the gate,
128131 expect(braid(1)).toBeNull();
129132 });
130133
131 /** Swim a crew up and down one lane aimed at the gate; the tick the bloom scores, or null. */
132 function braid(crew: number): number | null {
133 const w = createWorld({ seed: 11, gateAngle: 0 });
134 /**
135 * Swim a crew up and down one lane aimed along +x; the tick the bloom scores,
136 * or null. `watch` sees the world at the top of every tick and again at the
137 * bottom, which is how invariant 8's sum is measured on the same round
138 * invariant 4 is proven on.
139 */
140 function braid(
141 crew: number,
142 gateAngle = 0,
143 watch?: { top?: (w: World) => void; bottom?: (w: World) => void },
144 ): number | null {
145 const w = createWorld({ seed: 11, gateAngle });
134146 const swimmers: Body[] = [];
135147 for (let i = 0; i < crew; i++) {
136148 swimmers.push(place(w, `c${i}`, -60, (i - (crew - 1) / 2) * 12));
@@ -150,7 +162,9 @@ function braid(crew: number): number | null {
150162 aim(w, b, w.bloom.x + 400, w.bloom.y + offset);
151163 }
152164 }
165 watch?.top?.(w);
153166 step(w);
167 watch?.bottom?.(w);
154168 if (w.crossedAt !== null) return w.crossedAt;
155169 }
156170 return null;
@@ -295,6 +309,165 @@ test("invariant 6: one arrival is one scatter, so a lane cannot hold a mote in t
295309 expect(victim.stunnedUntil).toBeGreaterThan(paidUntil);
296310 });
297311
312 // ── What the record claims about credit, measured ──────────────────────
313 // Invariant 8 is #LADDER's to prove: there is no ledger here, so no player's
314 // share of the sea can be summed yet. What can be driven against the pure
315 // simulation today is the half of it that is geometry rather than attribution
316 // — that the gate-ward pull the sea applies, summed over a round, is the
317 // distance the bloom really closed — and the claims the Credit section of
318 // `docs/wake.md` makes about how the own-wake term behaves. Both were argued
319 // in prose first; they are measured here so the argument cannot rot quietly.
320
321 interface Carried {
322 /** The distance the bloom really closed on the gate's mouth, in units. */
323 closed: number;
324 /** Gate-ward velocity summed per tick, read where the sea applies it. */
325 beforeRim: number;
326 /** The same sum, read from the ground the bloom was left with. */
327 afterRim: number;
328 /** Ticks with any forward current at the bloom at all. */
329 forwardTicks: number;
330 /** Of those, the ticks the own-wake subtraction took the whole of it. */
331 clamped: number;
332 /**
333 * Clamped ticks the rim kept its hands off, so the current the bloom really
334 * felt can be read back out of its acceleration.
335 */
336 clampedClean: number;
337 /**
338 * The most forward current left to the bloom on one of those ticks, as a
339 * magnitude: a subtraction that overshot shows here as surely as one that
340 * fell short.
341 */
342 worstLeftForward: number;
343 }
344
345 /** Drive the invariant-4 braid and measure what the Credit section claims. */
346 function carried(gateAngle: number): Carried {
347 const mouth = {
348 x: Math.cos(gateAngle) * ARENA_RADIUS,
349 y: Math.sin(gateAngle) * ARENA_RADIUS,
350 };
351 const out: Carried = {
352 closed: 0,
353 beforeRim: 0,
354 afterRim: 0,
355 forwardTicks: 0,
356 clamped: 0,
357 clampedClean: 0,
358 worstLeftForward: 0,
359 };
360 const toMouth = (b: Body): number => Math.hypot(mouth.x - b.x, mouth.y - b.y);
361 let start: number | null = null;
362 let last = 0;
363 // Read at the top of the tick and used at the bottom: the direction of the
364 // mouth, where the bloom was, its heading, its velocity, and whether the
365 // clamp should have taken the whole forward current.
366 let dx = 0;
367 let dy = 0;
368 let px = 0;
369 let py = 0;
370 let hx = 0;
371 let hy = 0;
372 let vx = 0;
373 let vy = 0;
374 let clampedNow = false;
375 braid(3, gateAngle, {
376 top: (w) => {
377 const b = w.bloom;
378 if (start === null) start = toMouth(b);
379 const u = sampleField(w.field, b.x, b.y);
380 const s = speed(b);
381 clampedNow = false;
382 hx = s > 0 ? b.vx / s : 0;
383 hy = s > 0 ? b.vy / s : 0;
384 vx = b.vx;
385 vy = b.vy;
386 if (s > 0) {
387 const ahead = u.x * hx + u.y * hy;
388 if (ahead > 0) {
389 out.forwardTicks++;
390 if (ownWake(b.mass, s) >= ahead) {
391 out.clamped++;
392 clampedNow = true;
393 }
394 }
395 }
396 const d = toMouth(b) || 1;
397 dx = (mouth.x - b.x) / d;
398 dy = (mouth.y - b.y) / d;
399 px = b.x;
400 py = b.y;
401 },
402 bottom: (w) => {
403 const b = w.bloom;
404 // Before the rim: the velocity the sea left the bloom with this tick,
405 // which is what a pull is read from. After: the ground it really made,
406 // rim and all.
407 out.beforeRim += (b.vx * dx + b.vy * dy) / TICK_HZ;
408 out.afterRim += (b.x - px) * dx + (b.y - py) * dy;
409 last = toMouth(b);
410 // The bloom has no thruster, so its whole acceleration is the sea's:
411 // read the current it was actually handed back out of it, and on a tick
412 // the clamp bound there should be nothing of it left ahead. The rim
413 // rewrites velocity, so a tick that ended on the rim is not readable.
414 const onRim = Math.abs(Math.hypot(b.x, b.y) - ARENA_RADIUS) < 1e-9;
415 if (clampedNow && !onRim && w.crossedAt === null) {
416 out.clampedClean++;
417 const gain = DRAG * (MOTE_MASS / b.mass) * (1 / TICK_HZ);
418 const felt = { x: vx + (b.vx - vx) / gain, y: vy + (b.vy - vy) / gain };
419 out.worstLeftForward = Math.max(out.worstLeftForward, Math.abs(felt.x * hx + felt.y * hy));
420 }
421 },
422 });
423 out.closed = (start ?? 0) - last;
424 return out;
425 }
426
427 test("invariant 8, as far as a world with no ledger can prove it: the sum is the distance", () => {
428 // A round that scores, and a round driven the long way round so the bloom
429 // spends it against the rim: with a 60° gate most arrivals are wall
430 // arrivals, and the rim is where the two readings could have disagreed.
431 const runs: [string, Carried][] = [
432 ["the braid that scores", carried(0)],
433 ["a round spent against the rim", carried(Math.PI)],
434 ];
435 for (const [what, run] of runs) {
436 // A ten-point band proves nothing about a round that barely moved, so the
437 // round it is proven on has to have carried the bloom.
438 expect(run.closed, `${what} did not carry the bloom`).toBeGreaterThan(100);
439 expect(Math.abs(run.beforeRim - run.closed), `${what}, read before the rim`).toBeLessThan(
440 CREDIT_TOLERANCE_POINTS,
441 );
442 expect(Math.abs(run.afterRim - run.closed), `${what}, read after the rim`).toBeLessThan(
443 CREDIT_TOLERANCE_POINTS,
444 );
445 // And the record's reason for being free to choose a side: which side of
446 // `rim()` a pull is read on is worth well under a point on a whole round.
447 expect(Math.abs(run.beforeRim - run.afterRim), `${what}, the rim's own share`).toBeLessThan(1);
448 }
449 });
450
451 test("the bloom's own-wake subtraction ordinarily takes the whole lane, not a shaving", () => {
452 // The Credit section rests on this. The subtraction is clamped at the whole
453 // forward current, and for a mass-50 body in a real lane the clamp is the
454 // common case, so the excess has to come out of the players' shares rather
455 // than the bloom's own. If this stops holding, the ledger rule in the record
456 // is describing a world that is gone.
457 const run = carried(0);
458 expect(run.forwardTicks).toBeGreaterThan(100);
459 expect(run.clamped / run.forwardTicks).toBeGreaterThan(0.5);
460 // Measured on the bloom rather than from the formula: on a tick the clamp
461 // binds, the current the bloom was handed has no forward part left at all.
462 // Unclamp the subtraction in `advance` and this goes negative and fails.
463 expect(run.clampedClean).toBeGreaterThan(100);
464 expect(run.worstLeftForward).toBeLessThan(1e-6);
465 // The size of it at the speeds the bloom reaches: the fit asks for more than
466 // the lane the bloom sits in can supply.
467 expect(ownWake(BLOOM_MASS, 5)).toBeGreaterThan(FIELD_MAX / 20);
468 expect(ownWake(BLOOM_MASS, 10)).toBeGreaterThan(FIELD_MAX / 10);
469 });
470
298471 // ── The rules the rest of the app leans on ─────────────────────────────
299472
300473 test("the same world and the same inputs give the same next world, saved or not", () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs