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

Merge pull request #27 from pike/25-a-zone-s-position-is-sent-to-people-who

A zone's position is sent to people who cannot see it
benjivers committed 884b6d5 merge Browse files
2 files changed +68 −3
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, 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, 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,
@@ -167,7 +174,7 @@ function snapshotShape(state: MatchState, me: Player, seen: Player[]) {
167174 revealed: p.revealed > 0,
168175 })),
169176 zones: state.zones
170 .filter((z) => (z.kind === "trip" ? z.side === me.side : true))
177 .filter((z) => (z.kind === "trip" ? z.side === me.side : canSeeZone(z)))
171178 .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })),
172179 spike: {
173180 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");
↑↓ navigate ↵ open esc close

Keyboard shortcuts