/** * The arena: one match, always running, and the seats people sit in. * * The match ticks whether anybody is watching or not, so a visitor never * arrives at a frozen board — they get the round that is happening. Taking a * seat replaces a bot in place: same body, same credits, same position, so * joining mid-round hands you a live player rather than a lobby. * * Every snapshot is built for one viewer: enemies they cannot see are not in * it. The client never learns a position it has no line of sight to. */ import { agentById, sightBlocked } from "./abilities.ts"; import { runBots } from "./bots.ts"; import { recordResult } from "./db.ts"; import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts"; import type { Point } from "./map.ts"; import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, setInput, tick, visible, } from "./match.ts"; import type { MatchState, Player } from "./state.ts"; import { isWeaponId, WEAPONS } from "./weapons.ts"; export const TICK = 1 / 30; const BROADCAST_EVERY = 2; export type Seat = { playerId: string; send: (data: string) => void }; let state: MatchState = newMatch(); const seats = new Map(); let ticks = 0; let lastResult: string | null = null; // The kill feed is read off the match a tick at a time; the rules keep no // list of their own, and this is a view, not a rule. let marks: Mark[] = marksOf(state.players); let feed: Fall[] = []; export const match = (): MatchState => state; export const findPlayer = (id: string): Player | undefined => state.players.find((p) => p.id === id); /** Sit somebody down in the emptiest team, replacing a bot where it stands. */ export function takeSeat(session: string, name: string): Player | null { const existing = seats.get(session); const held = existing ? findPlayer(existing.playerId) : undefined; if (held) { held.name = name; return held; } const humans = { red: 0, blue: 0 }; for (const player of state.players) if (!player.bot) humans[player.team] += 1; const wanted = humans.red <= humans.blue ? "red" : "blue"; const seat = state.players.find((p) => p.bot && p.team === wanted) ?? state.players.find((p) => p.bot); if (!seat) return null; seat.bot = false; seat.name = name; seat.think = 0; seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null }; return seat; } export function attach(session: string, playerId: string, send: (data: string) => void): void { seats.set(session, { playerId, send }); } /** A person leaving gives the seat back to a bot, mid-round and all. */ export function release(session: string): void { const seat = seats.get(session); seats.delete(session); if (!seat) return; const player = findPlayer(seat.playerId); if (player && !stillHeld(seat.playerId)) { player.bot = true; player.think = 0; player.input.fire = false; player.input.use = false; } } const stillHeld = (playerId: string): boolean => [...seats.values()].some((s) => s.playerId === playerId); export const seatCount = (): number => seats.size; // ------------------------------------------------------------- the commands export type Command = { t: string; [key: string]: unknown }; const num = (value: unknown): number | undefined => typeof value === "number" && Number.isFinite(value) ? value : undefined; /** Everything a client may ask for, validated here and nowhere else. */ export function command(playerId: string, message: Command): void { const player = findPlayer(playerId); if (!player || player.bot) return; switch (message.t) { case "i": setInput(player, { mx: num(message.mx), my: num(message.my), angle: num(message.a), fire: typeof message.f === "boolean" ? message.f : undefined, use: typeof message.u === "boolean" ? message.u : undefined, reload: typeof message.r === "boolean" ? message.r : undefined, cast: message.c === "c" || message.c === "q" || message.c === "e" || message.c === "x" ? message.c : undefined, }); break; case "buy": { const what = String(message.what ?? ""); if (isWeaponId(what)) buyWeapon(state, player, what); else if (what === "light" || what === "heavy") buyShield(state, player, what); else if (what === "c" || what === "q") buyAbility(state, player, what); break; } case "agent": if (typeof message.id === "string") pickAgent(state, player, message.id); break; } } // ------------------------------------------------------------- the snapshot const round2 = (n: number): number => Math.round(n * 10) / 10; export function snapshot(viewerId: string): string { const me = findPlayer(viewerId) ?? state.players[0]!; const agent = agentById(me.agent); const seen = state.players.filter((p) => visible(state, me, p)); const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones); return JSON.stringify({ t: "s", phase: state.phase, clock: round2(Math.max(0, state.clock)), round: state.round, score: state.score, attackers: state.attackers, result: state.result, me: { id: me.id, side: me.side, team: me.team, agent: me.agent, alive: me.alive, x: round2(me.x), y: round2(me.y), angle: round2(me.angle), hp: Math.round(me.hp), armour: Math.round(me.armour), credits: me.credits, weapon: me.weapon, ammo: me.ammo, reloading: me.reloadIn > 0, charges: me.charges, cooldown: { c: round2(me.cooldown.c), q: round2(me.cooldown.q), e: round2(me.cooldown.e), x: 0 }, ult: me.ult, ultNeeds: agent.abilities.x.points, blind: round2(me.blind), slow: round2(me.slow), haste: round2(me.haste), spike: me.spike, planting: round2(me.planting), bot: me.bot, }, players: seen.map((p) => ({ id: p.id, name: p.name, agent: p.agent, x: round2(p.x), y: round2(p.y), angle: round2(p.angle), hp: Math.round(p.hp), armour: Math.round(p.armour), alive: p.alive, mate: p.side === me.side, spike: p.spike, blind: p.blind > 0, revealed: p.revealed > 0, })), zones: state.zones .filter((z) => (z.kind === "trip" ? z.side === me.side : true)) .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })), spike: { planted: state.spike.planted, carried: state.spike.carried !== null, fuse: round2(state.spike.fuse), defuse: round2(state.spike.defuse), checkpoint: state.spike.checkpoint, // A loose spike is the attackers' business until it is armed. x: state.spike.planted || me.side === "atk" ? round2(state.spike.x) : null, y: state.spike.planted || me.side === "atk" ? round2(state.spike.y) : null, }, orbs: state.orbs.filter((o) => !o.taken).map((o) => ({ x: o.x, y: o.y })), shots: state.shots.filter((s) => canSee({ x: s.x1, y: s.y1 }) || canSee({ x: s.x2, y: s.y2 })), board: state.players.map((p) => ({ id: p.id, name: p.name, team: p.team, side: p.side, agent: p.agent, bot: p.bot, alive: p.alive, kills: p.kills, deaths: p.deaths, damage: Math.round(p.damageDone), credits: p.team === me.team ? p.credits : null, ult: p.ult, ultNeeds: agentById(p.agent).abilities.x.points, })), events: state.events.slice(0, 5).map((e) => e.text), // Names and a gun, never a position: the feed says what happened, not // where, so it can never be read as a call-out. feed: feed.map((f) => ({ k: f.killer, v: f.victim, w: f.weapon ? WEAPONS[f.weapon].name : null, h: f.headshot, t: round2(Math.max(0, state.now - f.at)), })), }); } /** * Who fell on this tick. A shot that landed this tick and started where the * killer stands is what tells a gun kill from an ability kill — the position * is used here and goes no further. */ function readFalls(): void { const after = marksOf(state.players); const shooters = new Set(); for (const shot of state.shots) { if (!shot.hit || shot.age > 0) continue; for (const player of state.players) if (Math.hypot(player.x - shot.x1, player.y - shot.y1) < 2) shooters.add(player.id); } const falls = fallsBetween(marks, after, { at: state.now, events: state.events.slice(0, 6).map((e) => e.text), shooters, }); marks = after; if (falls.length) feed = [...feed, ...falls]; feed = trimFeed(feed, state.now); } // ----------------------------------------------------------------- the loop function step(): void { runBots(state, TICK); tick(state, TICK); readFalls(); if (state.result && state.result !== lastResult) { lastResult = state.result; recordResult(state.result, state.score.red, state.score.blue, state.round); } if (state.result === null) lastResult = null; ticks += 1; if (ticks % BROADCAST_EVERY !== 0) return; for (const seat of seats.values()) { try { seat.send(snapshot(seat.playerId)); } catch { // A socket that will not take a frame is closed already; the close // handler gives the seat back. } } } let timer: ReturnType | null = null; export function startArena(): void { if (timer) return; timer = setInterval(step, TICK * 1000); // The clock must never hold the process open on its own. (timer as unknown as { unref?: () => void }).unref?.(); } export function stopArena(): void { if (timer) clearInterval(timer); timer = null; } /** Used by the tests: run the arena forward without waiting for real time. */ export function advance(seconds: number): void { for (let i = 0; i < Math.round(seconds / TICK); i++) step(); }