pike/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine

Close the spectator-eyes hole and stop the engine naming val

snapshotFor fell back to players[0] for an unknown viewer id, which
would have handed that viewer somebody else's position and vision;
it now returns the same spectator view a seatless connection gets.
Added a test that fails against the old fallback.

The engine also carried val's vocabulary: aim.ts cited weapons.ts and
a Vandal, predict.js explained a hold with the buy-phase spawn pen,
predict.test.ts mentioned planting/defusing, and Seat.board was named
after val's own sticky field. Comments now describe the mechanism, and
boundary.test.ts's forbidden-word list is read from src/words.json —
data the game supplies, not a hardcoded regex of val's nouns — so a
copied template starts with no guard until it writes its own list.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Pike committed 67215f7 Browse files
8 files changed +50 −24
src/engine/aim.ts +5 −4
@@ -2,9 +2,10 @@
22 * How a bot points a gun.
33 *
44 * The shot itself is shared with people — weapon spread, the move penalty,
5 * the spray, the blind wobble all live in `weapons.ts` and apply to everyone
6 * the same way. What lives here is the part a person is bad at and a bot was
7 * not: seeing somebody, and getting the barrel onto them.
5 * the spray, the blind wobble all live in the game's own weapon rules and
6 * apply to everyone the same way. What lives here is the part a person is
7 * bad at and a bot was not: seeing somebody, and getting the barrel onto
8 * them.
89 *
910 * A hold on a target has three honest costs. It takes a moment to notice
1011 * (`reaction`), the barrel starts somewhere wrong and only settles the longer
@@ -44,7 +45,7 @@ export const REACTION_MAX = 0.35;
4445 export const FORGET = 0.4;
4546 /** Seconds for the cone to fall by 1/e while the same target is held. */
4647 export const SETTLE = 0.8;
47 /** The cone a bot settles to: under a Vandal's own spread, never zero. */
48 /** The cone a bot settles to: under the weapon's own spread, never zero. */
4849 export const CONE_FLOOR = 0.008;
4950 /** What the cone opens to the instant a target is acquired (~6°). */
5051 export const CONE_SWING = 0.105;
src/engine/arena.test.ts +10 −1
@@ -103,6 +103,15 @@ test("a viewer is sent only the bodies the game says they can see", () => {
103103 expect(JSON.stringify(snap.players)).not.toContain('"b1"');
104104 });
105105
106 test("an unknown viewer gets the spectator view, never another player's eyes", () => {
107 const arena = toyGame();
108 const snap = JSON.parse(arena.snapshot("nobody-here")) as Record<string, unknown>;
109 expect(snap).toMatchObject({ t: "s", spectator: true });
110 expect(snap.me).toBeUndefined();
111 expect(snap.players).toBeUndefined();
112 expect(JSON.stringify(snap)).not.toContain('"x"');
113 });
114
106115 test("a spectator is sent the public state and never a position", () => {
107116 const arena = toyGame();
108117 const got: string[] = [];
@@ -118,7 +127,7 @@ test("a spectator is sent the public state and never a position", () => {
118127
119128 test("the sticky field goes out once, then only when it changes", () => {
120129 const arena = toyGame();
121 const seat = { playerId: "a1", token: "t", address: "10.0.0.1", send: () => {}, board: "" };
130 const seat = { playerId: "a1", token: "t", address: "10.0.0.1", send: () => {}, stickyCache: "" };
122131 const first = JSON.parse(arena.snapshotForSeat(seat)) as { board?: unknown[] };
123132 expect(first.board?.length).toBe(4);
124133 const second = JSON.parse(arena.snapshotForSeat(seat)) as { board?: unknown[] };
src/engine/arena.ts +9 −6
@@ -38,7 +38,7 @@ export type Seat = {
3838 address: string;
3939 send: (data: string) => void;
4040 /** The sticky part of the snapshot as this seat last received it, so it is only resent when it changes. */
41 board: string;
41 stickyCache: string;
4242 };
4343
4444 /**
@@ -146,13 +146,13 @@ export function makeArena<S, P extends Body>(game: Game<S, P>) {
146146 if (!seat) return null;
147147 seat.name = freeName(name, seat.id);
148148 addressSeats.set(address, session);
149 seats.set(session, { playerId: seat.id, token: "", address, send: () => {}, board: "" });
149 seats.set(session, { playerId: seat.id, token: "", address, send: () => {}, stickyCache: "" });
150150 return seat;
151151 }
152152
153153 /** The socket that actually opened fills in how to reach it and its token. */
154154 function attach(session: string, playerId: string, address: string, token: string, send: (data: string) => void): void {
155 seats.set(session, { playerId, token, address, send, board: "" });
155 seats.set(session, { playerId, token, address, send, stickyCache: "" });
156156 }
157157
158158 const stillHeld = (playerId: string): boolean => [...seats.values()].some((s) => s.playerId === playerId);
@@ -216,8 +216,11 @@ export function makeArena<S, P extends Body>(game: Game<S, P>) {
216216 // ----------------------------------------------------------- the snapshot
217217
218218 function snapshotFor(viewerId: string): Record<string, unknown> {
219 const me = findPlayer(viewerId);
220 // No seat, no eyes: an id that names nobody gets exactly what a seatless
221 // connection already gets, never another player's vision by accident.
222 if (!me) return { t: "s", spectator: true, why: "unsaid", ...game.spectatorSnapshot(state, "unsaid") };
219223 const players = game.players(state);
220 const me = findPlayer(viewerId) ?? players[0]!;
221224 const seen = players.filter((p) => game.visible(state, me, p));
222225 return { t: "s", ...game.snapshot(state, me, seen) };
223226 }
@@ -234,8 +237,8 @@ export function makeArena<S, P extends Body>(game: Game<S, P>) {
234237 const full = snapshotFor(seat.playerId);
235238 if (game.sticky !== null) {
236239 const part = JSON.stringify(full[game.sticky]);
237 if (part === seat.board) delete full[game.sticky];
238 else seat.board = part;
240 if (part === seat.stickyCache) delete full[game.sticky];
241 else seat.stickyCache = part;
239242 }
240243 return JSON.stringify(full);
241244 }
src/engine/boundary.test.ts +16 −5
@@ -9,12 +9,21 @@
99 */
1010
1111 import { expect, test } from "bun:test";
12 import { readdirSync, readFileSync } from "node:fs";
12 import { existsSync, readdirSync, readFileSync } from "node:fs";
1313 import { dirname, join, resolve } from "node:path";
1414
1515 const ROOT = resolve(import.meta.dir, "..", "..");
1616 const ENGINE = [join(ROOT, "src", "engine"), join(ROOT, "static", "engine")];
1717
18 /**
19 * The game's own nouns, read as data rather than imported as code — an
20 * import from here would itself be a crossing the test below refuses. A
21 * copy of the engine that lands in a fresh game starts with no such file
22 * and so no such guard, until that game writes one down.
23 */
24 const WORDS_FILE = join(ROOT, "src", "words.json");
25 const gameWords: string[] = existsSync(WORDS_FILE) ? JSON.parse(readFileSync(WORDS_FILE, "utf8")) : [];
26
1827 const inEngine = (path: string): boolean => ENGINE.some((dir) => resolve(path).startsWith(dir + "/"));
1928
2029 /** Every import specifier in a file: static, re-exported, or dynamic. */
@@ -70,10 +79,12 @@ test("no engine module, and no engine test, imports a game module", () => {
7079 expect(crossings).toEqual([]);
7180 });
7281
73 test("the engine does not know what a spike is", () => {
74 // The words of this game. An engine file that says one of them has a hole
75 // that should have been a parameter.
76 const words = /\b(spike|agent|credits|molly|defus\w*|plant\w*|orb|valorant)\b/i;
82 test.skipIf(gameWords.length === 0)("the engine does not know what this game calls things", () => {
83 // The words are the game's own, from src/words.json, never hardcoded here:
84 // a game that legitimately has a "spike" should not inherit a guard against
85 // a game it has never played. An engine file that says one of them has a
86 // hole that should have been a parameter.
87 const words = new RegExp(`\\b(${gameWords.join("|")})\\b`, "i");
7788 const said: string[] = [];
7889 for (const file of files) {
7990 if (file.endsWith(".test.ts")) continue;
src/engine/predict.test.ts +1 −1
@@ -55,7 +55,7 @@ test("a frame of movement is the speed it was given, in the direction it was giv
5555 const diagonal = anchor(makeLocal(), { x: 1200, y: 640 });
5656 advance(diagonal, 1, 1, 250, 1 / 50, world);
5757 expect(Math.hypot(diagonal.x - 1200, diagonal.y - 640)).toBeCloseTo(5, 6);
58 // Standing still, planted, or stopped to defuse: nothing moves.
58 // Standing still, or moving at a speed the game has reduced to zero: nothing moves.
5959 const still = anchor(makeLocal(), { x: 1200, y: 640 });
6060 advance(still, 0, 0, 250, 1 / 50, world);
6161 advance(still, 1, 0, moveSpeed({ stopped: true }), 1 / 50, world);
src/server.test.ts +1 −1
@@ -267,7 +267,7 @@ test("the scoreboard is sent when it changes and left out when it has not", asyn
267267 const { snapshotForSeat } = await import("./arena.ts");
268268 const state = match();
269269 const player = state.players[0]!;
270 const seat = { playerId: player.id, token: "t", address: "198.51.100.200", send: () => {}, board: "" };
270 const seat = { playerId: player.id, token: "t", address: "198.51.100.200", send: () => {}, stickyCache: "" };
271271 const first = JSON.parse(snapshotForSeat(seat)) as { board?: unknown[]; last?: unknown };
272272 expect(first.board?.length).toBe(10);
273273 const second = JSON.parse(snapshotForSeat(seat)) as { board?: unknown[] };
src/words.json added +1
@@ -0,0 +1,1 @@
1 ["spike", "agent", "credits", "molly", "defus\\w*", "plant\\w*", "orb", "valorant"]
static/engine/predict.js +7 −6
@@ -162,12 +162,13 @@ export const insideWall = (local, world) => circleBlocked(local.x, local.y, worl
162162 * onto that, advanced a frame, and never drawn past it.
163163 *
164164 * A lean across a corner can put the drawn body inside a wall, and `refused`
165 * is the caller's word for anywhere else the server would not let you stand
166 * (the spawn pen in the buy phase). Both used to take the server's answer,
167 * which is a jump: pressed against a wall and strafing, that fired most
168 * frames and read as a stutter. Holding the last good place is invisible and
169 * just as honest — the next snapshot corrects it anyway. Anchoring is kept
170 * for the one case where holding still leaves the body in a wall.
165 * is the caller's word for anywhere else the server would not let you stand —
166 * a rule the game itself raises and drops, not a wall on the map. Both used
167 * to take the server's answer, which is a jump: pressed against a wall and
168 * strafing, that fired most frames and read as a stutter. Holding the last
169 * good place is invisible and just as honest — the next snapshot corrects it
170 * anyway. Anchoring is kept for the one case where holding still leaves the
171 * body in a wall.
171172 */
172173 export function stepLocal(local, me, held, speed, age, dt, world, refused) {
173174 const target = project(me, held.mx, held.my, speed, age, world);
↑↓ navigate ↵ open esc close

Keyboard shortcuts