/** * Bots. They fill the seats nobody is sitting in, and they use their kit: * a smoke on the way in, a flash before the entry, a dart to find the hold, * a molly on the planted spike, an ultimate when it is worth something. * * A bot only ever writes to its own `input`, the same field a browser writes * to, so the match loop cannot tell them apart and neither can a cheat. */ import { agentById, castable } from "./abilities.ts"; import { acquire, aimCone, canFire, crossRate, drift, heldFor, lose } from "./aim.ts"; import { pathStep, siteAt, siteCentre, walkDistance, type Point, type Site } from "./map.ts"; import { buyAbility, buyShield, buyWeapon, random, RUN_SPEED, SPIKE_REACH, visible } from "./match.ts"; import type { MatchState, Player, Slot } from "./state.ts"; import { WEAPONS, type WeaponId } from "./weapons.ts"; /** * The site the attacking bots agree on this round. It comes from the match * id and the round number, never the running seed: five bots have to reach * the same answer on every tick or they walk in circles between the sites. */ export function attackSite(state: MatchState): Site { let hash = 0; for (const ch of state.id) hash = (hash * 31 + ch.charCodeAt(0)) % 9973; return (hash + state.round) % 2 === 0 ? "A" : "B"; } const holdSpots = (state: MatchState): Point[] => { const a = siteCentre("A"); const b = siteCentre("B"); return [ { x: a.x + 60, y: a.y + 90 }, { x: a.x - 40, y: a.y - 20 }, { x: 1200, y: 640 }, { x: b.x - 60, y: b.y + 90 }, { x: b.x + 40, y: b.y - 20 }, ]; }; const ladder: { weapon: WeaponId; shield: "light" | "heavy" | null; floor: number }[] = [ { weapon: "operator", shield: "heavy", floor: 6200 }, { weapon: "vandal", shield: "heavy", floor: 4400 }, { weapon: "phantom", shield: "heavy", floor: 4200 }, { weapon: "spectre", shield: "heavy", floor: 2900 }, { weapon: "spectre", shield: "light", floor: 2100 }, { weapon: "sheriff", shield: "light", floor: 1300 }, { weapon: "ghost", shield: "light", floor: 950 }, { weapon: "classic", shield: "light", floor: 400 }, ]; /** * What a bot buys. It saves on a broken economy — under 2000 with an empty * gun rack is an eco round, and the credits are worth more next round. */ export function botBuy(state: MatchState, player: Player): void { const saving = player.credits < 2000 && state.round > 1 && player.weapon === "classic" && state.lossStreak[player.team] > 0; if (!saving) { const pick = ladder.find((step) => player.credits >= step.floor); if (pick && WEAPONS[pick.weapon].cost > 0) buyWeapon(state, player, pick.weapon); if (pick?.shield) buyShield(state, player, player.credits > 3500 ? "heavy" : pick.shield); } const agent = agentById(player.agent); // Abilities last: a flash is worth more than the last hundred credits. for (const slot of ["c", "q", "c", "q"] as Slot[]) { const ability = agent.abilities[slot]; if (player.credits >= ability.cost + (saving ? 400 : 0)) buyAbility(state, player, slot); } } const enemiesSeen = (state: MatchState, bot: Player): Player[] => state.players.filter((p) => p.alive && p.side !== bot.side && visible(state, bot, p)); const towards = (bot: Player, point: Point): { mx: number; my: number } => { const dx = point.x - bot.x; const dy = point.y - bot.y; const len = Math.hypot(dx, dy) || 1; return { mx: dx / len, my: dy / len }; }; const turn = (bot: Player, target: number, rate: number): void => { let diff = ((target - bot.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI; bot.input.angle = bot.angle + Math.max(-rate, Math.min(rate, diff)); }; /** One bot's decision for this tick. */ export function botThink(state: MatchState, bot: Player, dt: number): void { if (!bot.alive) return; if (state.phase === "buy") { if (bot.think <= 0) { botBuy(state, bot); bot.think = 99; } bot.input.mx = 0; bot.input.my = 0; return; } // The buy left the timer parked; the first live tick starts it thinking. if (bot.think > 5) bot.think = 0; bot.think -= dt; const spike = state.spike; const site = spike.planted && spike.site ? spike.site : attackSite(state); const target = enemiesSeen(state, bot).sort( (a, b) => Math.hypot(a.x - bot.x, a.y - bot.y) - Math.hypot(b.x - bot.x, b.y - bot.y), )[0]; // --- where to be let goal: Point = siteCentre(site); if (bot.side === "atk") { if (spike.planted) goal = { x: spike.x + (random(state) - 0.5) * 220, y: spike.y + (random(state) - 0.5) * 220 }; else if (!spike.carried && !bot.spike) { const loose = { x: spike.x, y: spike.y }; if (walkDistance(bot, loose) < 8) goal = loose; } } else { const spots = holdSpots(state); const index = Number(bot.id.slice(-1)) % spots.length; goal = spike.planted ? { x: spike.x, y: spike.y } : spots[index]!; } bot.goal = goal; const distToGoal = Math.hypot(goal.x - bot.x, goal.y - bot.y); const step = pathStep(bot, goal); const walk = towards(bot, step); bot.input.mx = walk.mx; bot.input.my = walk.my; // --- the kit if (bot.think <= 0) { bot.think = 0.4 + random(state) * 0.5; useKit(state, bot, target ?? null, site, distToGoal); } // --- the spike bot.input.use = false; if (bot.side === "atk" && bot.spike && !spike.planted) { const onSite = siteAt(bot.x, bot.y) === site; if (onSite && (!target || bot.planting > 0)) { bot.input.use = true; bot.input.mx = 0; bot.input.my = 0; } } if (bot.side === "def" && spike.planted && Math.hypot(bot.x - spike.x, bot.y - spike.y) < SPIKE_REACH - 6) { const safe = !target || spike.fuse < 8; if (safe) { bot.input.use = true; bot.input.mx = 0; bot.input.my = 0; } } if (bot.blind > 0) { // Blinded: back off and stop shooting, like a person would. bot.input.fire = false; bot.input.mx *= -0.6; bot.input.my *= -0.6; return; } // --- the gun bot.input.reload = bot.ammo === 0; if (!target) { lose(bot.aim, state.now); bot.input.fire = false; if (Math.hypot(bot.input.mx, bot.input.my) > 0.01) turn(bot, Math.atan2(bot.input.my, bot.input.mx), 6 * dt); return; } // A hold costs what a hold costs: a moment to notice, a barrel that starts // wrong and settles, and more of both when the target is crossing the view. const roll = () => random(state); acquire(bot.aim, target.id, state.now, target, roll); const crossing = crossRate(bot.aim, bot, target, dt) / RUN_SPEED; const cone = aimCone(heldFor(bot.aim, state.now), crossing); const range = Math.hypot(target.x - bot.x, target.y - bot.y); const aim = Math.atan2(target.y - bot.y, target.x - bot.x) + drift(bot.aim, cone, dt, roll); turn(bot, aim, 7 * dt); const off = Math.abs(((aim - bot.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI); const weapon = WEAPONS[bot.weapon]; bot.input.fire = canFire(bot.aim, state.now) && off < 0.07 && range < weapon.range && bot.ammo > 0; // Stand still to shoot at range, close the gap up front. if (range > 420) { bot.input.mx *= 0.15; bot.input.my *= 0.15; } else if (range < 160) { const away = towards(bot, target); bot.input.mx = -away.mx * 0.5; bot.input.my = -away.my * 0.5; } } /** Ability decisions, kept in one place so each is a sentence you can read. */ function useKit(state: MatchState, bot: Player, target: Player | null, site: Site, distToGoal: number): void { const agent = agentById(bot.agent); const spike = state.spike; const centre = siteCentre(site); const aimAt = (point: Point): void => { bot.input.angle = Math.atan2(point.y - bot.y, point.x - bot.x); bot.angle = bot.input.angle; }; const tryCast = (slot: Slot, point: Point): boolean => { if (!castable(bot, slot)) return false; aimAt(point); bot.input.cast = slot; return true; }; for (const slot of ["x", "c", "q", "e"] as Slot[]) { const ability = agent.abilities[slot]; if (!castable(bot, slot)) continue; const kind = ability.kind; if (kind === "rage" || kind === "haste") { if (bot.hp < 70 || (target && distToGoal < 500)) return void tryCast(slot, centre); continue; } if (kind === "revealall" || kind === "slowall") { if (target || distToGoal < 420) return void tryCast(slot, centre); continue; } if (kind === "heal") { const hurt = state.players.find((p) => p.alive && p.side === bot.side && p.hp <= 60 && Math.hypot(p.x - bot.x, p.y - bot.y) < ability.range); if (hurt) return void tryCast(slot, hurt); continue; } if (kind === "molly") { // Fire on the planted spike, or on the choke ahead of us. if (bot.side === "def" && spike.planted && Math.hypot(bot.x - spike.x, bot.y - spike.y) < ability.range) return void tryCast(slot, spike); if (target && Math.hypot(target.x - bot.x, target.y - bot.y) < ability.range && Math.hypot(target.x - bot.x, target.y - bot.y) > 120) return void tryCast(slot, target); continue; } if (kind === "flash") { const enemyClose = target && Math.hypot(target.x - bot.x, target.y - bot.y) < ability.range; if (enemyClose) return void tryCast(slot, target!); if (bot.side === "atk" && distToGoal < ability.range && distToGoal > 150) return void tryCast(slot, centre); continue; } if (kind === "smoke") { if (bot.side === "atk" && distToGoal < ability.range && distToGoal > 260) return void tryCast(slot, centre); if (bot.side === "def" && spike.planted && Math.hypot(bot.x - spike.x, bot.y - spike.y) < ability.range && Math.hypot(bot.x - spike.x, bot.y - spike.y) > 200) return void tryCast(slot, spike); continue; } if (kind === "dart") { if (distToGoal < ability.range && distToGoal > 200) return void tryCast(slot, centre); continue; } if (kind === "wall") { if (target && Math.hypot(target.x - bot.x, target.y - bot.y) < ability.range && bot.hp < 60) return void tryCast(slot, target); if (bot.side === "def" && !spike.planted && distToGoal < 200 && state.clock > 60) return void tryCast(slot, centre); continue; } if (kind === "trip") { if (bot.side === "def" && distToGoal < 160) return void tryCast(slot, { x: bot.x + (random(state) - 0.5) * 200, y: bot.y + (random(state) - 0.5) * 200 }); continue; } } } /** Drive every seat that nobody is sitting in. */ export function runBots(state: MatchState, dt: number): void { for (const player of state.players) if (player.bot) botThink(state, player, dt); }