wren/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Merge main: pike's zone gating beside the sent speed
A rebase cannot be pushed here — force pushes are refused — so main comes in as a merge instead. The only conflict is the `./match.ts` import line in src/arena.ts, where #25 adds `PLAYER_R` for its `canSeeZone` helper and this branch adds `speedOf` for `me.speed`. Both are wanted, so both are imported; nothing else in `snapshotShape` overlaps. The resulting tree is identical to the rebase, and both mutations still bite: a constant speed on the client fails one test, dropping `speed` from the wire fails three. Co-Authored-By: Claude Opus 5 <[email protected]>
10 files
changed +298
−33
src/arena.ts +10 −3
| @@ -17,8 +17,9 @@ import { recordResult } from "./db.ts"; | ||
| 17 | 17 | import { makeArena, type Command, type Why } from "./engine/arena.ts"; |
| 18 | 18 | import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts"; |
| 19 | 19 | import type { Point } from "./map.ts"; |
| 20 | import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, setInput, speedOf, tick, visible } from "./match.ts"; | |
| 21 | import type { MatchState, Player } from "./state.ts"; | |
| 20 | import { wallBetween } from "./map.ts"; | |
| 21 | import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, PLAYER_R, setInput, speedOf, tick, visible } from "./match.ts"; | |
| 22 | import type { MatchState, Player, Zone } from "./state.ts"; | |
| 22 | 23 | import { isWeaponId, WEAPONS } from "./weapons.ts"; |
| 23 | 24 | |
| 24 | 25 | export { broadcastEvery, TICK, type Command, type Seat, type Why } from "./engine/arena.ts"; |
| @@ -113,6 +114,12 @@ function spectatorShape(state: MatchState, _why: Why) { | ||
| 113 | 114 | function snapshotShape(state: MatchState, me: Player, seen: Player[]) { |
| 114 | 115 | const agent = agentById(me.agent); |
| 115 | 116 | const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones); |
| 117 | // A zone is known to the viewer if they are standing in it (so your own | |
| 118 | // smoke never vanishes from under you) or, failing that, if no wall sits | |
| 119 | // between the two — the wall test only, not `sightBlocked`, which also | |
| 120 | // counts zones and would let a smoke hide the wall behind it. | |
| 121 | const canSeeZone = (z: Zone): boolean => | |
| 122 | Math.hypot(me.x - z.x, me.y - z.y) <= z.r + PLAYER_R || !wallBetween(me, z); | |
| 116 | 123 | |
| 117 | 124 | return { |
| 118 | 125 | phase: state.phase, |
| @@ -171,7 +178,7 @@ function snapshotShape(state: MatchState, me: Player, seen: Player[]) { | ||
| 171 | 178 | revealed: p.revealed > 0, |
| 172 | 179 | })), |
| 173 | 180 | zones: state.zones |
| 174 | .filter((z) => (z.kind === "trip" ? z.side === me.side : true)) | |
| 181 | .filter((z) => (z.kind === "trip" ? z.side === me.side : canSeeZone(z))) | |
| 175 | 182 | .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })), |
| 176 | 183 | spike: { |
| 177 | 184 | planted: state.spike.planted, |
src/server.test.ts +58
| @@ -7,6 +7,7 @@ process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); | ||
| 7 | 7 | process.env.PORT = "0"; |
| 8 | 8 | const { server } = await import("./server.ts"); |
| 9 | 9 | const { match, snapshot, advance, takeSeat, release, releaseAddress } = await import("./arena.ts"); |
| 10 | const { visible } = await import("./match.ts"); | |
| 10 | 11 | const base = `http://${server.hostname}:${server.port}`; |
| 11 | 12 | |
| 12 | 13 | test("serves a live match on the first paint, with no script needed", async () => { |
| @@ -158,6 +159,63 @@ test("a snapshot only carries the enemies that viewer can see", () => { | ||
| 158 | 159 | expect(JSON.stringify(far.board)).not.toContain('"x"'); |
| 159 | 160 | }); |
| 160 | 161 | |
| 162 | test("the whole snapshot carries no id or position of a body the viewer cannot see", () => { | |
| 163 | // Generic on purpose: this scans the whole stringified payload rather | |
| 164 | // than one named field, so it would have caught each of the three leaks | |
| 165 | // of this shape — a hidden body's id or coordinate reaching the wire | |
| 166 | // through some field other than `players` — and would catch a fourth. | |
| 167 | const state = match(); | |
| 168 | const viewer = state.players.find((p) => p.side === "atk")!; | |
| 169 | viewer.x = 1200; | |
| 170 | viewer.y = 1320; | |
| 171 | viewer.blind = 0; | |
| 172 | viewer.alive = true; | |
| 173 | const enemy = state.players.find((p) => p.side === "def")!; | |
| 174 | enemy.alive = true; | |
| 175 | enemy.revealed = 0; | |
| 176 | // A distinctive decimal, not a round number, so it cannot be mistaken for | |
| 177 | // an unrelated field (hp, credits, damage) that legitimately holds 200. | |
| 178 | enemy.x = 217.3; | |
| 179 | enemy.y = 233.9; | |
| 180 | expect(visible(state, viewer, enemy)).toBe(false); | |
| 181 | ||
| 182 | // A molly lands at the thrower's own feet — the shape any thrown ability | |
| 183 | // takes — planted on the enemy this viewer cannot see. | |
| 184 | const zoneId = state.nextZone++; | |
| 185 | state.zones.push({ | |
| 186 | id: zoneId, | |
| 187 | kind: "molly", | |
| 188 | owner: enemy.id, | |
| 189 | side: enemy.side, | |
| 190 | x: enemy.x, | |
| 191 | y: enemy.y, | |
| 192 | r: 40, | |
| 193 | life: 5, | |
| 194 | fuse: 0, | |
| 195 | beat: 0, | |
| 196 | pulses: 0, | |
| 197 | damage: 0, | |
| 198 | blocks: false, | |
| 199 | solid: false, | |
| 200 | }); | |
| 201 | ||
| 202 | const text = snapshot(viewer.id); | |
| 203 | const round2 = (n: number) => Math.round(n * 10) / 10; | |
| 204 | // A number counts only bare, not as a substring of a larger one — "217.3" | |
| 205 | // inside "1217.3" would be a false alarm, "217.3" on its own is the leak. | |
| 206 | const bare = (n: number) => new RegExp(String.raw`(?<![\d.-])${n}(?![\d.])`); | |
| 207 | // The scoreboard is a named, reviewed exception: it lists every id on | |
| 208 | // purpose (kills, deaths, credits for the whole match), never a position. | |
| 209 | // The id check walks everything else; the position check still walks the | |
| 210 | // scoreboard too, so a coordinate added there would still be caught. | |
| 211 | const { board: _board, ...rest } = JSON.parse(text) as Record<string, unknown>; | |
| 212 | expect(JSON.stringify(rest)).not.toContain(JSON.stringify(enemy.id)); | |
| 213 | expect(bare(round2(enemy.x)).test(text)).toBe(false); | |
| 214 | expect(bare(round2(enemy.y)).test(text)).toBe(false); | |
| 215 | ||
| 216 | state.zones = state.zones.filter((z) => z.id !== zoneId); | |
| 217 | }); | |
| 218 | ||
| 161 | 219 | test("a command from a seat plants the spike, and nonsense from anyone else does nothing", async () => { |
| 162 | 220 | const { command } = await import("./arena.ts"); |
| 163 | 221 | const { siteCentre } = await import("./map.ts"); |
README.md +5 −1
| @@ -65,7 +65,11 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. | ||
| 65 | 65 | - Your own body is *drawn* from a guess — the browser runs your own movement |
| 66 | 66 | forward from your own keys between snapshots, through the same |
| 67 | 67 | step-and-slide the server uses (`src/engine/glide.ts`), and carries the last |
| 68 | snapshot forward by up to a quarter second of held input. It is a drawing | |
| 68 | snapshot forward by up to a quarter second of held input. How fast it runs | |
| 69 | is the server's answer and not one the browser worked out: the snapshot | |
| 70 | carries `me.speed`, so a haste, an operator, a plant or a decided round | |
| 71 | reaches the drawing as a number — a rule that stops you arrives as a zero. | |
| 72 | It is a drawing | |
| 69 | 73 | optimisation only: nothing is sent, every snapshot leans the drawn body |
| 70 | 74 | back onto the server's answer, and a real disagreement is taken outright. |
| 71 | 75 | The lag your own keys take to reach the server (a send every 50ms, a hop, |
src/arena.test.ts +58
| @@ -64,3 +64,61 @@ test("a snapshot stays small enough to send thirty times a second", () => { | ||
| 64 | 64 | expect(bytes).toBeGreaterThan(200); |
| 65 | 65 | expect(bytes).toBeLessThan(8000); |
| 66 | 66 | }); |
| 67 | ||
| 68 | test("the snapshot carries the speed the server will move you at, not the reasons for it", () => { | |
| 69 | // The browser used to rebuild this rule from four fields and was right | |
| 70 | // only until a fifth was added. What it gets now is the answer: every | |
| 71 | // reason the match has to speed a body up, slow it down or hold it still, | |
| 72 | // resolved here, in units a second, to a tenth like every other number in | |
| 73 | // the snapshot. | |
| 74 | for (let i = 0; i < 40 && match().phase !== "live"; i++) advance(1); | |
| 75 | const state = match(); | |
| 76 | const me = state.players[0]!; | |
| 77 | const was = { ...me }; | |
| 78 | const phase = state.phase; | |
| 79 | const seen = () => JSON.parse(snapshot(me.id)).me as Record<string, unknown>; | |
| 80 | const speed = () => seen().speed; | |
| 81 | ||
| 82 | Object.assign(me, { alive: true, haste: 0, slow: 0, planting: 0, defusing: 0, weapon: "classic" }); | |
| 83 | expect(speed()).toBe(250); | |
| 84 | me.weapon = "operator"; | |
| 85 | expect(speed()).toBe(215); | |
| 86 | me.weapon = "classic"; | |
| 87 | me.haste = 2; | |
| 88 | expect(speed()).toBe(337.5); // exactly what the tick will use | |
| 89 | me.haste = 0; | |
| 90 | me.slow = 2; | |
| 91 | expect(speed()).toBe(137.5); | |
| 92 | me.slow = 0; | |
| 93 | ||
| 94 | // Every way the match holds a body still arrives as a zero, and the client | |
| 95 | // is told none of them: a fifth one would need no change over there. | |
| 96 | me.planting = 1; | |
| 97 | expect(speed()).toBe(0); | |
| 98 | me.planting = 0; | |
| 99 | me.defusing = 1; | |
| 100 | expect(speed()).toBe(0); | |
| 101 | me.defusing = 0; | |
| 102 | me.alive = false; | |
| 103 | expect(speed()).toBe(0); | |
| 104 | me.alive = true; | |
| 105 | // Including the phases whose tick returns before it moves anybody. | |
| 106 | for (const stopped of ["end", "over"] as const) { | |
| 107 | state.phase = stopped; | |
| 108 | expect(speed()).toBe(0); | |
| 109 | } | |
| 110 | state.phase = phase; | |
| 111 | expect(speed()).toBe(250); | |
| 112 | ||
| 113 | // The three fields that existed only to be re-derived are gone with it. | |
| 114 | const me2 = seen(); | |
| 115 | expect(me2).not.toHaveProperty("haste"); | |
| 116 | expect(me2).not.toHaveProperty("slow"); | |
| 117 | expect(me2).not.toHaveProperty("defusing"); | |
| 118 | // What it costs the wire: a name and a number, once a seat a tick. | |
| 119 | const field = `"speed":${speed()},`; | |
| 120 | expect(snapshot(me.id)).toContain(field); | |
| 121 | expect(field.length).toBeLessThanOrEqual(16); | |
| 122 | ||
| 123 | Object.assign(me, was); | |
| 124 | }); |
src/arena.ts +10 −6
| @@ -18,7 +18,7 @@ import { makeArena, type Command, type Why } from "./engine/arena.ts"; | ||
| 18 | 18 | import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts"; |
| 19 | 19 | import type { Point } from "./map.ts"; |
| 20 | 20 | import { wallBetween } from "./map.ts"; |
| 21 | import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, PLAYER_R, setInput, tick, visible } from "./match.ts"; | |
| 21 | import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, PLAYER_R, setInput, speedOf, tick, visible } from "./match.ts"; | |
| 22 | 22 | import type { MatchState, Player, Zone } from "./state.ts"; |
| 23 | 23 | import { isWeaponId, WEAPONS } from "./weapons.ts"; |
| 24 | 24 | |
| @@ -149,13 +149,17 @@ function snapshotShape(state: MatchState, me: Player, seen: Player[]) { | ||
| 149 | 149 | ult: me.ult, |
| 150 | 150 | ultNeeds: agent.abilities.x.points, |
| 151 | 151 | blind: round2(me.blind), |
| 152 | slow: round2(me.slow), | |
| 153 | haste: round2(me.haste), | |
| 154 | 152 | spike: me.spike, |
| 155 | 153 | planting: round2(me.planting), |
| 156 | // Your own two ways of being held still, so the browser's prediction | |
| 157 | // of your own body runs at the speed the server is about to use. | |
| 158 | defusing: me.defusing > 0, | |
| 154 | // How fast the server is about to move you, in units a second, and not | |
| 155 | // the reasons behind it: the browser's prediction runs at this number | |
| 156 | // and re-derives nothing. A rule that holds you still — planting, | |
| 157 | // defusing, a decided round — arrives as a zero. To a tenth, like every | |
| 158 | // other number here: rounded to a whole unit instead, a hasted walk | |
| 159 | // (337.5) is drawn a half-unit a second fast and clipped back by the | |
| 160 | // cap every frame, which measured 0.2 points of per-frame spread. A | |
| 161 | // tenth is a no-op for feel and costs at most two bytes. | |
| 162 | speed: round2(speedOf(state, me)), | |
| 159 | 163 | bot: me.bot, |
| 160 | 164 | }, |
| 161 | 165 | players: seen.map((p) => ({ |
src/client.test.ts +86
| @@ -525,3 +525,89 @@ test("the buy phase is predicted, and its pen is the server's own rule", async ( | ||
| 525 | 525 | expect(bundle).toContain("world, refused)"); |
| 526 | 526 | expect(bundle).toContain("outsidePen(x, y) || insideBarrier(x, y)"); |
| 527 | 527 | }); |
| 528 | ||
| 529 | test("the drawn body runs at the speed the server sent, and never at one it works out", async () => { | |
| 530 | // Two snapshots a client that re-derived the rule would read backwards: one | |
| 531 | // that says stopped with every visible reason to be running, and one that | |
| 532 | // says running with a visible reason to be stopped. The drawing follows the | |
| 533 | // number both times, which is the whole of what it knows about movement. | |
| 534 | const arena = await import("./arena.ts"); | |
| 535 | const { blockedCircle } = await import("./map.ts"); | |
| 536 | arena.stopArena(); | |
| 537 | const body = arena.match().players[0]!; | |
| 538 | const base = JSON.parse(arena.snapshot(body.id)); | |
| 539 | ||
| 540 | // Somewhere with a hundred units of room in one direction to walk into. | |
| 541 | const clear = ( | |
| 542 | [ | |
| 543 | ["KeyW", 0, -1], | |
| 544 | ["KeyS", 0, 1], | |
| 545 | ["KeyA", -1, 0], | |
| 546 | ["KeyD", 1, 0], | |
| 547 | ] as const | |
| 548 | ).find(([, dx, dy]) => [25, 50, 75, 100].every((step) => !blockedCircle(body.x + dx * step, body.y + dy * step, 16)))!; | |
| 549 | expect(clear).toBeDefined(); | |
| 550 | ||
| 551 | // The top-down view, where your own body is the last thing translated to. | |
| 552 | if ((nodes.get("stage")!.dataset as Record<string, string>).mode !== "top") press("KeyV"); | |
| 553 | expect((nodes.get("stage")!.dataset as Record<string, string>).mode).toBe("top"); | |
| 554 | for (const code of ["KeyW", "KeyA", "KeyS", "KeyD"]) release(code); | |
| 555 | press(clear[0]); | |
| 556 | ||
| 557 | const mine = (base.players as Record<string, unknown>[]).find((p) => p.id === base.me.id) ?? { | |
| 558 | id: base.me.id, | |
| 559 | name: body.name, | |
| 560 | agent: base.me.agent, | |
| 561 | angle: 0, | |
| 562 | hp: 100, | |
| 563 | armour: 0, | |
| 564 | mate: true, | |
| 565 | spike: false, | |
| 566 | blind: false, | |
| 567 | revealed: false, | |
| 568 | }; | |
| 569 | const scale = 900 / (30 * 80); // canvas pixels to a world unit, as above | |
| 570 | const deliver = (me: Record<string, unknown>) => { | |
| 571 | const snap = { | |
| 572 | ...base, | |
| 573 | phase: "live", | |
| 574 | zones: [], | |
| 575 | // Your own body is on the map as one of the bodies you can see, and it | |
| 576 | // is the one the arrow is drawn for. | |
| 577 | players: [{ ...mine, alive: true, x: body.x, y: body.y }], | |
| 578 | shots: [], | |
| 579 | me: { ...base.me, alive: true, x: body.x, y: body.y, planting: 0, ...me }, | |
| 580 | }; | |
| 581 | openedSocket!.onmessage!({ data: JSON.stringify(snap) } as MessageEvent); | |
| 582 | }; | |
| 583 | /** How far past the server's own word the body is drawn, after ten frames. */ | |
| 584 | const leadOf = (me: Record<string, unknown>) => { | |
| 585 | deliver(me); | |
| 586 | const t0 = performance.now(); | |
| 587 | runFrame(t0); // the first frame after a jump takes the server's word | |
| 588 | for (let i = 1; i <= 10; i++) { | |
| 589 | moves.length = 0; | |
| 590 | runFrame(t0 + i * 40); | |
| 591 | } | |
| 592 | const at = moves[moves.length - 1]!; | |
| 593 | return (at.x / scale - body.x) * clear[1] + (at.y / scale - body.y) * clear[2]; | |
| 594 | }; | |
| 595 | ||
| 596 | // Stopped, with nothing in the snapshot to say why: no planting, no | |
| 597 | // slow, a live round, a light weapon. A client working it out for itself | |
| 598 | // would run this body the whole quarter second. | |
| 599 | expect(leadOf({ speed: 0 })).toBeLessThan(1); | |
| 600 | // And moving, mid-plant — a reason the old client read as stopped. The | |
| 601 | // number is what counts, so the body walks. | |
| 602 | const walking = leadOf({ speed: 250, planting: 1 }); | |
| 603 | expect(walking).toBeGreaterThan(30); | |
| 604 | // And the number is the whole of it: half the speed, half the lead. The | |
| 605 | // cap on a guess is a length of time (predict.js LEAD_MAX), so how far | |
| 606 | // ahead the drawing may be is the server's speed and nothing else. | |
| 607 | const half = leadOf({ speed: 125 }); | |
| 608 | expect(half / walking).toBeGreaterThan(0.45); | |
| 609 | expect(half / walking).toBeLessThan(0.55); | |
| 610 | ||
| 611 | release(clear[0]); | |
| 612 | openedSocket?.close(); | |
| 613 | }); |
src/engine/glide.ts +4 −3
| @@ -112,9 +112,10 @@ export function slideCircle( | ||
| 112 | 112 | } |
| 113 | 113 | |
| 114 | 114 | /** |
| 115 | * How fast a body moves, given what is on it. The browser reads the same | |
| 116 | * four facts out of its snapshot that the match reads off a player, so a | |
| 117 | * prediction runs at the speed the server is about to use. | |
| 115 | * How fast a body moves, given what is on it. This is the server's alone: | |
| 116 | * the game decides here (`speedOf` in the match), and the snapshot hands the | |
| 117 | * browser the resulting number. Nothing in a client reads these four facts, | |
| 118 | * so a fifth one can be added here without desyncing anybody's prediction. | |
| 118 | 119 | */ |
| 119 | 120 | export function moveSpeed(state: { haste?: boolean; slow?: boolean; stopped?: boolean; heavy?: boolean }): number { |
| 120 | 121 | if (state.stopped) return 0; |
src/feel.test.ts +41 −6
| @@ -11,9 +11,9 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { expect, test } from "bun:test"; |
| 14 | import { BODY_R, moveSpeed, RUN } from "./engine/glide.ts"; | |
| 14 | import { BODY_R, RUN } from "./engine/glide.ts"; | |
| 15 | 15 | import { solidTile, TILE } from "./map.ts"; |
| 16 | import { newMatch, setInput, startRound, tick } from "./match.ts"; | |
| 16 | import { newMatch, setInput, speedOf, startRound, tick } from "./match.ts"; | |
| 17 | 17 | import { runBots } from "./bots.ts"; |
| 18 | 18 | import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "../static/engine/interp.js"; |
| 19 | 19 | import { makeLocal, stepLocal } from "../static/engine/predict.js"; |
| @@ -52,14 +52,19 @@ const spreadOf = (steps: number[]) => { | ||
| 52 | 52 | }; |
| 53 | 53 | |
| 54 | 54 | type Body = { id: string; x: number; y: number; angle: number }; |
| 55 | type Packet = { at: number; stamp: number; me: Body & { alive: boolean; phase: string }; bodies: Body[] }; | |
| 55 | type Packet = { at: number; stamp: number; me: Body & { alive: boolean; phase: string; speed: number }; bodies: Body[] }; | |
| 56 | 56 | |
| 57 | 57 | /** |
| 58 | 58 | * A person walking east across open ground for two seconds, sending intent |
| 59 | 59 | * every 50ms, hearing back thirty times a second over a jittery wire, and |
| 60 | 60 | * drawn at sixty frames — with the input reaching the server one way late. |
| 61 | * | |
| 62 | * The packet carries `speed` the way the arena's snapshot does — the match's | |
| 63 | * own answer, to a tenth — and the drawing uses that and nothing else, which | |
| 64 | * is what val.js does. `on` is whatever the walk is carrying: a haste, an | |
| 65 | * operator, the states whose speed is not the plain run. | |
| 61 | 66 | */ |
| 62 | function walkOwnBody(base = 0.02) { | |
| 67 | function walkOwnBody(base = 0.02, on: Partial<{ haste: number; slow: number; weapon: string }> = {}) { | |
| 63 | 68 | const random = rng(7); |
| 64 | 69 | const state = newMatch(11); |
| 65 | 70 | const me = state.players[0]!; |
| @@ -76,6 +81,7 @@ function walkOwnBody(base = 0.02) { | ||
| 76 | 81 | me.haste = 0; |
| 77 | 82 | me.slow = 0; |
| 78 | 83 | me.weapon = "classic"; |
| 84 | Object.assign(me, on); | |
| 79 | 85 | const packets: Packet[] = []; |
| 80 | 86 | const inputs: { at: number; mx: number; my: number }[] = []; |
| 81 | 87 | const held = { mx: 0, my: 0 }; |
| @@ -101,7 +107,15 @@ function walkOwnBody(base = 0.02) { | ||
| 101 | 107 | packets.push({ |
| 102 | 108 | at: t + base + jitter(random), |
| 103 | 109 | stamp: 0, |
| 104 | me: { id: me.id, x: round2(me.x), y: round2(me.y), angle: round2(me.angle), alive: me.alive, phase: state.phase }, | |
| 110 | me: { | |
| 111 | id: me.id, | |
| 112 | x: round2(me.x), | |
| 113 | y: round2(me.y), | |
| 114 | angle: round2(me.angle), | |
| 115 | alive: me.alive, | |
| 116 | phase: state.phase, | |
| 117 | speed: round2(speedOf(state, me)), | |
| 118 | }, | |
| 105 | 119 | bodies: [], |
| 106 | 120 | }); |
| 107 | 121 | } |
| @@ -125,7 +139,9 @@ function walkOwnBody(base = 0.02) { | ||
| 125 | 139 | if (!snap) continue; |
| 126 | 140 | held.mx = now >= walkFrom && now < walkUntil ? 1 : 0; |
| 127 | 141 | const before = local.anchored; |
| 128 | stepLocal(local, snap.me, held, moveSpeed({}), now - snap.stamp, 1 / 60, world, null); | |
| 142 | // The speed the server sent, exactly as val.js passes it: no rule about | |
| 143 | // movement is re-derived on this side of the wire. | |
| 144 | stepLocal(local, snap.me, held, snap.me.speed, now - snap.stamp, 1 / 60, world, null); | |
| 129 | 145 | // The first word about where you are is taken outright: that one is not a jump. |
| 130 | 146 | if (before > 0) anchors += local.anchored - before; |
| 131 | 147 | if (f > 0 && previous !== 0 && now > walkFrom + 0.2 && now < walkUntil - 0.05) { |
| @@ -155,6 +171,25 @@ test("your own body, walking, takes even steps and never a backward one", () => | ||
| 155 | 171 | expect(anchors).toBe(0); |
| 156 | 172 | }); |
| 157 | 173 | |
| 174 | test("a hasted walk is drawn at the speed the wire carried, rounding and all", () => { | |
| 175 | // Haste is the case where the match's own answer is not a whole number: | |
| 176 | // 250 × 1.35 is 337.5, and the wire carries it to a tenth, like every other | |
| 177 | // number in the snapshot. What is checked is that the drawing runs at the | |
| 178 | // speed the server will use — no correction, no backward step, and no body | |
| 179 | // drawn out past where the server is about to put it. | |
| 180 | const { steps, back, anchors, local, server } = walkOwnBody(0.02, { haste: 10 }); | |
| 181 | const { mean, spread } = spreadOf(steps); | |
| 182 | console.log( | |
| 183 | `hasted body: ${steps.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` + | |
| 184 | `largest backward step ${back.toFixed(2)}, anchors ${anchors}, drawn ${(local.x - server.x).toFixed(1)} past the server`, | |
| 185 | ); | |
| 186 | expect(mean * 60).toBeGreaterThan(RUN * 1.35 * 0.9); | |
| 187 | expect(mean * 60).toBeLessThan(RUN * 1.35 * 1.1); | |
| 188 | expect(spread).toBeLessThan(0.05); | |
| 189 | expect(back).toBeGreaterThan(-0.01); | |
| 190 | expect(anchors).toBe(0); | |
| 191 | }); | |
| 192 | ||
| 158 | 193 | test("starting and stopping never pulls you back, and the speed never dips a third", () => { |
| 159 | 194 | // The first tenth of a second after the key goes down the server has not |
| 160 | 195 | // heard yet, and the guess used to be yanked back to the server's still |
src/match.ts +18 −4
| @@ -26,8 +26,10 @@ import { | ||
| 26 | 26 | import { emptyInput, type Input, type MatchState, type Player, type Side, type Slot, type Team } from "./state.ts"; |
| 27 | 27 | import { applyDamage, damage, shotSpread, SHIELD, WEAPONS, zoneFromMiss, type WeaponId } from "./weapons.ts"; |
| 28 | 28 | |
| 29 | // A body's size and its speed are shared with the browser, which predicts | |
| 30 | // your own movement between snapshots: `src/engine/glide.ts` is the one copy. | |
| 29 | // A body's size and the step-and-slide are shared with the browser, which | |
| 30 | // predicts your own movement between snapshots: `src/engine/glide.ts` is the | |
| 31 | // one copy. How fast that body moves is decided here alone, by `speedOf`, | |
| 32 | // and sent as a number — see the note on it. | |
| 31 | 33 | export const PLAYER_R = BODY_R; |
| 32 | 34 | export const RUN_SPEED = RUN; |
| 33 | 35 | export const BUY_TIME = 14; |
| @@ -359,7 +361,19 @@ export function tick(state: MatchState, dt: number): void { | ||
| 359 | 361 | endConditions(state); |
| 360 | 362 | } |
| 361 | 363 | |
| 362 | function speedOf(player: Player): number { | |
| 364 | /** | |
| 365 | * How fast this player moves right now — every reason the match has to speed | |
| 366 | * a body up, slow it down or hold it still, in one number. | |
| 367 | * | |
| 368 | * The browser used to rebuild this from four snapshot fields, which was this | |
| 369 | * function written twice and correct only until a fifth reason was added. | |
| 370 | * The snapshot carries the answer instead (`me.speed` in `src/arena.ts`), so | |
| 371 | * a new rule that stops you arrives as a zero and the prediction stops with | |
| 372 | * it without knowing why. Add a reason here and nothing else needs telling. | |
| 373 | */ | |
| 374 | export function speedOf(state: MatchState, player: Player): number { | |
| 375 | // The phases whose tick returns before it moves anybody, and the dead. | |
| 376 | if (state.phase === "end" || state.phase === "over" || !player.alive) return 0; | |
| 363 | 377 | return moveSpeed({ |
| 364 | 378 | haste: player.haste > 0, |
| 365 | 379 | slow: player.slow > 0, |
| @@ -379,7 +393,7 @@ function movePlayer(state: MatchState, player: Player, dt: number, penned: boole | ||
| 379 | 393 | mx /= len; |
| 380 | 394 | my /= len; |
| 381 | 395 | } |
| 382 | const speed = speedOf(player) * dt; | |
| 396 | const speed = speedOf(state, player) * dt; | |
| 383 | 397 | if (speed === 0 || (mx === 0 && my === 0)) return; |
| 384 | 398 | let next = moveCircle(player.x, player.y, mx * speed, my * speed, PLAYER_R); |
| 385 | 399 | // In the buy phase nobody leaves their own spawn. |
static/val.js +8 −10
| @@ -15,7 +15,7 @@ import { banner, drawBanner } from "./banner.js"; | ||
| 15 | 15 | import { makeBeeper } from "./beep.js"; |
| 16 | 16 | import { makeBudget, measure } from "./engine/budget.js"; |
| 17 | 17 | import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "./engine/interp.js"; |
| 18 | import { BODY_R, moveSpeed } from "../src/engine/glide.ts"; | |
| 18 | import { BODY_R } from "../src/engine/glide.ts"; | |
| 19 | 19 | import { drawGun, gunFire, gunSnapshot, gunTick, makeGun } from "./engine/gun.js"; |
| 20 | 20 | import { makeLocal, stepLocal } from "./engine/predict.js"; |
| 21 | 21 | import { forget, ghosts, makePresence, shown, sighted, slide } from "./engine/presence.js"; |
| @@ -418,15 +418,13 @@ function predict(dt, now) { | ||
| 418 | 418 | return; |
| 419 | 419 | } |
| 420 | 420 | const held = readIntent(); |
| 421 | const speed = moveSpeed({ | |
| 422 | haste: snap.me.haste > 0, | |
| 423 | slow: snap.me.slow > 0, | |
| 424 | // Nobody moves once a round is decided: the server's tick returns before | |
| 425 | // it moves anybody, and a guess that kept walking ran a quarter second | |
| 426 | // into nothing and waited there for the next round to cut back. | |
| 427 | stopped: snap.me.planting > 0 || snap.me.defusing || snap.phase === "end" || snap.phase === "over", | |
| 428 | heavy: snap.me.weapon === "operator", | |
| 429 | }); | |
| 421 | // The speed the server is about to move you at, as it sent it. This was | |
| 422 | // rebuilt here out of four snapshot fields — haste, slow, holding still to | |
| 423 | // plant or defuse, the weight of an operator, the phases where nobody | |
| 424 | // moves — which was the server's rule written twice and right only until | |
| 425 | // somebody added a fifth. Now a rule that stops you arrives as a zero and | |
| 426 | // the guess stops with it, with no knowledge here of why. | |
| 427 | const speed = snap.me.speed; | |
| 430 | 428 | // The snapshot is a moment old and the keys held since have not reached |
| 431 | 429 | // it; carry it that far forward and lean the drawn body onto it. Walking |
| 432 | 430 | // out of the spawn pen, or into a barrier, is refused by the server |