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
vesper/drift:vesper/38-credit-decided-before-it-is-coded-what-a → main 2 commits fork 1 thread · 1 unresolved
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 | ||
| 41 | 41 | the check that reads it, in the action. |
| 42 | 42 | |
| 43 | 43 | 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 | |
| 45 | 45 | against — and `src/concept.ts` is the part of it the app says out loud. Take |
| 46 | 46 | a number from there rather than spelling it again, cite an invariant by its |
| 47 | 47 | 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 | ||
| 59 | 59 | yet. `src/world.ts` advances one tick — motes, momentum, the current field, |
| 60 | 60 | the rim and the gate — with no clock, no database and no network, and the |
| 61 | 61 | 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. | |
| 66 | 68 | |
| 67 | 69 | ## The game |
| 68 | 70 | |
| 69 | 71 | The only terrain is the wake other players leave. Your thruster is feeble; |
| 70 | 72 | the currents other swimmers deposit are not, and the bloom only reaches its |
| 71 | 73 | gate when several people braid one lane. [](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 | |
| 76 | 78 | (the forge id, or the guest id behind the cookie) and one key holds one |
| 77 | 79 | seat: a second tab steers the same mote, a client presenting no session |
| 78 | 80 | 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. | ||
| 140 | 140 | | unsteered seat | 20 s | `src/concept.ts` `UNSTEERED_SECONDS` | |
| 141 | 141 | | round | 180 s | `src/concept.ts` `ROUND_SECONDS` | |
| 142 | 142 | | 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` | | |
| 143 | 145 | | arena | 1000 units across | `src/world.ts` | |
| 144 | 146 | | tick | 20 Hz, one loop, server-authoritative | `src/round.ts` | |
| 145 | 147 | | 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 | ||
| 155 | 157 | turned away; **A seat** above says who a seat belongs to and how it is let go. |
| 156 | 158 | |
| 157 | 159 | 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 | |
| 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. | |
| 162 | 313 | |
| 163 | 314 | ## Invariants |
| 164 | 315 | |
| 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 | |
| 166 | 317 | number; `INVARIANTS` in `src/concept.ts` is the same list as data, so a test can name the one it |
| 167 | 318 | proves. The list only ever grows at the end. |
| 168 | 319 | |
| @@ -179,6 +330,8 @@ proves. The list only ever grows at the end. | ||
| 179 | 330 | 7. One key, one seat: the number of live motes never exceeds the number of distinct player keys in |
| 180 | 331 | the roster, whatever a client opens — a second connection under a key steers that key's own mote, |
| 181 | 332 | 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. | |
| 182 | 335 | |
| 183 | 336 | Where each is proven: 1–4 and 6 in `src/world.test.ts` against the pure simulation (#5), and 4 |
| 184 | 337 | 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 | ||
| 186 | 339 | difference rather than an absolute: the same lane twice, one arrival over the scatter speed and one |
| 187 | 340 | under, so a build where the rim is free — or where it pays, as the first one did — fails it. 7 is |
| 188 | 341 | `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. | |
| 190 | 346 | |
| 191 | 347 | ### Two clarifications the build turned up |
| 192 | 348 | |
| @@ -203,8 +359,11 @@ no key is none — and the upgrade's to hold in `src/socket.test.ts` (#9). | ||
| 203 | 359 | |
| 204 | 360 | ## What this record does not decide |
| 205 | 361 | |
| 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 | |
| 210 | 369 | 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 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import * as concept from "./concept.ts"; |
| 3 | 3 | 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"; | |
| 4 | 8 | import type { Who } from "./who.ts"; |
| 5 | 9 | |
| 6 | 10 | 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 | ||
| 45 | 49 | ); |
| 46 | 50 | // If this ever finds nothing, the table changed shape and the check below |
| 47 | 51 | // would pass by proving nothing at all. |
| 48 | expect(owned.length).toBe(6); | |
| 52 | expect(owned.length).toBe(8); | |
| 49 | 53 | for (const [name, value, owner] of owned) { |
| 50 | 54 | const exportName = (owner ?? "").match(/`([A-Z_]+)`/)?.[1]; |
| 51 | 55 | 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", () | ||
| 83 | 87 | .split(/\n(?=\d+\. )/) |
| 84 | 88 | .filter((entry) => /^\d+\. /.test(entry.trim())) |
| 85 | 89 | .map((entry) => tidy(entry.trim().replace(/^\d+\.\s*/, ""))); |
| 86 | expect(listed.length).toBe(7); | |
| 90 | expect(listed.length).toBe(8); | |
| 87 | 91 | expect(listed).toEqual(concept.INVARIANTS.map(tidy)); |
| 88 | 92 | // 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. | |
| 90 | 94 | const opens = [ |
| 91 | 95 | "A lone mote", |
| 92 | 96 | "A mote dropped", |
| @@ -94,6 +98,7 @@ test("the invariants read the same in the record and in the code, in order", () | ||
| 94 | 98 | "Three motes braiding", |
| 95 | 99 | "With no input", |
| 96 | 100 | "The rim costs", |
| 101 | "One key, one seat", | |
| 97 | 102 | ]; |
| 98 | 103 | opens.forEach((open, i) => expect(concept.INVARIANTS[i]).toStartWith(open)); |
| 99 | 104 | }); |
| @@ -129,15 +134,87 @@ test("the seat's seconds are ordered the way the record argues them", () => { | ||
| 129 | 134 | ); |
| 130 | 135 | }); |
| 131 | 136 | |
| 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", () => { | |
| 133 | 138 | const seven = concept.INVARIANTS[6]; |
| 134 | 139 | expect(seven).toBeDefined(); |
| 135 | expect(concept.INVARIANTS.length).toBe(7); | |
| 140 | expect(concept.INVARIANTS.length).toBe(8); | |
| 136 | 141 | expect(seven).toStartWith("One key, one seat"); |
| 137 | 142 | // Worded so #ROUND's test can measure it rather than agree with it. |
| 138 | 143 | expect(seven).toContain("never exceeds the number of distinct player keys"); |
| 139 | 144 | }); |
| 140 | 145 | |
| 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 | ||
| 141 | 218 | test("the page says the round's shape in the record's numbers", () => { |
| 142 | 219 | // The sentence is written in minutes, so the constant has to be whole ones. |
| 143 | 220 | expect(concept.ROUND_SECONDS % 60).toBe(0); |
src/concept.ts +51 −3
| @@ -8,15 +8,38 @@ | ||
| 8 | 8 | * Physics tuning is not here. Thrust, field half-life, spread, drag, the |
| 9 | 9 | * arena and the gate are the world's, in one block at the top of |
| 10 | 10 | * `src/world.ts` (#WORLD), and they move whenever play says they should. What |
| 11 | * lives here is the shape of a round, 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. | |
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | 16 | /** The one sentence a newcomer has to read: the whole game is other people. */ |
| 17 | 17 | export const RULE = |
| 18 | 18 | "Your own thruster is feeble; the wake other swimmers leave is not. Ride their lane, braid it, and throw the bloom through the gate."; |
| 19 | 19 | |
| 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 | ||
| 20 | 43 | /** Swimmers in one round. An arrival over the cap watches as a spectator. */ |
| 21 | 44 | export const ROSTER_MAX = 24; |
| 22 | 45 | |
| @@ -53,6 +76,30 @@ export const ROUND_SECONDS = 180; | ||
| 53 | 76 | /** The quiet between rounds — long enough to read the ladder, short enough to wait through. */ |
| 54 | 77 | export const INTERMISSION_SECONDS = 20; |
| 55 | 78 | |
| 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 | ||
| 56 | 103 | /** |
| 57 | 104 | * The things every build issue is measured against, in the order |
| 58 | 105 | * `docs/wake.md` numbers them: `INVARIANTS[0]` is invariant 1. A test that |
| @@ -68,4 +115,5 @@ export const INVARIANTS = [ | ||
| 68 | 115 | "With no input, total field magnitude strictly decreases every tick and reaches zero: no energy from nowhere.", |
| 69 | 116 | "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 | 117 | "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.", | |
| 71 | 119 | ] as const; |
src/world.test.ts +183 −10
| @@ -1,9 +1,10 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 7 | 8 | * |
| 8 | 9 | * Every invariant test is written so that it fails if the current field is |
| 9 | 10 | * taken away — both halves of it, the writing and the drag: each asserts a |
| @@ -12,14 +13,16 @@ | ||
| 12 | 13 | */ |
| 13 | 14 | |
| 14 | 15 | import { expect, test } from "bun:test"; |
| 15 | import { ROUND_SECONDS } from "./concept.ts"; | |
| 16 | import { CREDIT_TOLERANCE_POINTS, ROUND_SECONDS } from "./concept.ts"; | |
| 16 | 17 | import { |
| 17 | 18 | ARENA_RADIUS, |
| 19 | BLOOM_MASS, | |
| 18 | 20 | DRAG, |
| 19 | 21 | FIELD_CELL, |
| 20 | 22 | FIELD_CELLS, |
| 21 | 23 | FIELD_MAX, |
| 22 | 24 | GATE_HALF_ANGLE, |
| 25 | MOTE_MASS, | |
| 23 | 26 | SCATTER_KEEP, |
| 24 | 27 | SCATTER_SPEED, |
| 25 | 28 | STUN_TICKS, |
| @@ -58,7 +61,7 @@ function place(w: World, name: string, x: number, y: number): Body { | ||
| 58 | 61 | return m; |
| 59 | 62 | } |
| 60 | 63 | |
| 61 | // ── The six invariants ───────────────────────────────────────────────── | |
| 64 | // ── The invariants a world holds on its own ─────────────────────────── | |
| 62 | 65 | |
| 63 | 66 | test("invariant 1: a lone mote at full thrust cannot cross the arena inside 45 seconds", () => { |
| 64 | 67 | const w = createWorld({ seed: 11 }); |
| @@ -128,9 +131,18 @@ test("invariant 4: three motes braiding one lane put the bloom through the gate, | ||
| 128 | 131 | expect(braid(1)).toBeNull(); |
| 129 | 132 | }); |
| 130 | 133 | |
| 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 }); | |
| 134 | 146 | const swimmers: Body[] = []; |
| 135 | 147 | for (let i = 0; i < crew; i++) { |
| 136 | 148 | swimmers.push(place(w, `c${i}`, -60, (i - (crew - 1) / 2) * 12)); |
| @@ -150,7 +162,9 @@ function braid(crew: number): number | null { | ||
| 150 | 162 | aim(w, b, w.bloom.x + 400, w.bloom.y + offset); |
| 151 | 163 | } |
| 152 | 164 | } |
| 165 | watch?.top?.(w); | |
| 153 | 166 | step(w); |
| 167 | watch?.bottom?.(w); | |
| 154 | 168 | if (w.crossedAt !== null) return w.crossedAt; |
| 155 | 169 | } |
| 156 | 170 | return null; |
| @@ -295,6 +309,165 @@ test("invariant 6: one arrival is one scatter, so a lane cannot hold a mote in t | ||
| 295 | 309 | expect(victim.stunnedUntil).toBeGreaterThan(paidUntil); |
| 296 | 310 | }); |
| 297 | 311 | |
| 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 | ||
| 298 | 471 | // ── The rules the rest of the app leans on ───────────────────────────── |
| 299 | 472 | |
| 300 | 473 | test("the same world and the same inputs give the same next world, saved or not", () => { |
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 testis 68/0 at 818b894, and because the branch is based on b783656 and so predates #46, I also mergedmainin and ran the real check:bunx tsc --noEmitclean, 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.
createWorldputs the bloom at (0,0) and the gate's mouth on the rim atARENA_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 largeralways selects the floor, and 2% is decoration. The justification is wrong about where the crossover sits —docs/wake.md:178says "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 andCREDIT_TOLERANCEor 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:167calls 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:291isconst own = Math.min(ahead, ownWake(b.mass, speed))— clamped by the total forward current, so whenownWakeexceeds it the subtraction removes the lane's entire forward component, not the bloom's deposit. For the bloom that term isownWake(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 ofFIELD_MAX= 60 (andworld.test.ts:330pins 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 aboveSCATTER_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 afterrim(). 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_CREDITandCREDIT_POINTS_PER_UNITare referenced only fromconcept.test.ts;AGENTS.mdcalls this module "the part of it the app says out loud", andRULEis on the page atpage.ts:69while 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.mdline 41 and the last paragraph ofdocs/wake.mdbreak 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.