/** * 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; token: string; address: string; send: (data: string) => void }; let state: MatchState = newMatch(); const seats = new Map(); // A session is a cookie, and a cookie costs one HTTP request — free for // anyone to mint as many of as they like. The address is the one thing here // a single machine cannot hand itself two of, so it is what a playing seat // is actually rationed by; the session on top of it is only what lets a // reload find its way back to the same seat rather than a fresh one. const addressSeats = 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); /** * A name nobody else in this match is using. * * The kill feed, the round log and the scoreboard all address people by * name: two players called Kit make a line that reads as a team kill this * game cannot have. The bots are dealt distinct names; a person who picks * one that is taken gets a number after it, inside the sixteen characters a * name is allowed. */ function freeName(want: string, exceptId: string): string { const taken = new Set(state.players.filter((p) => p.id !== exceptId).map((p) => p.name)); if (!taken.has(want)) return want; for (let n = 2; n < 100; n++) { const tried = `${want.slice(0, 13)} ${n}`; if (!taken.has(tried)) return tried; } return want; } /** * Whether this address already plays under a different session. Checked * before a socket is even offered a seat, so the caller can hand it a * spectator view instead of upgrading it into a second side's vision. */ export function addressCapped(address: string, session: string): boolean { const holder = addressSeats.get(address); return holder !== undefined && holder !== session; } /** * Sit somebody down in the emptiest team, replacing a bot where it stands. * * Both claims go in right here, synchronously, before the caller ever opens * a socket. A websocket upgrade completes on a later tick, so two requests * arriving close together both used to read the maps before either had * written to them — one client, two seats, opposite sides, the vision of * both. Claiming inline closes that window regardless of how the two * requests were related. */ export function takeSeat(address: string, session: string, name: string): Player | null { const existing = seats.get(session); const held = existing ? findPlayer(existing.playerId) : undefined; if (held) { held.name = freeName(name, held.id); return held; } if (addressCapped(address, session)) return null; 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 = freeName(name, seat.id); seat.think = 0; seat.input = { mx: 0, my: 0, angle: seat.angle, fire: false, use: false, reload: false, cast: null }; addressSeats.set(address, session); seats.set(session, { playerId: seat.id, token: "", address, send: () => {} }); return seat; } /** The socket that actually opened fills in how to reach it and its token. */ export function attach(session: string, playerId: string, address: string, token: string, send: (data: string) => void): void { seats.set(session, { playerId, token, address, send }); } /** * A person leaving gives the seat back to a bot, mid-round and all — but only * if this is still the socket the session is on. A reload opens the new * socket before the old one's close event arrives; without the token, that * late close would delete the reconnect's seat entry and hand a live human's * seat to a bot mid-round. */ export function release(session: string, token: string): void { const seat = seats.get(session); if (!seat || seat.token !== token) return; seats.delete(session); if (addressSeats.get(seat.address) === session) addressSeats.delete(seat.address); const player = findPlayer(seat.playerId); if (player && !stillHeld(seat.playerId)) { player.bot = true; player.think = 0; player.input.fire = false; player.input.use = false; } } /** * Test-only hygiene: give back whatever this address currently holds. The * arena is one match shared by the whole test run, over real HTTP, from one * loopback address — this is how a test that needs that address free gets it * free, rather than depending on every other test in the file to clean up * after itself. */ export function releaseAddress(address: string): void { const session = addressSeats.get(address); if (session !== undefined) release(session, seats.get(session)?.token ?? ""); } const stillHeld = (playerId: string): boolean => [...seats.values()].some((s) => s.playerId === playerId); export const seatCount = (): number => seats.size; // -------------------------------------------------------------- spectators // A connection that never carried a session the page minted gets no side and // no seat: only the public state everyone already sees on the page itself. const spectators = new Map void>(); export function spectatorSnapshot(): string { return JSON.stringify({ t: "s", spectator: true, phase: state.phase, clock: round2(Math.max(0, state.clock)), round: state.round, score: state.score, attackers: state.attackers, result: state.result, events: state.events.slice(0, 5).map((e) => e.text), }); } export function watch(token: string, send: (data: string) => void): void { spectators.set(token, send); send(spectatorSnapshot()); } export function unwatch(token: string): void { spectators.delete(token); } // ------------------------------------------------------------- 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), // Your own two ways of being held still, so the browser's prediction // of your own body runs at the speed the server is about to use. defusing: me.defusing > 0, 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. } } for (const send of spectators.values()) { try { send(spectatorSnapshot()); } catch { // Same story: the close handler drops it from the set. } } } 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(); }