rook/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]>
Wren committed c8ea83f merge Browse files
10 files changed +298 −33
src/arena.ts +10 −3
@@ -17,8 +17,9 @@ import { recordResult } from "./db.ts";
1717 import { makeArena, type Command, type Why } from "./engine/arena.ts";
1818 import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts";
1919 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";
2223 import { isWeaponId, WEAPONS } from "./weapons.ts";
2324
2425 export { broadcastEvery, TICK, type Command, type Seat, type Why } from "./engine/arena.ts";
@@ -113,6 +114,12 @@ function spectatorShape(state: MatchState, _why: Why) {
113114 function snapshotShape(state: MatchState, me: Player, seen: Player[]) {
114115 const agent = agentById(me.agent);
115116 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);
116123
117124 return {
118125 phase: state.phase,
@@ -171,7 +178,7 @@ function snapshotShape(state: MatchState, me: Player, seen: Player[]) {
171178 revealed: p.revealed > 0,
172179 })),
173180 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)))
175182 .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })),
176183 spike: {
177184 planted: state.spike.planted,
src/server.test.ts +58
@@ -7,6 +7,7 @@ process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
77 process.env.PORT = "0";
88 const { server } = await import("./server.ts");
99 const { match, snapshot, advance, takeSeat, release, releaseAddress } = await import("./arena.ts");
10 const { visible } = await import("./match.ts");
1011 const base = `http://${server.hostname}:${server.port}`;
1112
1213 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", () => {
158159 expect(JSON.stringify(far.board)).not.toContain('"x"');
159160 });
160161
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
161219 test("a command from a seat plants the spike, and nonsense from anyone else does nothing", async () => {
162220 const { command } = await import("./arena.ts");
163221 const { siteCentre } = await import("./map.ts");
README.md +5 −1
@@ -65,7 +65,11 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
6565 - Your own body is *drawn* from a guess — the browser runs your own movement
6666 forward from your own keys between snapshots, through the same
6767 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
6973 optimisation only: nothing is sent, every snapshot leans the drawn body
7074 back onto the server's answer, and a real disagreement is taken outright.
7175 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", () => {
6464 expect(bytes).toBeGreaterThan(200);
6565 expect(bytes).toBeLessThan(8000);
6666 });
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";
1818 import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts";
1919 import type { Point } from "./map.ts";
2020 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";
2222 import type { MatchState, Player, Zone } from "./state.ts";
2323 import { isWeaponId, WEAPONS } from "./weapons.ts";
2424
@@ -149,13 +149,17 @@ function snapshotShape(state: MatchState, me: Player, seen: Player[]) {
149149 ult: me.ult,
150150 ultNeeds: agent.abilities.x.points,
151151 blind: round2(me.blind),
152 slow: round2(me.slow),
153 haste: round2(me.haste),
154152 spike: me.spike,
155153 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)),
159163 bot: me.bot,
160164 },
161165 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 (
525525 expect(bundle).toContain("world, refused)");
526526 expect(bundle).toContain("outsidePen(x, y) || insideBarrier(x, y)");
527527 });
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(
112112 }
113113
114114 /**
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.
118119 */
119120 export function moveSpeed(state: { haste?: boolean; slow?: boolean; stopped?: boolean; heavy?: boolean }): number {
120121 if (state.stopped) return 0;
src/feel.test.ts +41 −6
@@ -11,9 +11,9 @@
1111 */
1212
1313 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";
1515 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";
1717 import { runBots } from "./bots.ts";
1818 import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "../static/engine/interp.js";
1919 import { makeLocal, stepLocal } from "../static/engine/predict.js";
@@ -52,14 +52,19 @@ const spreadOf = (steps: number[]) => {
5252 };
5353
5454 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[] };
5656
5757 /**
5858 * A person walking east across open ground for two seconds, sending intent
5959 * every 50ms, hearing back thirty times a second over a jittery wire, and
6060 * 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.
6166 */
62 function walkOwnBody(base = 0.02) {
67 function walkOwnBody(base = 0.02, on: Partial<{ haste: number; slow: number; weapon: string }> = {}) {
6368 const random = rng(7);
6469 const state = newMatch(11);
6570 const me = state.players[0]!;
@@ -76,6 +81,7 @@ function walkOwnBody(base = 0.02) {
7681 me.haste = 0;
7782 me.slow = 0;
7883 me.weapon = "classic";
84 Object.assign(me, on);
7985 const packets: Packet[] = [];
8086 const inputs: { at: number; mx: number; my: number }[] = [];
8187 const held = { mx: 0, my: 0 };
@@ -101,7 +107,15 @@ function walkOwnBody(base = 0.02) {
101107 packets.push({
102108 at: t + base + jitter(random),
103109 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 },
105119 bodies: [],
106120 });
107121 }
@@ -125,7 +139,9 @@ function walkOwnBody(base = 0.02) {
125139 if (!snap) continue;
126140 held.mx = now >= walkFrom && now < walkUntil ? 1 : 0;
127141 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);
129145 // The first word about where you are is taken outright: that one is not a jump.
130146 if (before > 0) anchors += local.anchored - before;
131147 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", () =>
155171 expect(anchors).toBe(0);
156172 });
157173
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
158193 test("starting and stopping never pulls you back, and the speed never dips a third", () => {
159194 // The first tenth of a second after the key goes down the server has not
160195 // 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 {
2626 import { emptyInput, type Input, type MatchState, type Player, type Side, type Slot, type Team } from "./state.ts";
2727 import { applyDamage, damage, shotSpread, SHIELD, WEAPONS, zoneFromMiss, type WeaponId } from "./weapons.ts";
2828
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.
3133 export const PLAYER_R = BODY_R;
3234 export const RUN_SPEED = RUN;
3335 export const BUY_TIME = 14;
@@ -359,7 +361,19 @@ export function tick(state: MatchState, dt: number): void {
359361 endConditions(state);
360362 }
361363
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;
363377 return moveSpeed({
364378 haste: player.haste > 0,
365379 slow: player.slow > 0,
@@ -379,7 +393,7 @@ function movePlayer(state: MatchState, player: Player, dt: number, penned: boole
379393 mx /= len;
380394 my /= len;
381395 }
382 const speed = speedOf(player) * dt;
396 const speed = speedOf(state, player) * dt;
383397 if (speed === 0 || (mx === 0 && my === 0)) return;
384398 let next = moveCircle(player.x, player.y, mx * speed, my * speed, PLAYER_R);
385399 // In the buy phase nobody leaves their own spawn.
static/val.js +8 −10
@@ -15,7 +15,7 @@ import { banner, drawBanner } from "./banner.js";
1515 import { makeBeeper } from "./beep.js";
1616 import { makeBudget, measure } from "./engine/budget.js";
1717 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";
1919 import { drawGun, gunFire, gunSnapshot, gunTick, makeGun } from "./engine/gun.js";
2020 import { makeLocal, stepLocal } from "./engine/predict.js";
2121 import { forget, ghosts, makePresence, shown, sighted, slide } from "./engine/presence.js";
@@ -418,15 +418,13 @@ function predict(dt, now) {
418418 return;
419419 }
420420 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;
430428 // The snapshot is a moment old and the keys held since have not reached
431429 // it; carry it that far forward and lean the drawn body onto it. Walking
432430 // out of the spawn pen, or into a barrier, is refused by the server
↑↓ navigate ↵ open esc close

Keyboard shortcuts