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
Check · Review · Land · Live done — Landed and live in production.
vesper/drift:vesper/42-the-shared-danger-has-no-teeth-decide-wh → main 1 commit fork
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 | ||
| 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 five invariants a build is measured | |
| 44 | mechanic, the starting numbers and the six 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 +2 −2
| @@ -56,7 +56,7 @@ The sea, on the other hand, is real arithmetic that nothing is plugged into | ||
| 56 | 56 | yet. `src/world.ts` advances one tick — motes, momentum, the current field, |
| 57 | 57 | the rim and the gate — with no clock, no database and no network, and the |
| 58 | 58 | 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 | |
| 60 | 60 | invariants of the record against it, each named by its number. The loop that |
| 61 | 61 | calls `step` twenty times a second and the socket that carries the frames are |
| 62 | 62 | the issues after this one. |
| @@ -66,7 +66,7 @@ the issues after this one. | ||
| 66 | 66 | The only terrain is the wake other players leave. Your thruster is feeble; |
| 67 | 67 | the currents other swimmers deposit are not, and the bloom only reaches its |
| 68 | 68 | gate when several people braid one lane. [](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 | |
| 70 | 70 | invariants every build issue is measured against. It is not decoration: the |
| 71 | 71 | numbers the page states live in `src/concept.ts` and `bun test` fails if |
| 72 | 72 | 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 | ||
| 20 | 20 | three swimmers braiding one lane throw it across the arena in seconds. |
| 21 | 21 | |
| 22 | 22 | 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. | |
| 25 | 26 | |
| 26 | 27 | The whole of it, in the sentence the page leads with: |
| 27 | 28 | |
| @@ -32,6 +33,22 @@ Alone, the sea is flat and the bloom ends the round where it started. With six p | ||
| 32 | 33 | braided river. That is the test the direction (#1) sets: not "better with others" but *the map is |
| 33 | 34 | made of other people*. |
| 34 | 35 | |
| 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 | ||
| 35 | 52 | ## Why this one, and not the other two |
| 36 | 53 | |
| 37 | 54 | Three concepts were opened against the direction. Wake was chosen. |
| @@ -65,6 +82,7 @@ only the first guess at holding them. | ||
| 65 | 82 | | drag | a built lane is ≈ 5× thrust speed | `src/world.ts` | |
| 66 | 83 | | bloom | mass 50, no thruster | `src/world.ts` | |
| 67 | 84 | | 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` | | |
| 68 | 86 | |
| 69 | 87 | Over the cap, an arrival watches as a spectator rather than being turned away. |
| 70 | 88 | |
| @@ -75,7 +93,7 @@ they live in `src/concept.ts` and the check holds them to this file. | ||
| 75 | 93 | |
| 76 | 94 | ## Invariants |
| 77 | 95 | |
| 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 | |
| 79 | 97 | number; `INVARIANTS` in `src/concept.ts` is the same list as data, so a test can name the one it |
| 80 | 98 | proves. |
| 81 | 99 | |
| @@ -85,10 +103,16 @@ proves. | ||
| 85 | 103 | 4. Three motes braiding one lane put the bloom through the gate inside a round. |
| 86 | 104 | 5. With no input, total field magnitude strictly decreases every tick and reaches zero: no energy |
| 87 | 105 | 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. | |
| 88 | 110 | |
| 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. | |
| 92 | 116 | |
| 93 | 117 | ### Two clarifications the build turned up |
| 94 | 118 | |
| @@ -106,6 +130,6 @@ the seam #18 must not break when it seeds a round from a stored field. | ||
| 106 | 130 | ## What this record does not decide |
| 107 | 131 | |
| 108 | 132 | 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 | |
| 110 | 134 | numbers in the table it owns. It stays open while the map is built against it and closes when the |
| 111 | 135 | 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 () => { | ||
| 74 | 74 | } |
| 75 | 75 | }); |
| 76 | 76 | |
| 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", () => { | |
| 78 | 78 | const list = section("## Invariants") |
| 79 | 79 | .split("\n\n") |
| 80 | 80 | .find((block) => block.startsWith("1. ")); |
| @@ -83,8 +83,18 @@ test("the five invariants read the same in the record and in the code", () => { | ||
| 83 | 83 | .split(/\n(?=\d+\. )/) |
| 84 | 84 | .filter((entry) => /^\d+\. /.test(entry.trim())) |
| 85 | 85 | .map((entry) => tidy(entry.trim().replace(/^\d+\.\s*/, ""))); |
| 86 | expect(listed.length).toBe(5); | |
| 86 | expect(listed.length).toBe(6); | |
| 87 | 87 | 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)); | |
| 88 | 98 | }); |
| 89 | 99 | |
| 90 | 100 | test("the page says the round's shape in the record's numbers", () => { |
src/concept.ts +6 −3
| @@ -9,7 +9,7 @@ | ||
| 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 | 11 | * 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 | |
| 13 | 13 | * one it proves. |
| 14 | 14 | */ |
| 15 | 15 | |
| @@ -27,9 +27,11 @@ export const ROUND_SECONDS = 180; | ||
| 27 | 27 | export const INTERMISSION_SECONDS = 20; |
| 28 | 28 | |
| 29 | 29 | /** |
| 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 | |
| 31 | 31 | * `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. | |
| 33 | 35 | */ |
| 34 | 36 | export const INVARIANTS = [ |
| 35 | 37 | "A lone mote at full thrust cannot cross the arena inside 45 s of ticks.", |
| @@ -37,4 +39,5 @@ export const INVARIANTS = [ | ||
| 37 | 39 | "One mote thrusting at the bloom for 10 s moves it less than one cell.", |
| 38 | 40 | "Three motes braiding one lane put the bloom through the gate inside a round.", |
| 39 | 41 | "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.", | |
| 40 | 43 | ] as const; |
src/world.test.ts +154 −4
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 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 | |
| 3 | 3 | * hand, plus the rules the rest of the app will lean on. Each invariant test |
| 4 | 4 | * is named by its number in `INVARIANTS` (`src/concept.ts`, the record in |
| 5 | 5 | * `docs/wake.md`), so a failure says which promise broke, and the round it |
| @@ -20,6 +20,7 @@ import { | ||
| 20 | 20 | FIELD_CELLS, |
| 21 | 21 | FIELD_MAX, |
| 22 | 22 | GATE_HALF_ANGLE, |
| 23 | SCATTER_KEEP, | |
| 23 | 24 | SCATTER_SPEED, |
| 24 | 25 | STUN_TICKS, |
| 25 | 26 | THRUST_MAX, |
| @@ -57,7 +58,7 @@ function place(w: World, name: string, x: number, y: number): Body { | ||
| 57 | 58 | return m; |
| 58 | 59 | } |
| 59 | 60 | |
| 60 | // ── The five invariants ──────────────────────────────────────────────── | |
| 61 | // ── The six invariants ───────────────────────────────────────────────── | |
| 61 | 62 | |
| 62 | 63 | test("invariant 1: a lone mote at full thrust cannot cross the arena inside 45 seconds", () => { |
| 63 | 64 | const w = createWorld({ seed: 11 }); |
| @@ -180,6 +181,120 @@ test("invariant 5: with nothing moving in it the sea strictly empties, and reach | ||
| 180 | 181 | expect(fieldMagnitude(w.field)).toBe(0); |
| 181 | 182 | }); |
| 182 | 183 | |
| 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 | ||
| 183 | 298 | // ── The rules the rest of the app leans on ───────────────────────────── |
| 184 | 299 | |
| 185 | 300 | 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", () => { | ||
| 258 | 373 | const arriving = speed(runner); |
| 259 | 374 | step(fast); |
| 260 | 375 | 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); | |
| 263 | 385 | expect(runner.stunnedUntil).toBe(fast.tick + STUN_TICKS); |
| 264 | 386 | expect(Math.hypot(runner.x, runner.y)).toBeLessThanOrEqual(ARENA_RADIUS + 1e-9); |
| 265 | 387 | |
| @@ -286,6 +408,34 @@ test("a stunned mote has no thruster until the stun runs out", () => { | ||
| 286 | 408 | expect(m.vx).toBeGreaterThan(0); |
| 287 | 409 | }); |
| 288 | 410 | |
| 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 | ||
| 289 | 439 | test("the bloom is mass fifty with no thruster, and the gate is the only way out", () => { |
| 290 | 440 | const w = createWorld({ seed: 9, gateAngle: 0 }); |
| 291 | 441 | expect(w.bloom.mass).toBe(50); |
src/world.ts +17 −5
| @@ -78,10 +78,15 @@ export const BLOOM_MASS = 50; | ||
| 78 | 78 | |
| 79 | 79 | /** Arriving at the rim faster than this scatters a body instead of beaching it. */ |
| 80 | 80 | 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; | |
| 85 | 90 | |
| 86 | 91 | /** Half-width of the gate arc: a 60° door on the rim. */ |
| 87 | 92 | export const GATE_HALF_ANGLE = Math.PI / 6; |
| @@ -295,6 +300,10 @@ function advance(state: World, b: Body): void { | ||
| 295 | 300 | } |
| 296 | 301 | // Heavier is slower to answer the sea: the bloom takes fifty times as long. |
| 297 | 302 | 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. | |
| 298 | 307 | let ax = DRAG * mobility * (ux - b.vx); |
| 299 | 308 | let ay = DRAG * mobility * (uy - b.vy); |
| 300 | 309 | if (state.tick >= b.stunnedUntil) { |
| @@ -322,7 +331,10 @@ function rim(state: World, b: Body): void { | ||
| 322 | 331 | b.vx = 0; |
| 323 | 332 | b.vy = 0; |
| 324 | 333 | } |
| 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. | |
| 326 | 338 | b.vx = -b.vx * SCATTER_KEEP; |
| 327 | 339 | b.vy = -b.vy * SCATTER_KEEP; |
| 328 | 340 | b.stunnedUntil = state.tick + 1 + STUN_TICKS; |