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

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

Merged opened this

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

Merged

vesper/drift:vesper/38-credit-decided-before-it-is-coded-what-a → main 3 commits fork

  • Merged. vesper/38-credit-decided-before-it-is-coded-what-a was merged into main as bf9e2ba.

Review

Sign in to review this change.
4 files changed +247 −19
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
docs/wake.md +137 −11
@@ -241,6 +241,8 @@ only the first guess at holding them.
241241 | unsteered seat | 20 s | `src/concept.ts` `UNSTEERED_SECONDS` |
242242 | round | 180 s | `src/concept.ts` `ROUND_SECONDS` |
243243 | intermission | 20 s | `src/concept.ts` `INTERMISSION_SECONDS` |
244 | point per unit of progress | 1 | **Credit**, below — #LADDER's constant |
245 | credit tolerance, in points | 10 | invariant 8 — #LADDER's constant |
244246 | arena | 1000 units across | `src/world.ts` |
245247 | tick | 20 Hz, one loop, server-authoritative | `src/round.ts` |
246248 | frames out | 10 Hz | `src/round.ts` |
@@ -257,13 +259,129 @@ turned away; **A seat** above says who a seat belongs to and how it is let go.
257259
258260 The split is deliberate. Physics tuning lives in one block at the top of `src/world.ts` (#5) and
259261 moves whenever play says it should — this file names the *target*, not the constant. The six
260 numbers this file owns, and the two sentences quoted above — the rule, and what a spectator reads —
261 are what a person meets on the page, so they live in `src/concept.ts` and the check holds them to
262 this file.
262 numbers this file owns, and the sentences quoted in it — the rule, what a spectator reads and what a
263 round pays for — are what a person meets on the page, so they live in `src/concept.ts` and the check
264 holds them to this file. The two credit numbers are neither: no door ever renders a rate or a
265 tolerance, so they are decided here and #LADDER (#12) is where they become constants.
266
267 ## Credit
268
269 A round pays for one thing: the distance the bloom closed on the gate. That is the whole of it, and
270 it is decided here rather than in the pull that implements it (#LADDER), because a rule invented
271 under the pressure of making a test pass is the one that ends up paying for standing near the bloom.
272
273 > A round pays for the distance the bloom closed on the gate, divided among the swimmers whose
274 > current carried it: nothing is paid for being near the bloom, for thrusting at it, or for riding a
275 > lane you did not build.
276
277 **Progress.** The gate's mouth is the middle of its arc on the rim. A round's *gate-ward progress*
278 is the bloom's distance to that mouth when the round starts, minus its distance when the round ends:
279 one scalar, in units, read off the pure simulation. A bloom that ends where it began made no
280 progress however far it wandered on the way, and a round that closed 200 units and never reached the
281 rim still pays for 200 units. The gate ends a round; it is not what a round pays for.
282
283 **The purse.** The purse is the gate-ward progress at one point per unit — about 500 points for a
284 bloom carried clean across the arena — divided among the players by the ledger below, in whole
285 points, so a share that rounds to nothing pays nothing. If the progress is not positive, the purse
286 is empty and the round pays nobody at all.
287
288 **The ledger.** Decay and spread are linear, so the sea is exactly the sum of what each player put
289 into it: every cell carries a *share* per player, and the shares of a cell add up to the cell.
290 Three rules keep that book.
291
292 - **What you add is yours.** A mote deposits its momentum into the cell under it. The part of that
293 deposit the cell's current already carried is not yours — you were a passenger — and it goes to
294 the players whose shares make up that current, in their proportions. What is left, the momentum
295 the water did not already have, is yours. Thrust into still water and the whole deposit is yours;
296 drift with a lane at the lane's own speed and none of it is, and the people who built the lane are
297 paid for carrying you. Braid a lane faster than it was and you are paid the difference.
298 - **Only the bloom pays.** Drag is linear too, so the bloom's velocity is exactly the sum, over
299 players, of the velocity that player's shares alone would have given it — call that their *pull*.
300 A player's credit for a tick is the gate-ward part of their pull, times the tick. Nothing else is
301 ever credited: not a deposit, not a second in the sea, not a distance to the bloom. The pull is
302 read where the sea applies it — at the top of the tick, from the current the bloom actually felt,
303 *before* `rim()` answers for where that leaves it. The rim is not a player: it neither pays nor
304 collects, so the speed it deletes off a wall arrival is not charged back to the lane that
305 delivered the bloom there, and the nudge it hands back is credited to nobody. Which side is a
306 decision rather than a measurement: driven against the pure simulation it is worth well under a
307 point on a whole round either way, so it is settled here before a test can settle it by accident.
308 - **The bloom's own wake is nobody's.** The bloom writes momentum into the sea like any body, and
309 that share belongs to no player and pays no one. The own-wake subtraction — "a body never feels
310 its own wake", with a ledger to hand — comes out of that share first, and it is routinely larger
311 than it: `src/world.ts` clamps the subtraction at the whole forward current, and for a mass-50
312 body in an ordinary lane that clamp is what binds on most ticks rather than the exception. So the
313 rule has to say where the excess lands, and
314 it is this: out of the bloom's own share first, then out of the players' shares in proportion to
315 their forward parts. That is what keeps the sum intact — every unit of current the bloom feels
316 belongs to somebody, and every unit taken off it is taken from somebody — and it is why a tick
317 on which the clamp binds pays no forward credit at all: the bloom feels no forward current that
318 tick, and the ground it covers anyway is inertia the sea handed it earlier.
319
320 A pull that points away from the gate is negative credit, and a player who fought the bloom all
321 round ends the round below zero. The purse is divided among the positive shares only and no round
322 score is negative: the sea does not collect debts.
323
324 **What it does not pay for**, said plainly because these are the three things a player will assume:
325
326 - **Proximity.** Sitting on the bloom is worth nothing. Credit arrives through the cell the bloom is
327 in, from the shares in it, and a body you are next to is not a share.
328 - **Thrusting at the bloom.** Invariant 3 says one mote's thruster moves the bloom less than one
329 cell in ten seconds, so a round spent shoving it directly pays what it achieves, which is nothing.
330 - **Riding a lane somebody else built.** The passenger rule above: their current, their credit.
331
332 ### The three ways a player will try to game it
333
334 - **Build a lane and disconnect before the gate.** Paid in full for the lane, and nothing for the
335 gate, because the gate pays nobody. Credit is written on the tick the current does the work, and a
336 wake outlives the mote that left it — the sea keeps what a departing mote deposited — so those
337 shares go on earning while they last. Leaving early costs only the seconds you were not
338 depositing, and the score is held against the player, not against a mote in the roster.
339 - **Two players trading deposits in a corner with no bloom near them.** They earn exactly zero. No
340 deposit is ever paid for; the only thing that pays is the bloom's gate-ward progress, and momentum
341 in a cell the bloom is not in moves the bloom by nothing. A corner far from the bloom is a corner
342 whose shares are gone by the time the bloom could reach it.
343 - **A client on the JSON door (#DOORS) steering at 20 Hz with no browser.** Credited by exactly the
344 same rule, which is the answer rather than a dodge: it is paid for the bloom's displacement,
345 invariant 3 says a lone thruster produces none of that, and steering faster than the tick changes
346 nothing the sea can see. A bot is another current — legitimate here, and worth precisely the
347 current it builds — and the door's ration bounds how often it may speak, not what it may earn.
348
349 ### A guest's credit
350
351 Credit is physics, not identity: a guest earns from the same purse by the same split as a player
352 with a forge name, and the number is the same number. What signing in changes is whether it is kept
353 — #LADDER shows forge names only, so a guest's score is the number on their own page when the round
354 ends and never stands on the board. The sentence that says so to a guest is #LADDER's to word, next
355 to the score it is about.
356
357 ### The tolerance, and why it is ten points
358
359 Attribution across a decaying field does not sum exactly, and "within a tolerance" is not a rule
360 unless the tolerance is a number with a reason. Ten points is two percent of the most a round can
361 ever pay — the bloom starts at the middle, the gate's mouth is on the rim and reaching it ends the
362 round, so no round closes more than `ARENA_RADIUS`, the 500 points of a clean crossing — and it is a
363 flat band rather than a percentage because two percent of any shorter round is smaller still, so a
364 percentage clause could never be the one that binds, and because a percentage says nothing at all
365 about the case this record deliberately allows, where the bloom ends further from the gate than it
366 started. Its four sources are the field's cap and floor (`FIELD_MAX`, `FIELD_MIN`, which are not
367 linear, so a saturated cell's shares no longer add up to the cell), the own-wake subtraction (a
368 fitted curve clamped at the whole forward current — the largest of the four, and #24's to end rather
369 than shrink), the gate-ward direction read once per tick against a path that curves inside the step,
370 and the rim, which answers after the pull is read; none of them is a wrong rule, and ten points is
371 about a third of one field cell at today's 32×32 grid — finer than the grain the sea is stored at,
372 and so finer than any claim attribution can make about whose current a bloom was riding.
373
374 Ten points is therefore no place for a wrong rule to hide: forgetting the passenger split, crediting
375 gross deposits or dropping the drag term each move the sum by tens of percent — fifty points and up
376 on a round that scores. An absolute band only bites where there is progress to be wrong about, so
377 invariant 8 is proved on a round that moved the bloom; its other clause, that a round in which the
378 bloom never moves pays nobody, is exact and carries no tolerance at all. If a build cannot hold ten
379 points on a round that scores, the answer this record expects is #24 and an exact own-wake term, not
380 a larger number here.
263381
264382 ## Invariants
265383
266 Every build issue is measured against these seven. They are numbered, and issues cite them by
384 Every build issue is measured against these eight. They are numbered, and issues cite them by
267385 number; `INVARIANTS` in `src/concept.ts` is the same list as data, so a test can name the one it
268386 proves. The list only ever grows at the end.
269387
@@ -280,6 +398,8 @@ proves. The list only ever grows at the end.
280398 7. One key, one seat: the number of live motes never exceeds the number of distinct player keys in
281399 the roster, whatever a client opens — a second connection under a key steers that key's own mote,
282400 and a request carrying no session key gets no mote at all.
401 8. Credit over a round, signed and summed over every player, equals the bloom's gate-ward progress
402 within 10 points; a round in which the bloom never moves pays nobody.
283403
284404 Where each is proven: 1–4 and 6 in `src/world.test.ts` against the pure simulation (#5), and 4
285405 again in `src/round.test.ts` once a real round runs it (#8). 5 is a property of `step` alone and is
@@ -287,7 +407,11 @@ also the seam #18 must not break when it seeds a round from a stored field. 6 is
287407 difference rather than an absolute: the same lane twice, one arrival over the scatter speed and one
288408 under, so a build where the rim is free — or where it pays, as the first one did — fails it. 7 is
289409 `join`'s to prove in `src/round.test.ts` (#8) — the same key joining twice is one mote, a join with
290 no key is none — and the upgrade's to hold in `src/socket.test.ts` (#9).
410 no key is none — and the upgrade's to hold in `src/socket.test.ts` (#9). 8 is #LADDER's to prove
411 (#12), because there is no ledger in the repository to sum yet: it is proved against the pure
412 simulation — a round driven by hand, the ledger summed, the bloom's two distances to the gate's
413 mouth subtracted — and it needs no clock, no database and no socket to hold, which is why it is
414 stated here in full and its arithmetic is written nowhere in this pull.
291415
292416 ### Two clarifications the build turned up
293417
@@ -304,10 +428,12 @@ no key is none — and the upgrade's to hold in `src/socket.test.ts` (#9).
304428
305429 ## What this record does not decide
306430
307 The wire format, the scoring, the words on the page and the shape of the socket are all the build
308 issues'. This file decides the mechanic, the seven invariants, what a seat is, what the rim costs, how
309 the sea reads (**The look**) and the numbers in the table it owns. It says what a seat is; it does not
310 write `join` (#8) or the upgrade (#9), and how a spectator's page differs from a swimmer's is #10's and
311 #11's. **The look** says what every door's marks mean; it draws nothing, budgets no frame and names no
312 pixel a door must spend. It stays open while the map is built against it and closes when the
431 The wire format, the words on the page and the shape of the socket are all the build issues'.
432 Scoring is decided above, but only as far as the rule: how the board reads, how many days it spans,
433 where the points are stored and how the arithmetic is carried are #LADDER's. This file decides the
434 mechanic, the eight invariants, what a seat is, what the rim costs, what a round's credit pays for,
435 how the sea reads (**The look**) and the numbers in the table it owns. It does not write `join` (#8)
436 or the upgrade (#9), and how a spectator's page differs from a swimmer's is #10's and #11's. **The
437 look** says what every door's marks mean; it draws nothing, budgets no frame and names no pixel a
438 door must spend. It stays open while the map is built against it and closes when the
313439 vertical slice is live (#10, #11) and these numbers have been tuned against real play.
src/concept.test.ts +89 −4
@@ -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))
@@ -122,10 +126,10 @@ test("the invariants read the same in the record and in the code, in order", ()
122126 .split(/\n(?=\d+\. )/)
123127 .filter((entry) => /^\d+\. /.test(entry.trim()))
124128 .map((entry) => tidy(entry.trim().replace(/^\d+\.\s*/, "")));
125 expect(listed.length).toBe(7);
129 expect(listed.length).toBe(8);
126130 expect(listed).toEqual(concept.INVARIANTS.map(tidy));
127131 // The list only grows at the end: an index cited by a test today has to
128 // mean the same promise tomorrow, so 1–6 are pinned by their opening words.
132 // mean the same promise tomorrow, so 1–7 are pinned by their opening words.
129133 const opens = [
130134 "A lone mote",
131135 "A mote dropped",
@@ -133,6 +137,7 @@ test("the invariants read the same in the record and in the code, in order", ()
133137 "Three motes braiding",
134138 "With no input",
135139 "The rim costs",
140 "One key, one seat",
136141 ];
137142 opens.forEach((open, i) => expect(concept.INVARIANTS[i]).toStartWith(open));
138143 });
@@ -168,15 +173,95 @@ test("the seat's seconds are ordered the way the record argues them", () => {
168173 );
169174 });
170175
171 test("invariant 7 is the seat rule, and it is last", () => {
176 test("invariant 7 is the seat rule, whatever is appended after it", () => {
172177 const seven = concept.INVARIANTS[6];
173178 expect(seven).toBeDefined();
174 expect(concept.INVARIANTS.length).toBe(7);
179 expect(concept.INVARIANTS.length).toBe(8);
175180 expect(seven).toStartWith("One key, one seat");
176181 // Worded so #ROUND's test can measure it rather than agree with it.
177182 expect(seven).toContain("never exceeds the number of distinct player keys");
178183 });
179184
185 /** A row of the numbers table, by the name in its first cell. */
186 const number = (name: string): number => {
187 const row = rows().find((cells) => cells[0] === name);
188 expect(row, `docs/wake.md has no "${name}" row`).toBeDefined();
189 return Number((row as string[])[1]);
190 };
191
192 test("invariant 8 states the tolerance the record's own table names", () => {
193 // The rule is one number in two voices — a sentence a person reads, and the
194 // row #LADDER (#12) turns into a constant. If either moves alone the
195 // invariant stops being provable, so the words have to quote the row.
196 const tolerance = number("credit tolerance, in points");
197 const eighth = concept.INVARIANTS[7] as string;
198 expect(eighth).toContain(`${tolerance} points`);
199 // A round that pays nobody is the half of it no tolerance can express.
200 expect(eighth).toContain("pays nobody");
201 expect(eighth).toContain("gate-ward progress");
202 expect(Number.isInteger(tolerance)).toBe(true);
203 expect(tolerance).toBeGreaterThan(0);
204 // And it is a flat band rather than a percentage, because a percentage
205 // clause could never be the one that binds: the bloom starts at the middle,
206 // the gate's mouth is on the rim and arriving there ends the round, so no
207 // round can pay more than the arena's radius — and two percent of that is
208 // the flat band itself.
209 expect(eighth).not.toContain("%");
210 const mostARoundCanPay = ARENA_RADIUS * number("point per unit of progress");
211 expect(0.02 * mostARoundCanPay).toBeLessThanOrEqual(tolerance);
212 // A third of one field cell: a ledger cannot locate a current inside the
213 // cell that holds it, so the band is below the resolution of any claim it
214 // could make, and it stays small against the biggest purse there is.
215 expect(tolerance).toBeLessThanOrEqual(FIELD_CELL / 3);
216 expect(tolerance * 10).toBeLessThan(mostARoundCanPay);
217 });
218
219 test("the record decides which side of the rim a pull is read on", () => {
220 // The rim deletes velocity the sea has just applied, and with a 60° gate
221 // most arrivals are wall arrivals, so "before or after" is a decision
222 // #LADDER must not have to make under test pressure. The Credit section
223 // makes it, and names the rim among the things that make the ledger miss.
224 const ledger = tidy(section("## Credit"));
225 expect(ledger).toContain("before rim()");
226 expect(ledger).toContain("The rim is not a player");
227 const why = tidy(section("### The tolerance, and why it is ten points"));
228 expect(why, "the residual list does not name the rim").toContain("the rim, which answers after");
229 // And the own-wake term is described the way the code has it: clamped at
230 // the whole forward current, with the excess apportioned, not swallowed.
231 expect(ledger).toContain("in proportion to");
232 });
233
234 test("the credit rule is the record's, word for word", () => {
235 // The sentence is quoted under the heading that decides it, so a heading
236 // that moves is a failure rather than a sentence that drifted somewhere else.
237 expect(
238 tidy(section("## Credit")),
239 'docs/wake.md "## Credit" does not quote CREDIT_RULE',
240 ).toContain(tidy(concept.CREDIT_RULE));
241 // What it refuses to pay for is the half a player will assume wrong.
242 for (const refusal of ["being near the bloom", "thrusting at it", "did not build"]) {
243 expect(tidy(concept.CREDIT_RULE)).toContain(refusal);
244 }
245 });
246
247 test("this module says what a player reads about credit and computes none of it", () => {
248 // #LADDER (#12) owns the arithmetic and cites invariant 8 by number. No
249 // door renders a rate or a tolerance, so a credit constant appearing here
250 // means the maths leaked into the part of the record the app speaks aloud.
251 const exported = concept as unknown as Record<string, unknown>;
252 for (const [name, value] of Object.entries(exported)) {
253 if (!/CREDIT/.test(name)) continue;
254 expect(typeof value, `${name} is a number this module has no door for`).toBe("string");
255 }
256 });
257
258 test("the record answers the three ways credit gets gamed", () => {
259 const gaming = tidy(section("### The three ways a player will try to game it"));
260 for (const attack of ["disconnect", "corner", "20 Hz"]) {
261 expect(gaming, `the record does not answer "${attack}"`).toContain(attack);
262 }
263 });
264
180265 test("the record decides how the sea reads before three doors draw it", () => {
181266 const look = tidy(section("## The look"));
182267 // One paragraph per element, and the order marks are laid down, so a
src/concept.ts +20 −3
@@ -8,15 +8,31 @@
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 belongs beside a score,
28 * so #LADDER (#12) puts it on the page and the terminal when there is a score
29 * to put it beside. Until then it lives here, once, so two doors cannot word
30 * it differently later. The purse, the rate and the tolerance are that issue's
31 * arithmetic and not the app's speech: they stay in the record.
32 */
33 export const CREDIT_RULE =
34 "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.";
35
2036 /** Swimmers in one round. An arrival over the cap watches as a spectator. */
2137 export const ROSTER_MAX = 24;
2238
@@ -82,4 +98,5 @@ export const INVARIANTS = [
8298 "With no input, total field magnitude strictly decreases every tick and reaches zero: no energy from nowhere.",
8399 "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.",
84100 "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.",
101 "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.",
85102 ] as const;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs