fable/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]>
Wren committed f06c947 Browse files
5 files changed +78 −9
README.md +6 −3
@@ -19,9 +19,12 @@ server:
1919 a Vandal head shot kills through full armour at any range, a Phantom's
2020 does not past fifteen metres.
2121 - **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.
2528 - **The spike.** 100s round, 4s plant, 45s fuse, 7s defuse with a
2629 checkpoint at 3.5s — a defender killed past half way leaves the defuse
2730 half done for the next one.
src/map.ts +2 −2
@@ -28,8 +28,8 @@ const ROWS_SRC = [
2828 "#..####..####.##.####..####..#",
2929 "#........####.##.####........#",
3030 "#..####..####....####..####..#",
31 "#..####..####....####..####..#",
32 "#..####..####....####..####..#",
31 "#..####..#####..#####..####..#",
32 "#..####..#####..#####..####..#",
3333 "#..####..###......###..####..#",
3434 "#........##..aaaa..##........#",
3535 "#............aaaa............#",
src/match.test.ts +54 −1
@@ -16,7 +16,7 @@ import {
1616 visible,
1717 WIN_ROUNDS,
1818 } from "./match.ts";
19 import { siteCentre } from "./map.ts";
19 import { siteCentre, spawnCells, wallBetween } from "./map.ts";
2020 import type { MatchState, Player } from "./state.ts";
2121
2222 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",
271271 expect(visible(state, viewer, enemy)).toBe(true);
272272 });
273273
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
274327 test("a blinded player sees nothing", () => {
275328 const state = live();
276329 const viewer = atk(state)[0]!;
src/match.ts +7 −1
@@ -628,7 +628,13 @@ export function visible(state: MatchState, viewer: Player, other: Player): boole
628628 if (!other.alive) return false;
629629 if (other.revealed > 0) return true;
630630 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 );
632638 return !sightBlocked(viewer, other, state.zones);
633639 }
634640
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
146146 state.clock = 100;
147147 const spot = siteCentre("B");
148148 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 }
150157 carrier.x = spot.x;
151158 carrier.y = spot.y;
152159 carrier.spike = true;
@@ -159,7 +166,7 @@ test("a command from a seat plants the spike, and nonsense from anyone else does
159166 advance(5);
160167 expect(state.spike.planted).toBe(true);
161168 expect(state.spike.site).toBe("B");
162 carrier.bot = true;
169 for (const player of state.players) player.bot = true;
163170 });
164171
165172 test("the match keeps running and is never parked", () => {
↑↓ navigate ↵ open esc close

Keyboard shortcuts