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

forked from benjivers/val

workbenchthis machine

A kill feed, read off the match rather than written into it

The round loop counts kills on the players and says a line about it; it
keeps no list, and the rules were not mine to change. So the arena compares
the players either side of a tick — alive then dead is a victim, a kill
count that went up is a killer, and the line the loop said settles which of
them did it when two fall at once. A shot that landed on that tick and
started where the killer stands is what tells a gun kill from an ability
kill, so a molly is never reported as a Vandal.

The feed carries names, the gun, whether it was a head shot and how long
ago — and no coordinates at all, so it can never be read as a call-out. It
shows top-right for a few seconds.

src/server.test.ts now forces both players alive in the line-of-sight test:
the arena is one live match that other tests run forward, and the test was
relying on nobody having died yet.

Co-Authored-By: Claude Opus 5 <[email protected]>
Wren committed 2b02293 Browse files
6 files changed +326 −1
src/arena.ts +39 −1
@@ -13,6 +13,7 @@
1313 import { agentById, sightBlocked } from "./abilities.ts";
1414 import { runBots } from "./bots.ts";
1515 import { recordResult } from "./db.ts";
16 import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts";
1617 import type { Point } from "./map.ts";
1718 import {
1819 buyAbility,
@@ -25,7 +26,7 @@ import {
2526 visible,
2627 } from "./match.ts";
2728 import type { MatchState, Player } from "./state.ts";
28 import { isWeaponId } from "./weapons.ts";
29 import { isWeaponId, WEAPONS } from "./weapons.ts";
2930
3031 export const TICK = 1 / 30;
3132 const BROADCAST_EVERY = 2;
@@ -36,6 +37,10 @@ let state: MatchState = newMatch();
3637 const seats = new Map<string, Seat>();
3738 let ticks = 0;
3839 let lastResult: string | null = null;
40 // The kill feed is read off the match a tick at a time; the rules keep no
41 // list of their own, and this is a view, not a rule.
42 let marks: Mark[] = marksOf(state.players);
43 let feed: Fall[] = [];
3944
4045 export const match = (): MatchState => state;
4146
@@ -211,14 +216,47 @@ export function snapshot(viewerId: string): string {
211216 ultNeeds: agentById(p.agent).abilities.x.points,
212217 })),
213218 events: state.events.slice(0, 5).map((e) => e.text),
219 // Names and a gun, never a position: the feed says what happened, not
220 // where, so it can never be read as a call-out.
221 feed: feed.map((f) => ({
222 k: f.killer,
223 v: f.victim,
224 w: f.weapon ? WEAPONS[f.weapon].name : null,
225 h: f.headshot,
226 t: round2(Math.max(0, state.now - f.at)),
227 })),
214228 });
215229 }
216230
231 /**
232 * Who fell on this tick. A shot that landed this tick and started where the
233 * killer stands is what tells a gun kill from an ability kill — the position
234 * is used here and goes no further.
235 */
236 function readFalls(): void {
237 const after = marksOf(state.players);
238 const shooters = new Set<string>();
239 for (const shot of state.shots) {
240 if (!shot.hit || shot.age > 0) continue;
241 for (const player of state.players)
242 if (Math.hypot(player.x - shot.x1, player.y - shot.y1) < 2) shooters.add(player.id);
243 }
244 const falls = fallsBetween(marks, after, {
245 at: state.now,
246 events: state.events.slice(0, 6).map((e) => e.text),
247 shooters,
248 });
249 marks = after;
250 if (falls.length) feed = [...feed, ...falls];
251 feed = trimFeed(feed, state.now);
252 }
253
217254 // ----------------------------------------------------------------- the loop
218255
219256 function step(): void {
220257 runBots(state, TICK);
221258 tick(state, TICK);
259 readFalls();
222260 if (state.result && state.result !== lastResult) {
223261 lastResult = state.result;
224262 recordResult(state.result, state.score.red, state.score.blue, state.round);
src/client.test.ts +14
@@ -191,6 +191,20 @@ test("the bundled client connects, paints the HUD and draws a frame", async () =
191191 expect(slots.length).toBe(5);
192192 expect(slots.filter((slot) => slot.className.includes("mine")).length).toBe(1);
193193
194 // Run the match on a few seconds: bots trade, and the feed fills with
195 // names — and with nothing that says where anybody is.
196 const arena = await import("./arena.ts");
197 for (let i = 0; i < 40 && (nodes.get("feed")!.children as unknown[]).length === 0; i++) {
198 arena.advance(5);
199 await Bun.sleep(5);
200 }
201 const feedRows = nodes.get("feed")!.children as { children: { textContent: string }[] }[];
202 expect(feedRows.length).toBeGreaterThan(0);
203 // killer ▸ victim, and the gun or the ability it was: four spans, no numbers.
204 const row = feedRows[0]!.children.map((span) => span.textContent).join("");
205 expect(row).toContain(" ▸ ");
206 expect(row).not.toMatch(/\d/);
207
194208 // A frame of the first-person view actually draws walls: a slice a column,
195209 // far more rectangles than the map has tiles.
196210 drawn.length = 0;
src/killfeed.test.ts added +153
@@ -0,0 +1,153 @@
1 import { describe, expect, test } from "bun:test";
2 import { mkdtempSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { fallsBetween, marksOf, trimFeed, type Mark } from "./killfeed.ts";
6 import { newMatch } from "./match.ts";
7
8 process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-"));
9
10 const mark = (id: string, over: Partial<Mark> = {}): Mark => ({
11 id,
12 name: id,
13 side: "atk",
14 alive: true,
15 kills: 0,
16 weapon: "vandal",
17 ...over,
18 });
19
20 const context = (events: string[] = [], shooters: string[] = [], at = 10) => ({
21 at,
22 events,
23 shooters: new Set(shooters),
24 });
25
26 describe("reading falls out of two instants", () => {
27 test("a shot kill names the killer, the victim and the gun", () => {
28 const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })];
29 const after = [mark("A", { side: "atk", kills: 1 }), mark("B", { side: "def", alive: false })];
30 const falls = fallsBetween(before, after, context(["A killed B"], ["A"]));
31 expect(falls).toEqual([{ killer: "A", victim: "B", weapon: "vandal", headshot: false, at: 10 }]);
32 });
33
34 test("a head shot is marked as one", () => {
35 const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })];
36 const after = [mark("A", { side: "atk", kills: 1 }), mark("B", { side: "def", alive: false })];
37 expect(fallsBetween(before, after, context(["A headshot B"], ["A"]))[0]!.headshot).toBe(true);
38 });
39
40 test("a kill with no shot behind it names no gun — a molly is not a Vandal", () => {
41 const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })];
42 const after = [mark("A", { side: "atk", kills: 1 }), mark("B", { side: "def", alive: false })];
43 expect(fallsBetween(before, after, context(["A killed B"]))[0]!.weapon).toBe(null);
44 });
45
46 test("dying to nobody — a molly of your own, a fall — has no killer", () => {
47 const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })];
48 const after = [mark("A", { side: "atk" }), mark("B", { side: "def", alive: false })];
49 const falls = fallsBetween(before, after, context(["B died"]));
50 expect(falls.length).toBe(1);
51 expect(falls[0]!.killer).toBe(null);
52 expect(falls[0]!.weapon).toBe(null);
53 });
54
55 test("two kills in one tick go to the right killers, by what the loop said", () => {
56 const before = [
57 mark("A", { side: "atk" }),
58 mark("C", { side: "atk" }),
59 mark("B", { side: "def" }),
60 mark("D", { side: "def" }),
61 ];
62 const after = [
63 mark("A", { side: "atk", kills: 1 }),
64 mark("C", { side: "atk", kills: 1 }),
65 mark("B", { side: "def", alive: false }),
66 mark("D", { side: "def", alive: false }),
67 ];
68 const falls = fallsBetween(before, after, context(["C killed B", "A killed D"], ["A", "C"]));
69 expect(falls.find((f) => f.victim === "B")!.killer).toBe("C");
70 expect(falls.find((f) => f.victim === "D")!.killer).toBe("A");
71 });
72
73 test("a killer is never somebody on the victim's own side", () => {
74 const before = [mark("A", { side: "atk" }), mark("B", { side: "atk" }), mark("E", { side: "def" })];
75 const after = [
76 mark("A", { side: "atk", kills: 1 }),
77 mark("B", { side: "atk", alive: false }),
78 mark("E", { side: "def" }),
79 ];
80 expect(fallsBetween(before, after, context([]))[0]!.killer).toBe(null);
81 });
82
83 test("a kill with two victims and one killer only credits the one kill", () => {
84 const before = [mark("A", { side: "atk" }), mark("B", { side: "def" }), mark("C", { side: "def" })];
85 const after = [
86 mark("A", { side: "atk", kills: 1 }),
87 mark("B", { side: "def", alive: false }),
88 mark("C", { side: "def", alive: false }),
89 ];
90 const falls = fallsBetween(before, after, context(["A killed B"], ["A"]));
91 expect(falls.filter((f) => f.killer === "A").length).toBe(1);
92 expect(falls.filter((f) => f.killer === null).length).toBe(1);
93 });
94
95 test("nothing happening is no falls", () => {
96 const players = [mark("A"), mark("B", { side: "def" })];
97 expect(fallsBetween(players, players, context())).toEqual([]);
98 });
99
100 test("a body that was already dead does not fall twice", () => {
101 const before = [mark("B", { side: "def", alive: false })];
102 expect(fallsBetween(before, before, context())).toEqual([]);
103 });
104
105 test("a fall never carries a position", () => {
106 const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })];
107 const after = [mark("A", { side: "atk", kills: 1 }), mark("B", { side: "def", alive: false })];
108 const fall = fallsBetween(before, after, context(["A killed B"], ["A"]))[0]!;
109 expect(Object.keys(fall).sort()).toEqual(["at", "headshot", "killer", "victim", "weapon"]);
110 });
111
112 test("marks come off the real players of a real match", () => {
113 const marks = marksOf(newMatch().players);
114 expect(marks.length).toBe(10);
115 expect(marks.every((m) => m.alive && m.kills === 0 && typeof m.name === "string")).toBe(true);
116 expect(JSON.stringify(marks)).not.toContain('"x"');
117 });
118 });
119
120 describe("the feed as it is shown", () => {
121 const fall = (victim: string, at: number) => ({ killer: "A", victim, weapon: null, headshot: false, at });
122
123 test("keeps the last few and drops the stale", () => {
124 const feed = [fall("a", 1), fall("b", 2), fall("c", 9), fall("d", 10)];
125 expect(trimFeed(feed, 10, 5, 8).map((f) => f.victim)).toEqual(["b", "c", "d"]);
126 });
127
128 test("never grows past its slots", () => {
129 const feed = [1, 2, 3, 4, 5, 6, 7].map((n) => fall(String(n), 10));
130 expect(trimFeed(feed, 10, 5, 8).map((f) => f.victim)).toEqual(["3", "4", "5", "6", "7"]);
131 });
132 });
133
134 describe("the feed the arena sends", () => {
135 test("bots killing each other reach the snapshot as names, with no positions", async () => {
136 const { advance, match, snapshot } = await import("./arena.ts");
137 const viewer = match().players[0]!.id;
138 let feed: { k: string | null; v: string; w: string | null; h: boolean; t: number }[] = [];
139 for (let seconds = 0; seconds < 400 && feed.length === 0; seconds += 10) {
140 advance(10);
141 feed = JSON.parse(snapshot(viewer)).feed;
142 }
143 expect(feed.length).toBeGreaterThan(0);
144 const names = match().players.map((p) => p.name);
145 for (const line of feed) {
146 expect(names).toContain(line.v);
147 if (line.k !== null) expect(names).toContain(line.k);
148 expect(line.t).toBeLessThanOrEqual(8);
149 // Names, a gun, an age. Nothing that says where anybody is.
150 expect(Object.keys(line).sort()).toEqual(["h", "k", "t", "v", "w"]);
151 }
152 }, 30000);
153 });
src/killfeed.ts added +84
@@ -0,0 +1,84 @@
1 /**
2 * Who killed whom, read off the match rather than written into it.
3 *
4 * The round loop counts kills and deaths on the players and says a line
5 * about it; it keeps no list. Rather than change the rules to carry one,
6 * the arena compares the players before and after a tick: somebody who was
7 * alive and is not is a victim, somebody whose kill count went up is a
8 * killer, and the line the loop said settles which of them did it when two
9 * fall in the same thirtieth of a second.
10 *
11 * Pure functions over plain marks. Nothing here knows a position, and a fall
12 * never carries one — the feed must not be a way to learn where anybody is.
13 */
14
15 import type { Player, Side } from "./state.ts";
16 import type { WeaponId } from "./weapons.ts";
17
18 /** Everything about a player this module needs, taken at one instant. */
19 export type Mark = {
20 id: string;
21 name: string;
22 side: Side;
23 alive: boolean;
24 kills: number;
25 weapon: WeaponId;
26 };
27
28 export type Fall = {
29 killer: string | null;
30 victim: string;
31 /** The killer's gun when the kill came from a shot; null when it did not. */
32 weapon: WeaponId | null;
33 headshot: boolean;
34 /** The match clock when it happened. */
35 at: number;
36 };
37
38 export const marksOf = (players: Player[]): Mark[] =>
39 players.map((p) => ({ id: p.id, name: p.name, side: p.side, alive: p.alive, kills: p.kills, weapon: p.weapon }));
40
41 export type Context = {
42 at: number;
43 /** The lines the round loop said this tick, or the most recent few. */
44 events: string[];
45 /** Players whose shot landed on this tick, so a molly is not called a gun. */
46 shooters: Set<string>;
47 };
48
49 /**
50 * The falls between two instants. Killers are matched to victims by the line
51 * the loop said, and by side and a spare kill when there is no line.
52 */
53 export function fallsBetween(before: Mark[], after: Mark[], context: Context): Fall[] {
54 const was = new Map(before.map((m) => [m.id, m]));
55 const falls: Fall[] = [];
56 const credit = new Map<string, number>();
57 for (const mark of after) {
58 const old = was.get(mark.id);
59 if (old && mark.kills > old.kills) credit.set(mark.id, mark.kills - old.kills);
60 }
61 const named = (killer: Mark, victim: Mark): boolean =>
62 context.events.includes(`${killer.name} killed ${victim.name}`) ||
63 context.events.includes(`${killer.name} headshot ${victim.name}`);
64
65 for (const mark of after) {
66 const old = was.get(mark.id);
67 if (!old || !old.alive || mark.alive) continue;
68 const able = after.filter((m) => (credit.get(m.id) ?? 0) > 0 && m.side !== mark.side);
69 const killer = able.find((m) => named(m, mark)) ?? able[0] ?? null;
70 if (killer) credit.set(killer.id, (credit.get(killer.id) ?? 1) - 1);
71 falls.push({
72 killer: killer ? killer.name : null,
73 victim: mark.name,
74 weapon: killer && context.shooters.has(killer.id) ? killer.weapon : null,
75 headshot: killer ? context.events.includes(`${killer.name} headshot ${mark.name}`) : false,
76 at: context.at,
77 });
78 }
79 return falls;
80 }
81
82 /** The feed as it is shown: the last few falls, and only the recent ones. */
83 export const trimFeed = (feed: Fall[], now: number, keep = 5, life = 8): Fall[] =>
84 feed.filter((f) => now - f.at <= life).slice(-keep);
src/server.test.ts +5
@@ -123,6 +123,11 @@ test("a snapshot only carries the enemies that viewer can see", () => {
123123 viewer.x = 1200;
124124 viewer.y = 1320;
125125 viewer.blind = 0;
126 // Both of them alive on purpose: the arena is one live match that other
127 // tests run forward, and a dead enemy is invisible for a different reason
128 // than the one this test is about.
129 viewer.alive = true;
130 enemy.alive = true;
126131 enemy.x = 1200;
127132 enemy.y = 1240;
128133 enemy.revealed = 0;
static/val.js +31
@@ -414,6 +414,7 @@ function paintHud(s) {
414414 paintKit(s);
415415 paintBuy(s);
416416 paintRoster(s);
417 paintFeed(s);
417418
418419 const log = $("log");
419420 const lines = s.events.slice(0, 3).join("\n");
@@ -461,6 +462,36 @@ function onSite(me) {
461462 return cell === "A" || cell === "B";
462463 }
463464
465 /**
466 * What just happened to whom: newest first, and gone a few seconds later.
467 * The server sends names and a gun and nothing else, so there is nothing
468 * here to read a position out of.
469 */
470 function paintFeed(s) {
471 const box = $("feed");
472 const lines = (s.feed ?? []).slice().reverse();
473 const key = lines.map((l) => `${l.k}>${l.v}:${l.w}:${l.h}`).join("|");
474 if (box.dataset.key === key) return;
475 box.dataset.key = key;
476 box.replaceChildren();
477 for (const line of lines) {
478 const row = document.createElement("div");
479 const killer = document.createElement("span");
480 killer.className = "k";
481 killer.textContent = line.k ?? "—";
482 const victim = document.createElement("span");
483 victim.className = "v";
484 victim.textContent = line.v;
485 const how = document.createElement("span");
486 how.className = "w";
487 how.textContent = `${line.w ? " " + line.w : " ability"}${line.h ? " ✧" : ""}`;
488 const arrow = document.createElement("span");
489 arrow.textContent = " ▸ ";
490 row.append(killer, arrow, victim, how);
491 box.append(row);
492 }
493 }
494
464495 /**
465496 * Your team, always on screen: five slots, health, and struck through when
466497 * somebody is dead. The one place both questions — who is with me, who is
↑↓ navigate ↵ open esc close

Keyboard shortcuts