quill/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Close the two holes that made enemies visible across the map
The reported symptom was real: snapshots carried enemies they should not. Two causes, both fixed here. Mid was two straight one-tile lanes from spawn to spawn, so fifteen of the sixty-four spawn pairs had a clear sight line and a buy phase showed you half the enemy team standing still. Rows 13 and 14 of the grid now stagger against the box at rows 10-11, leaving a chicane: no spawn sees a spawn, every tile is still reachable, and attacks now reach a site — a headless match plants 23 times in 23 rounds where it managed 7 before. A dead player was handed every enemy position outright. They now watch through their living team mates' eyes: what one of them can see, and nothing else, so staying connected after dying is not a wallhack. Three tests pin it: a wall between two players hides them and open floor does not, no spawn-to-spawn pair has a sight line, and a dead viewer loses the enemy the moment their last team mate with a line on it does. Co-Authored-By: Claude Opus 5 <[email protected]>
5 files
changed +78
−9
README.md +6 −3
| @@ -19,9 +19,12 @@ server: | ||
| 19 | 19 | a Vandal head shot kills through full armour at any range, a Phantom's |
| 20 | 20 | does not past fifteen metres. |
| 21 | 21 | - **Line of sight.** A snapshot only carries the enemies that viewer can |
| 22 | see. A smoke blocks that sight line, a flash blinds by the angle you were | |
| 23 | facing, and a blinded player is sent no enemies at all. A patched client | |
| 24 | learns nothing a plain one does not. | |
| 22 | see. A wall of the map blocks the sight line, a smoke blocks it, a flash | |
| 23 | blinds by the angle you were facing, and a blinded player is sent no | |
| 24 | enemies at all. A dead player watches through their living team mates' | |
| 25 | eyes rather than through walls, so dying is not a wallhack to call out. | |
| 26 | No spawn can see the other spawn: mid is staggered on purpose. A patched | |
| 27 | client learns nothing a plain one does not. | |
| 25 | 28 | - **The spike.** 100s round, 4s plant, 45s fuse, 7s defuse with a |
| 26 | 29 | checkpoint at 3.5s — a defender killed past half way leaves the defuse |
| 27 | 30 | half done for the next one. |
src/map.ts +2 −2
| @@ -28,8 +28,8 @@ const ROWS_SRC = [ | ||
| 28 | 28 | "#..####..####.##.####..####..#", |
| 29 | 29 | "#........####.##.####........#", |
| 30 | 30 | "#..####..####....####..####..#", |
| 31 | "#..####..####....####..####..#", | |
| 32 | "#..####..####....####..####..#", | |
| 31 | "#..####..#####..#####..####..#", | |
| 32 | "#..####..#####..#####..####..#", | |
| 33 | 33 | "#..####..###......###..####..#", |
| 34 | 34 | "#........##..aaaa..##........#", |
| 35 | 35 | "#............aaaa............#", |
src/match.test.ts +54 −1
| @@ -16,7 +16,7 @@ import { | ||
| 16 | 16 | visible, |
| 17 | 17 | WIN_ROUNDS, |
| 18 | 18 | } from "./match.ts"; |
| 19 | import { siteCentre } from "./map.ts"; | |
| 19 | import { siteCentre, spawnCells, wallBetween } from "./map.ts"; | |
| 20 | 20 | import type { MatchState, Player } from "./state.ts"; |
| 21 | 21 | |
| 22 | 22 | const live = (seed = 5): MatchState => { |
| @@ -271,6 +271,59 @@ test("line of sight is the server's answer: a smoke hides an enemy in the open", | ||
| 271 | 271 | expect(visible(state, viewer, enemy)).toBe(true); |
| 272 | 272 | }); |
| 273 | 273 | |
| 274 | test("a wall hides two players from each other, and open floor does not", () => { | |
| 275 | const state = live(); | |
| 276 | const viewer = atk(state)[0]!; | |
| 277 | const enemy = def(state)[0]!; | |
| 278 | viewer.blind = 0; | |
| 279 | enemy.revealed = 0; | |
| 280 | // Either side of the block between A site and the middle of the map. | |
| 281 | viewer.x = 400; | |
| 282 | viewer.y = 400; | |
| 283 | enemy.x = 1200; | |
| 284 | enemy.y = 400; | |
| 285 | expect(wallBetween(viewer, enemy)).toBe(true); | |
| 286 | expect(visible(state, viewer, enemy)).toBe(false); | |
| 287 | // The same two down the open back corridor, with nothing in between. | |
| 288 | viewer.x = 400; | |
| 289 | viewer.y = 1400; | |
| 290 | enemy.x = 1000; | |
| 291 | enemy.y = 1400; | |
| 292 | expect(wallBetween(viewer, enemy)).toBe(false); | |
| 293 | expect(visible(state, viewer, enemy)).toBe(true); | |
| 294 | }); | |
| 295 | ||
| 296 | test("no spawn can see a spawn: mid is staggered, not a corridor", () => { | |
| 297 | for (const from of spawnCells("atk")) | |
| 298 | for (const to of spawnCells("def")) expect(wallBetween(from, to)).toBe(true); | |
| 299 | }); | |
| 300 | ||
| 301 | test("a dead player watches through the team, never through walls", () => { | |
| 302 | const state = live(); | |
| 303 | const viewer = atk(state)[0]!; | |
| 304 | const mate = atk(state)[1]!; | |
| 305 | const enemy = def(state)[0]!; | |
| 306 | for (const player of atk(state)) { | |
| 307 | player.alive = false; | |
| 308 | player.x = 400; | |
| 309 | player.y = 400; | |
| 310 | } | |
| 311 | enemy.revealed = 0; | |
| 312 | enemy.x = 1000; | |
| 313 | enemy.y = 1400; | |
| 314 | // Every attacker is dead: the enemy across the map is nobody's to see. | |
| 315 | expect(visible(state, viewer, enemy)).toBe(false); | |
| 316 | // One team mate alive with a sight line, and the dead player sees it too. | |
| 317 | mate.alive = true; | |
| 318 | mate.x = 400; | |
| 319 | mate.y = 1400; | |
| 320 | expect(visible(state, viewer, enemy)).toBe(true); | |
| 321 | // That team mate behind a wall takes the vision away again. | |
| 322 | mate.x = 400; | |
| 323 | mate.y = 400; | |
| 324 | expect(visible(state, viewer, enemy)).toBe(false); | |
| 325 | }); | |
| 326 | ||
| 274 | 327 | test("a blinded player sees nothing", () => { |
| 275 | 328 | const state = live(); |
| 276 | 329 | const viewer = atk(state)[0]!; |
src/match.ts +7 −1
| @@ -628,7 +628,13 @@ export function visible(state: MatchState, viewer: Player, other: Player): boole | ||
| 628 | 628 | if (!other.alive) return false; |
| 629 | 629 | if (other.revealed > 0) return true; |
| 630 | 630 | if (viewer.blind > 0) return false; |
| 631 | if (!viewer.alive) return true; // a dead player watches the round out | |
| 631 | // A dead player watches the round out through their team's eyes, not | |
| 632 | // through the walls: what a living team mate can see, and nothing more. | |
| 633 | // Otherwise dying and staying connected would be a wallhack to call out. | |
| 634 | if (!viewer.alive) | |
| 635 | return state.players.some( | |
| 636 | (mate) => mate.side === viewer.side && mate.alive && mate.blind <= 0 && !sightBlocked(mate, other, state.zones), | |
| 637 | ); | |
| 632 | 638 | return !sightBlocked(viewer, other, state.zones); |
| 633 | 639 | } |
| 634 | 640 |
src/server.test.ts +9 −2
| @@ -146,7 +146,14 @@ test("a command from a seat plants the spike, and nonsense from anyone else does | ||
| 146 | 146 | state.clock = 100; |
| 147 | 147 | const spot = siteCentre("B"); |
| 148 | 148 | const carrier = state.players.find((p) => p.side === "atk")!; |
| 149 | carrier.bot = false; | |
| 149 | // Nobody is a bot for these five seconds: no fight can end the round | |
| 150 | // underneath the plant and make this depend on who shot whom. | |
| 151 | for (const player of state.players) { | |
| 152 | player.bot = false; | |
| 153 | player.alive = true; | |
| 154 | player.hp = 100; | |
| 155 | player.input.fire = false; | |
| 156 | } | |
| 150 | 157 | carrier.x = spot.x; |
| 151 | 158 | carrier.y = spot.y; |
| 152 | 159 | carrier.spike = true; |
| @@ -159,7 +166,7 @@ test("a command from a seat plants the spike, and nonsense from anyone else does | ||
| 159 | 166 | advance(5); |
| 160 | 167 | expect(state.spike.planted).toBe(true); |
| 161 | 168 | expect(state.spike.site).toBe("B"); |
| 162 | carrier.bot = true; | |
| 169 | for (const player of state.players) player.bot = true; | |
| 163 | 170 | }); |
| 164 | 171 | |
| 165 | 172 | test("the match keeps running and is never parked", () => { |