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

The shared danger has no teeth: decide what the rim costs, in the record and in the world #52

Merged opened this

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

Merged

vesper/drift:vesper/42-the-shared-danger-has-no-teeth-decide-wh → main 1 commit fork

  • Merged. vesper/42-the-shared-danger-has-no-teeth-decide-wh was merged into main as d677f6e.

Review

Sign in to review this change.
7 files changed +223 −24
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 five invariants a build is measured
44 mechanic, the starting numbers and the six 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 +2 −2
@@ -56,7 +56,7 @@ The sea, on the other hand, is real arithmetic that nothing is plugged into
5656 yet. `src/world.ts` advances one tick — motes, momentum, the current field,
5757 the rim and the gate — with no clock, no database and no network, and the
5858 same world always yields the same next one because every source of chance is
59 the seed in the state. `bun test` drives it by hand and proves all five
59 the seed in the state. `bun test` drives it by hand and proves all six
6060 invariants of the record against it, each named by its number. The loop that
6161 calls `step` twenty times a second and the socket that carries the frames are
6262 the issues after this one.
@@ -66,7 +66,7 @@ the issues after this one.
6666 The only terrain is the wake other players leave. Your thruster is feeble;
6767 the currents other swimmers deposit are not, and the bloom only reaches its
6868 gate when several people braid one lane. [`docs/wake.md`](docs/wake.md) is
69 the design record — the mechanic, the starting numbers and the five
69 the design record — the mechanic, the starting numbers and the six
7070 invariants every build issue is measured against. It is not decoration: the
7171 numbers the page states live in `src/concept.ts` and `bun test` fails if
7272 they and the record drift apart.
docs/wake.md +31 −7
@@ -20,8 +20,9 @@ rim. No thruster shifts it measurably and no single swimmer can raise a current
2020 three swimmers braiding one lane throw it across the arena in seconds.
2121
2222 The shared danger is the same fact turned around. The current built to carry the bloom carries you
23 too, and the rim scatters anything that hits it fast — a lane someone else built can be the thing
24 that throws you out of the round.
23 too, and it ends at the rim — a lane someone else built can be the thing that throws you into the
24 wall and leaves you there, seconds down, with nothing to swim with. What that costs exactly is the
25 next section; it is never the round.
2526
2627 The whole of it, in the sentence the page leads with:
2728
@@ -32,6 +33,22 @@ Alone, the sea is flat and the bloom ends the round where it started. With six p
3233 braided river. That is the test the direction (#1) sets: not "better with others" but *the map is
3334 made of other people*.
3435
36 ## What the rim costs
37
38 The sea has an edge, and the edge is not soft. Drift into it and you beach: the push into the wall
39 is gone, whatever ran along it you keep, and you can swim off whenever you like. Arrive fast —
40 above `SCATTER_SPEED`, which a lane two people are keeping will hand you without asking — and the
41 rim scatters you instead. It takes nearly all of your speed and nudges the last of it back inward,
42 and your thruster is dead for three seconds. It is a sponge, not a trampoline: you always leave the
43 rim slower than you arrived, so riding a lane into the wall on purpose is never a quicker way back
44 across the arena than swimming. The sea does not pause while you pay, either — the current that
45 threw you there goes on dragging you, so the rim spits you back into the traffic and the lane
46 decides where those three seconds are spent, usually pinned flat against the wall until it fades.
47 Measured, that is three seconds of thrust and a cell of ground or better, set against the same mote
48 in the same lane that came in under the speed. What it is not is the end of your round: nobody is
49 ever taken out of the sea, and one arrival is one scatter — while you are stunned the rim only
50 beaches you, so a lane somebody else keeps building cannot hold you in the dark.
51
3552 ## Why this one, and not the other two
3653
3754 Three concepts were opened against the direction. Wake was chosen.
@@ -65,6 +82,7 @@ only the first guess at holding them.
6582 | drag | a built lane is ≈ 5× thrust speed | `src/world.ts` |
6683 | bloom | mass 50, no thruster | `src/world.ts` |
6784 | gate | a 60° arc on the rim, moved each round | `src/world.ts` |
85 | rim | a fast arrival costs 3 s of thrust and a cell of ground | `src/world.ts` |
6886
6987 Over the cap, an arrival watches as a spectator rather than being turned away.
7088
@@ -75,7 +93,7 @@ they live in `src/concept.ts` and the check holds them to this file.
7593
7694 ## Invariants
7795
78 Every build issue is measured against these five. They are numbered, and issues cite them by
96 Every build issue is measured against these six. They are numbered, and issues cite them by
7997 number; `INVARIANTS` in `src/concept.ts` is the same list as data, so a test can name the one it
8098 proves.
8199
@@ -85,10 +103,16 @@ proves.
85103 4. Three motes braiding one lane put the bloom through the gate inside a round.
86104 5. With no input, total field magnitude strictly decreases every tick and reaches zero: no energy
87105 from nowhere.
106 6. The rim costs seconds and ground, never a round: a mote that arrives above the scatter speed
107 comes away slower than it arrived, loses 3 s of thrust and a cell of ground against the same
108 mote in the same lane that arrived under it, and steers again the moment those seconds are up —
109 one arrival is one scatter, however hard the lane holds it there.
88110
89 Where each is proven: 1–4 in `src/world.test.ts` against the pure simulation (#5), and 4 again in
90 `src/round.test.ts` once a real round runs it (#8). 5 is a property of `step` alone and is also
91 the seam #18 must not break when it seeds a round from a stored field.
111 Where each is proven: 1–4 and 6 in `src/world.test.ts` against the pure simulation (#5), and 4
112 again in `src/round.test.ts` once a real round runs it (#8). 5 is a property of `step` alone and is
113 also the seam #18 must not break when it seeds a round from a stored field. 6 is measured as a
114 difference rather than an absolute: the same lane twice, one arrival over the scatter speed and one
115 under, so a build where the rim is free — or where it pays, as the first one did — fails it.
92116
93117 ### Two clarifications the build turned up
94118
@@ -106,6 +130,6 @@ the seam #18 must not break when it seeds a round from a stored field.
106130 ## What this record does not decide
107131
108132 The look of the sea, the wire format, the scoring, the words on the page and the shape of the
109 socket are all the build issues'. This file decides the mechanic, the five invariants and the four
133 socket are all the build issues'. This file decides the mechanic, the six invariants and the four
110134 numbers in the table it owns. It stays open while the map is built against it and closes when the
111135 vertical slice is live (#10, #11) and these numbers have been tuned against real play.
src/concept.test.ts +12 −2
@@ -74,7 +74,7 @@ test("the record's numbers name a file that exists", async () => {
7474 }
7575 });
7676
77 test("the five invariants read the same in the record and in the code", () => {
77 test("the six invariants read the same in the record and in the code, in order", () => {
7878 const list = section("## Invariants")
7979 .split("\n\n")
8080 .find((block) => block.startsWith("1. "));
@@ -83,8 +83,18 @@ test("the five invariants read the same in the record and in the code", () => {
8383 .split(/\n(?=\d+\. )/)
8484 .filter((entry) => /^\d+\. /.test(entry.trim()))
8585 .map((entry) => tidy(entry.trim().replace(/^\d+\.\s*/, "")));
86 expect(listed.length).toBe(5);
86 expect(listed.length).toBe(6);
8787 expect(listed).toEqual(concept.INVARIANTS.map(tidy));
88 // The list only grows at the end: an index cited by a test today has to
89 // mean the same promise tomorrow, so 1–5 are pinned by their opening words.
90 const opens = [
91 "A lone mote",
92 "A mote dropped",
93 "One mote thrusting",
94 "Three motes braiding",
95 "With no input",
96 ];
97 opens.forEach((open, i) => expect(concept.INVARIANTS[i]).toStartWith(open));
8898 });
8999
90100 test("the page says the round's shape in the record's numbers", () => {
src/concept.ts +6 −3
@@ -9,7 +9,7 @@
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
1111 * lives here is the shape of a round and the words for it — the parts a
12 * person reads — plus the five invariants, numbered, so a test can name the
12 * person reads — plus the six invariants, numbered, so a test can name the
1313 * one it proves.
1414 */
1515
@@ -27,9 +27,11 @@ export const ROUND_SECONDS = 180;
2727 export const INTERMISSION_SECONDS = 20;
2828
2929 /**
30 * The five things every build issue is measured against, in the order
30 * The six things every build issue is measured against, in the order
3131 * `docs/wake.md` numbers them: `INVARIANTS[0]` is invariant 1. A test that
32 * proves one names it, so a failure says which promise broke.
32 * proves one names it, so a failure says which promise broke. The list only
33 * ever grows at the end: tests cite these by index, so a new invariant is
34 * appended and none is ever reordered.
3335 */
3436 export const INVARIANTS = [
3537 "A lone mote at full thrust cannot cross the arena inside 45 s of ticks.",
@@ -37,4 +39,5 @@ export const INVARIANTS = [
3739 "One mote thrusting at the bloom for 10 s moves it less than one cell.",
3840 "Three motes braiding one lane put the bloom through the gate inside a round.",
3941 "With no input, total field magnitude strictly decreases every tick and reaches zero: no energy from nowhere.",
42 "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.",
4043 ] as const;
src/world.test.ts +154 −4
@@ -1,5 +1,5 @@
11 /**
2 * The five invariants from the concept (#WAKE), proven by driving ticks by
2 * The six invariants from the concept (#WAKE), proven by driving ticks by
33 * hand, plus the rules the rest of the app will lean on. Each invariant test
44 * is named by its number in `INVARIANTS` (`src/concept.ts`, the record in
55 * `docs/wake.md`), so a failure says which promise broke, and the round it
@@ -20,6 +20,7 @@ import {
2020 FIELD_CELLS,
2121 FIELD_MAX,
2222 GATE_HALF_ANGLE,
23 SCATTER_KEEP,
2324 SCATTER_SPEED,
2425 STUN_TICKS,
2526 THRUST_MAX,
@@ -57,7 +58,7 @@ function place(w: World, name: string, x: number, y: number): Body {
5758 return m;
5859 }
5960
60 // ── The five invariants ────────────────────────────────────────────────
61 // ── The six invariants ─────────────────────────────────────────────────
6162
6263 test("invariant 1: a lone mote at full thrust cannot cross the arena inside 45 seconds", () => {
6364 const w = createWorld({ seed: 11 });
@@ -180,6 +181,120 @@ test("invariant 5: with nothing moving in it the sea strictly empties, and reach
180181 expect(fieldMagnitude(w.field)).toBe(0);
181182 });
182183
184 /**
185 * The same mote, the same lane, the same tick, the same place: the only
186 * difference is the speed it arrives at the rim with, one side of
187 * `SCATTER_SPEED` each. The crew swims on at the moment of arrival, which is
188 * the ordinary case — a lane is what put you there and nobody stays to watch
189 * — and then the rider spends `seconds` swimming for the middle.
190 *
191 * What comes back is where it got to and what the rim took on the way.
192 */
193 interface RimRun {
194 w: World;
195 rider: Body;
196 /** The speed it was carrying when the wall arrived. */
197 arriving: number;
198 /** How far from the middle it got to in the end. */
199 home: number;
200 /** Ticks it spent with no thruster. */
201 stunnedFor: number;
202 /** The tick it was moving under its own steering again, or -1. */
203 steeringAgain: number;
204 }
205
206 function intoTheRim(over: boolean, seconds: number): RimRun {
207 const w = createWorld({ seed: 11 });
208 const crew = [place(w, "b1", 120, -5), place(w, "b2", 120, 5)];
209 const rider = place(w, "rider", ARENA_RADIUS - 3, 0);
210 const arriving = SCATTER_SPEED * (over ? 1.2 : 0.9);
211 const laid = ticks(10);
212 let stunnedFor = 0;
213 let steeringAgain = -1;
214 for (let t = 0; t < laid + ticks(seconds); t++) {
215 for (const b of crew) {
216 // A lane kept running into the rim, the thing the rim is dangerous for.
217 if (b.x > ARENA_RADIUS - 160) b.x = 120;
218 applyInput(w, b.id, { x: 1, y: 0 });
219 }
220 if (t < laid) {
221 // The rider waits at the rim while the lane is built, so both runs meet
222 // the same water at the same tick and only the arrival differs.
223 rider.vx = 0;
224 rider.vy = 0;
225 applyInput(w, rider.id, { x: 0, y: 0 });
226 } else if (t === laid) {
227 rider.vx = arriving;
228 rider.vy = 0;
229 for (const b of crew) removeMote(w, b.id);
230 }
231 if (t >= laid) aim(w, rider, 0, 0);
232 step(w);
233 if (w.tick < rider.stunnedUntil) stunnedFor++;
234 else if (stunnedFor > 0 && steeringAgain < 0 && speed(rider) > 1) steeringAgain = w.tick;
235 }
236 return { w, rider, arriving, home: Math.hypot(rider.x, rider.y), stunnedFor, steeringAgain };
237 }
238
239 test("invariant 6: the rim costs a fast arrival seconds and ground, and never the round", () => {
240 const scattered = intoTheRim(true, 10);
241 const clean = intoTheRim(false, 10);
242
243 // Slower than it arrived: the rim is a sponge, never a trampoline, so no
244 // lane is a way of being thrown anywhere.
245 expect(speed(scattered.rider)).toBeLessThan(scattered.arriving);
246
247 // Ground, measured as a difference: the same lane, over and under. Both
248 // motes are swimming home, so a sea that carried nothing would put them in
249 // the same place and this test would have nothing to say.
250 expect(clean.home).toBeLessThan(ARENA_RADIUS - 100);
251 expect(scattered.home - clean.home).toBeGreaterThan(FIELD_CELL);
252
253 // Seconds: the rim takes them from the fast arrival and nobody else, and
254 // three of them is the number `docs/wake.md` says out loud — move
255 // `STUN_TICKS` and the record moves with it or this fails.
256 expect(scattered.stunnedFor).toBe(STUN_TICKS);
257 expect(clean.stunnedFor).toBe(0);
258 expect(STUN_TICKS / TICK_HZ).toBe(3);
259
260 // Never the round: it is still in the sea, still inside the arena, moving
261 // under its own steering again the moment the seconds are up and back at a
262 // whole mote's cruising speed. The cost is ground and seconds, not a body
263 // taken out of play.
264 expect(moteById(scattered.w, scattered.rider.id)).toBe(scattered.rider);
265 expect(Math.hypot(scattered.rider.x, scattered.rider.y)).toBeLessThanOrEqual(ARENA_RADIUS + 1e-9);
266 expect(scattered.steeringAgain).toBeGreaterThan(0);
267 expect(speed(scattered.rider)).toBeGreaterThan(0.75 * (THRUST_MAX / DRAG));
268 });
269
270 test("invariant 6: one arrival is one scatter, so a lane cannot hold a mote in the dark", () => {
271 const w = createWorld({ seed: 8 });
272 const victim = place(w, "victim", ARENA_RADIUS - 2, 0);
273 victim.vx = SCATTER_SPEED * 2;
274 step(w);
275 const paidUntil = victim.stunnedUntil;
276 expect(paidUntil).toBe(w.tick + STUN_TICKS);
277
278 // The lane keeps flinging it at the wall while it is stunned. Every one of
279 // those arrivals is beached — the outward push taken off it — and none of
280 // them buys another three seconds.
281 for (let t = 0; t < STUN_TICKS - 1; t++) {
282 victim.vx = SCATTER_SPEED * 2;
283 victim.vy = 0;
284 step(w);
285 expect(victim.stunnedUntil).toBe(paidUntil);
286 expect(Math.hypot(victim.x, victim.y)).toBeLessThanOrEqual(ARENA_RADIUS + 1e-9);
287 }
288
289 // Once the seconds are paid the rim is dangerous again: this is a bound on
290 // one mistake, not an immunity.
291 step(w);
292 victim.vx = SCATTER_SPEED * 2;
293 victim.vy = 0;
294 step(w);
295 expect(victim.stunnedUntil).toBeGreaterThan(paidUntil);
296 });
297
183298 // ── The rules the rest of the app leans on ─────────────────────────────
184299
185300 test("the same world and the same inputs give the same next world, saved or not", () => {
@@ -258,8 +373,15 @@ test("the rim scatters what arrives fast and beaches what arrives slow", () => {
258373 const arriving = speed(runner);
259374 step(fast);
260375 expect(runner.vx).toBeLessThan(0);
261 expect(speed(runner)).toBeLessThan(arriving * 0.55);
262 expect(speed(runner)).toBeGreaterThan(arriving * 0.4);
376 // What comes back is `SCATTER_KEEP` of what went in, and the number is the
377 // world's rather than another copy of it here. A rim that handed back more
378 // than a mote can swim would be transport, not a danger (invariant 6).
379 // (a tick of drag comes off before the wall does, so it is a shade under)
380 expect(speed(runner)).toBeLessThanOrEqual(arriving * SCATTER_KEEP);
381 expect(speed(runner)).toBeGreaterThan(arriving * SCATTER_KEEP * 0.9);
382 // A rim that handed back more than a mote can swim would be transport
383 // rather than a danger (invariant 6): what comes off the wall is a nudge.
384 expect(speed(runner)).toBeLessThan(THRUST_MAX / DRAG);
263385 expect(runner.stunnedUntil).toBe(fast.tick + STUN_TICKS);
264386 expect(Math.hypot(runner.x, runner.y)).toBeLessThanOrEqual(ARENA_RADIUS + 1e-9);
265387
@@ -286,6 +408,34 @@ test("a stunned mote has no thruster until the stun runs out", () => {
286408 expect(m.vx).toBeGreaterThan(0);
287409 });
288410
411 test("the sea still drags a stunned mote: the rim spits you back into the traffic", () => {
412 // Stated rather than incidental (#RIM). The stun takes the thruster, never
413 // the drag, so the three seconds are spent wherever the lane wants them
414 // spent — which is what makes somebody else's current a danger.
415 const w = createWorld({ seed: 8 });
416 const crew = [place(w, "b1", -200, -5), place(w, "b2", -200, 5)];
417 const drifting = place(w, "drifting", 0, 0);
418 // A lane is built past the mote first, the way one would be built past you.
419 for (let t = 0; t < ticks(12); t++) {
420 for (const b of crew) applyInput(w, b.id, { x: 1, y: 0 });
421 drifting.vx = 0;
422 drifting.vy = 0;
423 step(w);
424 }
425 const from = drifting.x;
426 drifting.stunnedUntil = w.tick + STUN_TICKS;
427 // It asks to swim the other way the whole time and never gets to.
428 for (let t = 0; t < STUN_TICKS; t++) {
429 for (const b of crew) applyInput(w, b.id, { x: 1, y: 0 });
430 applyInput(w, drifting.id, { x: -1, y: 0 });
431 step(w);
432 }
433 expect(w.tick).toBe(drifting.stunnedUntil);
434 // Carried the way it was asking not to go, faster than it could ever swim.
435 expect(drifting.vx).toBeGreaterThan(THRUST_MAX / DRAG);
436 expect(drifting.x - from).toBeGreaterThan(50);
437 });
438
289439 test("the bloom is mass fifty with no thruster, and the gate is the only way out", () => {
290440 const w = createWorld({ seed: 9, gateAngle: 0 });
291441 expect(w.bloom.mass).toBe(50);
src/world.ts +17 −5
@@ -78,10 +78,15 @@ export const BLOOM_MASS = 50;
7878
7979 /** Arriving at the rim faster than this scatters a body instead of beaching it. */
8080 export const SCATTER_SPEED = 45;
81 /** What is left of the velocity after a scatter — reversed, and this much of it. */
82 export const SCATTER_KEEP = 0.5;
83 /** Ticks a scattered body spends without a thruster (≈ 1.5 s). */
84 export const STUN_TICKS = 30;
81 /**
82 * What is left of the velocity after a scatter — reversed, and this much of
83 * it. A tenth is a nudge off the wall, not a bounce: the rim is a sponge, so
84 * riding a lane into it on purpose is never a faster way back across the
85 * arena than swimming (#RIM). At half, measurably, it was.
86 */
87 export const SCATTER_KEEP = 0.1;
88 /** Ticks a scattered body spends without a thruster: three seconds, the cost of the rim. */
89 export const STUN_TICKS = 60;
8590
8691 /** Half-width of the gate arc: a 60° door on the rim. */
8792 export const GATE_HALF_ANGLE = Math.PI / 6;
@@ -295,6 +300,10 @@ function advance(state: World, b: Body): void {
295300 }
296301 // Heavier is slower to answer the sea: the bloom takes fifty times as long.
297302 const mobility = MOTE_MASS / b.mass;
303 // The sea does not let go of a stunned body: the thruster is off, the drag
304 // is not, so the rim spits you back into the traffic that threw you at it
305 // (#RIM). That is the shape of the danger — you lose the seconds, and the
306 // lane you rode decides where you spend them.
298307 let ax = DRAG * mobility * (ux - b.vx);
299308 let ay = DRAG * mobility * (uy - b.vy);
300309 if (state.tick >= b.stunnedUntil) {
@@ -322,7 +331,10 @@ function rim(state: World, b: Body): void {
322331 b.vx = 0;
323332 b.vy = 0;
324333 }
325 } else if (Math.hypot(b.vx, b.vy) > SCATTER_SPEED) {
334 } else if (Math.hypot(b.vx, b.vy) > SCATTER_SPEED && state.tick >= b.stunnedUntil) {
335 // One arrival is one scatter. A body already paying for the last one is
336 // beached instead, so a lane cannot pin somebody in the dark for a whole
337 // round: the cost of the rim is bounded at the seconds it just took.
326338 b.vx = -b.vx * SCATTER_KEEP;
327339 b.vy = -b.vy * SCATTER_KEEP;
328340 b.stunnedUntil = state.tick + 1 + STUN_TICKS;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs