/** * Who killed whom, read off the match rather than written into it. * * The round loop counts kills and deaths on the players and says a line * about it; it keeps no list. Rather than change the rules to carry one, * the arena compares the players before and after a tick: somebody who was * alive and is not is a victim, somebody whose kill count went up is a * killer, and the line the loop said settles which of them did it when two * fall in the same thirtieth of a second. * * Pure functions over plain marks. Nothing here knows a position, and a fall * never carries one — the feed must not be a way to learn where anybody is. */ import type { Player, Side } from "./state.ts"; import type { WeaponId } from "./weapons.ts"; /** Everything about a player this module needs, taken at one instant. */ export type Mark = { id: string; name: string; side: Side; alive: boolean; kills: number; weapon: WeaponId; }; export type Fall = { killer: string | null; victim: string; /** The killer's gun when the kill came from a shot; null when it did not. */ weapon: WeaponId | null; headshot: boolean; /** The match clock when it happened. */ at: number; }; export const marksOf = (players: Player[]): Mark[] => players.map((p) => ({ id: p.id, name: p.name, side: p.side, alive: p.alive, kills: p.kills, weapon: p.weapon })); export type Context = { at: number; /** The lines the round loop said this tick, or the most recent few. */ events: string[]; /** Players whose shot landed on this tick, so a molly is not called a gun. */ shooters: Set; }; /** * The falls between two instants. Killers are matched to victims by the line * the loop said, and by side and a spare kill when there is no line. */ export function fallsBetween(before: Mark[], after: Mark[], context: Context): Fall[] { const was = new Map(before.map((m) => [m.id, m])); const falls: Fall[] = []; const credit = new Map(); for (const mark of after) { const old = was.get(mark.id); if (old && mark.kills > old.kills) credit.set(mark.id, mark.kills - old.kills); } const named = (killer: Mark, victim: Mark): boolean => context.events.includes(`${killer.name} killed ${victim.name}`) || context.events.includes(`${killer.name} headshot ${victim.name}`); for (const mark of after) { const old = was.get(mark.id); if (!old || !old.alive || mark.alive) continue; const able = after.filter((m) => (credit.get(m.id) ?? 0) > 0 && m.side !== mark.side); const killer = able.find((m) => named(m, mark)) ?? able[0] ?? null; if (killer) credit.set(killer.id, (credit.get(killer.id) ?? 1) - 1); falls.push({ killer: killer ? killer.name : null, victim: mark.name, weapon: killer && context.shooters.has(killer.id) ? killer.weapon : null, headshot: killer ? context.events.includes(`${killer.name} headshot ${mark.name}`) : false, at: context.at, }); } return falls; } /** The feed as it is shown: the last few falls, and only the recent ones. */ export const trimFeed = (feed: Fall[], now: number, keep = 5, life = 8): Fall[] => feed.filter((f) => now - f.at <= life).slice(-keep);