pike/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
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
2 files
changed +68
−3
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, 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"; | |
| 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, |
| @@ -167,7 +174,7 @@ function snapshotShape(state: MatchState, me: Player, seen: Player[]) { | ||
| 167 | 174 | revealed: p.revealed > 0, |
| 168 | 175 | })), |
| 169 | 176 | 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))) | |
| 171 | 178 | .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })), |
| 172 | 179 | spike: { |
| 173 | 180 | 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"); |