import { describe, expect, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fallsBetween, marksOf, trimFeed, type Mark } from "./killfeed.ts"; import { newMatch } from "./match.ts"; process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-")); const mark = (id: string, over: Partial = {}): Mark => ({ id, name: id, side: "atk", alive: true, kills: 0, weapon: "vandal", ...over, }); const context = (events: string[] = [], shooters: string[] = [], at = 10) => ({ at, events, shooters: new Set(shooters), }); describe("reading falls out of two instants", () => { test("a shot kill names the killer, the victim and the gun", () => { const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })]; const after = [mark("A", { side: "atk", kills: 1 }), mark("B", { side: "def", alive: false })]; const falls = fallsBetween(before, after, context(["A killed B"], ["A"])); expect(falls).toEqual([{ killer: "A", victim: "B", weapon: "vandal", headshot: false, at: 10 }]); }); test("a head shot is marked as one", () => { const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })]; const after = [mark("A", { side: "atk", kills: 1 }), mark("B", { side: "def", alive: false })]; expect(fallsBetween(before, after, context(["A headshot B"], ["A"]))[0]!.headshot).toBe(true); }); test("a kill with no shot behind it names no gun — a molly is not a Vandal", () => { const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })]; const after = [mark("A", { side: "atk", kills: 1 }), mark("B", { side: "def", alive: false })]; expect(fallsBetween(before, after, context(["A killed B"]))[0]!.weapon).toBe(null); }); test("dying to nobody — a molly of your own, a fall — has no killer", () => { const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })]; const after = [mark("A", { side: "atk" }), mark("B", { side: "def", alive: false })]; const falls = fallsBetween(before, after, context(["B died"])); expect(falls.length).toBe(1); expect(falls[0]!.killer).toBe(null); expect(falls[0]!.weapon).toBe(null); }); test("two kills in one tick go to the right killers, by what the loop said", () => { const before = [ mark("A", { side: "atk" }), mark("C", { side: "atk" }), mark("B", { side: "def" }), mark("D", { side: "def" }), ]; const after = [ mark("A", { side: "atk", kills: 1 }), mark("C", { side: "atk", kills: 1 }), mark("B", { side: "def", alive: false }), mark("D", { side: "def", alive: false }), ]; const falls = fallsBetween(before, after, context(["C killed B", "A killed D"], ["A", "C"])); expect(falls.find((f) => f.victim === "B")!.killer).toBe("C"); expect(falls.find((f) => f.victim === "D")!.killer).toBe("A"); }); test("a killer is never somebody on the victim's own side", () => { const before = [mark("A", { side: "atk" }), mark("B", { side: "atk" }), mark("E", { side: "def" })]; const after = [ mark("A", { side: "atk", kills: 1 }), mark("B", { side: "atk", alive: false }), mark("E", { side: "def" }), ]; expect(fallsBetween(before, after, context([]))[0]!.killer).toBe(null); }); test("a kill with two victims and one killer only credits the one kill", () => { const before = [mark("A", { side: "atk" }), mark("B", { side: "def" }), mark("C", { side: "def" })]; const after = [ mark("A", { side: "atk", kills: 1 }), mark("B", { side: "def", alive: false }), mark("C", { side: "def", alive: false }), ]; const falls = fallsBetween(before, after, context(["A killed B"], ["A"])); expect(falls.filter((f) => f.killer === "A").length).toBe(1); expect(falls.filter((f) => f.killer === null).length).toBe(1); }); test("nothing happening is no falls", () => { const players = [mark("A"), mark("B", { side: "def" })]; expect(fallsBetween(players, players, context())).toEqual([]); }); test("a body that was already dead does not fall twice", () => { const before = [mark("B", { side: "def", alive: false })]; expect(fallsBetween(before, before, context())).toEqual([]); }); test("a fall never carries a position", () => { const before = [mark("A", { side: "atk" }), mark("B", { side: "def" })]; const after = [mark("A", { side: "atk", kills: 1 }), mark("B", { side: "def", alive: false })]; const fall = fallsBetween(before, after, context(["A killed B"], ["A"]))[0]!; expect(Object.keys(fall).sort()).toEqual(["at", "headshot", "killer", "victim", "weapon"]); }); test("marks come off the real players of a real match", () => { const marks = marksOf(newMatch().players); expect(marks.length).toBe(10); expect(marks.every((m) => m.alive && m.kills === 0 && typeof m.name === "string")).toBe(true); expect(JSON.stringify(marks)).not.toContain('"x"'); }); }); describe("the feed as it is shown", () => { const fall = (victim: string, at: number) => ({ killer: "A", victim, weapon: null, headshot: false, at }); test("keeps the last few and drops the stale", () => { const feed = [fall("a", 1), fall("b", 2), fall("c", 9), fall("d", 10)]; expect(trimFeed(feed, 10, 5, 8).map((f) => f.victim)).toEqual(["b", "c", "d"]); }); test("never grows past its slots", () => { const feed = [1, 2, 3, 4, 5, 6, 7].map((n) => fall(String(n), 10)); expect(trimFeed(feed, 10, 5, 8).map((f) => f.victim)).toEqual(["3", "4", "5", "6", "7"]); }); }); describe("the feed the arena sends", () => { test("bots killing each other reach the snapshot as names, with no positions", async () => { const { advance, match, snapshot } = await import("./arena.ts"); const viewer = match().players[0]!.id; let feed: { k: string | null; v: string; w: string | null; h: boolean; t: number }[] = []; for (let seconds = 0; seconds < 400 && feed.length === 0; seconds += 10) { advance(10); feed = JSON.parse(snapshot(viewer)).feed; } expect(feed.length).toBeGreaterThan(0); const names = match().players.map((p) => p.name); for (const line of feed) { expect(names).toContain(line.v); if (line.k !== null) expect(names).toContain(line.k); expect(line.t).toBeLessThanOrEqual(8); // Names, a gun, an age. Nothing that says where anybody is. expect(Object.keys(line).sort()).toEqual(["h", "k", "t", "v", "w"]); } }, 30000); });