fable/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files d7211248ad6d576d3c93536129a0d746e3c90526
| 1 | /** |
| 2 | * Bots. They fill the seats nobody is sitting in, and they use their kit: |
| 3 | * a smoke on the way in, a flash before the entry, a dart to find the hold, |
| 4 | * a molly on the planted spike, an ultimate when it is worth something. |
| 5 | * |
| 6 | * A bot only ever writes to its own `input`, the same field a browser writes |
| 7 | * to, so the match loop cannot tell them apart and neither can a cheat. |
| 8 | */ |
| 9 | |
| 10 | import { agentById, castable, flashDuration, sightBlocked, type Ability } from "./abilities.ts"; |
| 11 | import { acquire, aimCone, canFire, crossRate, drift, heldFor, lose } from "./engine/aim.ts"; |
| 12 | import { pathStep, rayHitWall, siteAt, siteCentre, walkDistance, type Point, type Site } from "./map.ts"; |
| 13 | import { buyAbility, buyShield, buyWeapon, random, RUN_SPEED, SPIKE_REACH, visible } from "./match.ts"; |
| 14 | import type { MatchState, Player, Slot } from "./state.ts"; |
| 15 | import { WEAPONS, type WeaponId } from "./weapons.ts"; |
| 16 | |
| 17 | /** |
| 18 | * The site the attacking bots agree on this round. It comes from the match |
| 19 | * id and the round number, never the running seed: five bots have to reach |
| 20 | * the same answer on every tick or they walk in circles between the sites. |
| 21 | */ |
| 22 | export function attackSite(state: MatchState): Site { |
| 23 | let hash = 0; |
| 24 | for (const ch of state.id) hash = (hash * 31 + ch.charCodeAt(0)) % 9973; |
| 25 | return (hash + state.round) % 2 === 0 ? "A" : "B"; |
| 26 | } |
| 27 | |
| 28 | const holdSpots = (state: MatchState): Point[] => { |
| 29 | const a = siteCentre("A"); |
| 30 | const b = siteCentre("B"); |
| 31 | return [ |
| 32 | { x: a.x + 60, y: a.y + 90 }, |
| 33 | { x: a.x - 40, y: a.y - 20 }, |
| 34 | { x: 1200, y: 640 }, |
| 35 | { x: b.x - 60, y: b.y + 90 }, |
| 36 | { x: b.x + 40, y: b.y - 20 }, |
| 37 | ]; |
| 38 | }; |
| 39 | |
| 40 | const ladder: { weapon: WeaponId; shield: "light" | "heavy" | null; floor: number }[] = [ |
| 41 | { weapon: "operator", shield: "heavy", floor: 6200 }, |
| 42 | { weapon: "vandal", shield: "heavy", floor: 4400 }, |
| 43 | { weapon: "phantom", shield: "heavy", floor: 4200 }, |
| 44 | { weapon: "spectre", shield: "heavy", floor: 2900 }, |
| 45 | { weapon: "spectre", shield: "light", floor: 2100 }, |
| 46 | { weapon: "sheriff", shield: "light", floor: 1300 }, |
| 47 | { weapon: "ghost", shield: "light", floor: 950 }, |
| 48 | { weapon: "classic", shield: "light", floor: 400 }, |
| 49 | ]; |
| 50 | |
| 51 | /** |
| 52 | * What a bot buys. It saves on a broken economy — under 2000 with an empty |
| 53 | * gun rack is an eco round, and the credits are worth more next round. |
| 54 | */ |
| 55 | export function botBuy(state: MatchState, player: Player): void { |
| 56 | const saving = player.credits < 2000 && state.round > 1 && player.weapon === "classic" && state.lossStreak[player.team] > 0; |
| 57 | if (!saving) { |
| 58 | const pick = ladder.find((step) => player.credits >= step.floor); |
| 59 | if (pick && WEAPONS[pick.weapon].cost > 0) buyWeapon(state, player, pick.weapon); |
| 60 | if (pick?.shield) buyShield(state, player, player.credits > 3500 ? "heavy" : pick.shield); |
| 61 | } |
| 62 | const agent = agentById(player.agent); |
| 63 | // Abilities last: a flash is worth more than the last hundred credits. |
| 64 | for (const slot of ["c", "q", "c", "q"] as Slot[]) { |
| 65 | const ability = agent.abilities[slot]; |
| 66 | if (player.credits >= ability.cost + (saving ? 400 : 0)) buyAbility(state, player, slot); |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | const enemiesSeen = (state: MatchState, bot: Player): Player[] => |
| 71 | state.players.filter((p) => p.alive && p.side !== bot.side && visible(state, bot, p)); |
| 72 | |
| 73 | const towards = (bot: Player, point: Point): { mx: number; my: number } => { |
| 74 | const dx = point.x - bot.x; |
| 75 | const dy = point.y - bot.y; |
| 76 | const len = Math.hypot(dx, dy) || 1; |
| 77 | return { mx: dx / len, my: dy / len }; |
| 78 | }; |
| 79 | |
| 80 | const turn = (bot: Player, target: number, rate: number): void => { |
| 81 | let diff = ((target - bot.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI; |
| 82 | bot.input.angle = bot.angle + Math.max(-rate, Math.min(rate, diff)); |
| 83 | }; |
| 84 | |
| 85 | /** One bot's decision for this tick. */ |
| 86 | export function botThink(state: MatchState, bot: Player, dt: number): void { |
| 87 | if (!bot.alive) return; |
| 88 | if (state.phase === "buy") { |
| 89 | if (bot.think <= 0) { |
| 90 | botBuy(state, bot); |
| 91 | bot.think = 99; |
| 92 | } |
| 93 | bot.input.mx = 0; |
| 94 | bot.input.my = 0; |
| 95 | return; |
| 96 | } |
| 97 | // The buy left the timer parked; the first live tick starts it thinking. |
| 98 | if (bot.think > 5) bot.think = 0; |
| 99 | bot.think -= dt; |
| 100 | const spike = state.spike; |
| 101 | const site = spike.planted && spike.site ? spike.site : attackSite(state); |
| 102 | const target = enemiesSeen(state, bot).sort( |
| 103 | (a, b) => Math.hypot(a.x - bot.x, a.y - bot.y) - Math.hypot(b.x - bot.x, b.y - bot.y), |
| 104 | )[0]; |
| 105 | |
| 106 | // --- where to be |
| 107 | let goal: Point = siteCentre(site); |
| 108 | if (bot.side === "atk") { |
| 109 | if (spike.planted) goal = { x: spike.x + (random(state) - 0.5) * 220, y: spike.y + (random(state) - 0.5) * 220 }; |
| 110 | else if (!spike.carried && !bot.spike) { |
| 111 | const loose = { x: spike.x, y: spike.y }; |
| 112 | if (walkDistance(bot, loose) < 8) goal = loose; |
| 113 | } |
| 114 | } else { |
| 115 | const spots = holdSpots(state); |
| 116 | const index = Number(bot.id.slice(-1)) % spots.length; |
| 117 | goal = spike.planted ? { x: spike.x, y: spike.y } : spots[index]!; |
| 118 | } |
| 119 | bot.goal = goal; |
| 120 | |
| 121 | const distToGoal = Math.hypot(goal.x - bot.x, goal.y - bot.y); |
| 122 | const step = pathStep(bot, goal); |
| 123 | const walk = towards(bot, step); |
| 124 | bot.input.mx = walk.mx; |
| 125 | bot.input.my = walk.my; |
| 126 | |
| 127 | // --- the kit |
| 128 | if (bot.think <= 0) { |
| 129 | bot.think = 0.4 + random(state) * 0.5; |
| 130 | useKit(state, bot, target ?? null, site, distToGoal); |
| 131 | } |
| 132 | // A throw is made standing still, down the line it was decided on: the |
| 133 | // aim below used to swing the barrel up to 13° the same tick, and a flash |
| 134 | // decided for open floor went into the wall beside the thrower's head. |
| 135 | if (bot.input.cast !== null) { |
| 136 | bot.input.mx = 0; |
| 137 | bot.input.my = 0; |
| 138 | bot.input.fire = false; |
| 139 | return; |
| 140 | } |
| 141 | |
| 142 | // --- the spike |
| 143 | bot.input.use = false; |
| 144 | if (bot.side === "atk" && bot.spike && !spike.planted) { |
| 145 | const onSite = siteAt(bot.x, bot.y) === site; |
| 146 | if (onSite && (!target || bot.planting > 0)) { |
| 147 | bot.input.use = true; |
| 148 | bot.input.mx = 0; |
| 149 | bot.input.my = 0; |
| 150 | } |
| 151 | } |
| 152 | if (bot.side === "def" && spike.planted && Math.hypot(bot.x - spike.x, bot.y - spike.y) < SPIKE_REACH - 6) { |
| 153 | const safe = !target || spike.fuse < 8; |
| 154 | if (safe) { |
| 155 | bot.input.use = true; |
| 156 | bot.input.mx = 0; |
| 157 | bot.input.my = 0; |
| 158 | } |
| 159 | } |
| 160 | if (bot.blind > 0) { |
| 161 | // Blinded: back off and stop shooting, like a person would. |
| 162 | bot.input.fire = false; |
| 163 | bot.input.mx *= -0.6; |
| 164 | bot.input.my *= -0.6; |
| 165 | return; |
| 166 | } |
| 167 | |
| 168 | // --- the gun |
| 169 | bot.input.reload = bot.ammo === 0; |
| 170 | if (!target) { |
| 171 | lose(bot.aim, state.now); |
| 172 | bot.input.fire = false; |
| 173 | if (Math.hypot(bot.input.mx, bot.input.my) > 0.01) |
| 174 | turn(bot, Math.atan2(bot.input.my, bot.input.mx), 6 * dt); |
| 175 | return; |
| 176 | } |
| 177 | // A hold costs what a hold costs: a moment to notice, a barrel that starts |
| 178 | // wrong and settles, and more of both when the target is crossing the view. |
| 179 | const roll = () => random(state); |
| 180 | acquire(bot.aim, target.id, state.now, target, roll); |
| 181 | const crossing = crossRate(bot.aim, bot, target, dt) / RUN_SPEED; |
| 182 | const cone = aimCone(heldFor(bot.aim, state.now), crossing); |
| 183 | const range = Math.hypot(target.x - bot.x, target.y - bot.y); |
| 184 | const aim = Math.atan2(target.y - bot.y, target.x - bot.x) + drift(bot.aim, cone, dt, roll); |
| 185 | turn(bot, aim, 7 * dt); |
| 186 | const off = Math.abs(((aim - bot.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI); |
| 187 | const weapon = WEAPONS[bot.weapon]; |
| 188 | bot.input.fire = canFire(bot.aim, state.now) && off < 0.07 && range < weapon.range && bot.ammo > 0; |
| 189 | // Stand still to shoot at range, close the gap up front. |
| 190 | if (range > 420) { |
| 191 | bot.input.mx *= 0.15; |
| 192 | bot.input.my *= 0.15; |
| 193 | } else if (range < 160) { |
| 194 | const away = towards(bot, target); |
| 195 | bot.input.mx = -away.mx * 0.5; |
| 196 | bot.input.my = -away.my * 0.5; |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | /** |
| 201 | * Seconds a team waits between flashes: two pops back to back is a strobe. |
| 202 | * Before any of this, bots threw every charge the moment the round went |
| 203 | * live — 6.5 flashes a round, 4.4% of live player-seconds spent blind, and |
| 204 | * each flash blinding 0.2 throwers and 0.43 team mates against 0.23 enemies. |
| 205 | * `src/bots.test.ts` keeps the numbers. |
| 206 | */ |
| 207 | export const FLASH_SPACING = 6; |
| 208 | /** Seconds one thrower waits between its own: two charges are an entry and an exit, not a pair. */ |
| 209 | export const FLASH_REPEAT = 12; |
| 210 | |
| 211 | /** Where this flash pops if thrown at that point: full range down the line, or the first wall. */ |
| 212 | export const flashSpot = (bot: Point, ability: Ability, point: Point): Point => |
| 213 | rayHitWall(bot, Math.atan2(point.y - bot.y, point.x - bot.x), ability.range); |
| 214 | |
| 215 | /** |
| 216 | * Whether a bot should throw this flash at this point right now. A flash is |
| 217 | * for taking one angle, so it goes out only when it would do that and |
| 218 | * nothing else: not so close that the thrower, or a team mate, could be |
| 219 | * inside it by the time it pops — a fuse is long enough to run into one; |
| 220 | * not while a team mate is looking at where it will pop; not inside a few |
| 221 | * seconds of the team's last; and, thrown at an enemy, only when the pop |
| 222 | * would blind them as they stand — a flash that flies past somebody facing |
| 223 | * you pops behind their head, and does nothing but light up your own side. |
| 224 | */ |
| 225 | export function flashCall(state: MatchState, bot: Player, ability: Ability, point: Point, target: Player | null): boolean { |
| 226 | const spot = flashSpot(bot, ability, point); |
| 227 | const stride = RUN_SPEED * 1.35 * ability.fuse; |
| 228 | const reach = ability.radius + stride; |
| 229 | if (Math.hypot(spot.x - bot.x, spot.y - bot.y) <= reach) return false; |
| 230 | if (target && flashDuration(1, target, spot, target.angle, sightBlocked(spot, target, state.zones), ability.radius) <= 0) return false; |
| 231 | for (const mate of state.players) { |
| 232 | if (mate.side !== bot.side) continue; |
| 233 | if (state.now - mate.flashedAt < (mate.id === bot.id ? FLASH_REPEAT : FLASH_SPACING)) return false; |
| 234 | if (!mate.alive || mate.id === bot.id) continue; |
| 235 | const gap = Math.hypot(spot.x - mate.x, spot.y - mate.y); |
| 236 | if (gap <= stride || flashDuration(1, mate, spot, mate.angle, sightBlocked(spot, mate, state.zones), reach) > 0) return false; |
| 237 | } |
| 238 | return true; |
| 239 | } |
| 240 | |
| 241 | /** Ability decisions, kept in one place so each is a sentence you can read. */ |
| 242 | function useKit(state: MatchState, bot: Player, target: Player | null, site: Site, distToGoal: number): void { |
| 243 | const agent = agentById(bot.agent); |
| 244 | const spike = state.spike; |
| 245 | const centre = siteCentre(site); |
| 246 | const aimAt = (point: Point): void => { |
| 247 | bot.input.angle = Math.atan2(point.y - bot.y, point.x - bot.x); |
| 248 | bot.angle = bot.input.angle; |
| 249 | }; |
| 250 | const tryCast = (slot: Slot, point: Point): boolean => { |
| 251 | if (!castable(bot, slot)) return false; |
| 252 | aimAt(point); |
| 253 | bot.input.cast = slot; |
| 254 | return true; |
| 255 | }; |
| 256 | |
| 257 | for (const slot of ["x", "c", "q", "e"] as Slot[]) { |
| 258 | const ability = agent.abilities[slot]; |
| 259 | if (!castable(bot, slot)) continue; |
| 260 | const kind = ability.kind; |
| 261 | |
| 262 | if (kind === "rage" || kind === "haste") { |
| 263 | if (bot.hp < 70 || (target && distToGoal < 500)) return void tryCast(slot, centre); |
| 264 | continue; |
| 265 | } |
| 266 | if (kind === "revealall" || kind === "slowall") { |
| 267 | if (target || distToGoal < 420) return void tryCast(slot, centre); |
| 268 | continue; |
| 269 | } |
| 270 | if (kind === "heal") { |
| 271 | 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); |
| 272 | if (hurt) return void tryCast(slot, hurt); |
| 273 | continue; |
| 274 | } |
| 275 | if (kind === "molly") { |
| 276 | // Fire on the planted spike, or on the choke ahead of us. |
| 277 | if (bot.side === "def" && spike.planted && Math.hypot(bot.x - spike.x, bot.y - spike.y) < ability.range) |
| 278 | return void tryCast(slot, spike); |
| 279 | 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) |
| 280 | return void tryCast(slot, target); |
| 281 | continue; |
| 282 | } |
| 283 | if (kind === "flash") { |
| 284 | // A flash is for taking a site: on the way in for the attack, on the |
| 285 | // way back to the spike for the retake. Not for every duel on the way. |
| 286 | // It goes at an enemy in view, or short of the site so the pop sits |
| 287 | // between the team and whoever holds it; and only when it would take |
| 288 | // that one angle and blind nobody else (`flashCall`). |
| 289 | const taking = bot.side === "atk" ? !spike.planted : spike.planted; |
| 290 | const near = distToGoal < ability.range + ability.radius * 0.8; |
| 291 | const short = distToGoal > ability.range; |
| 292 | const at = taking && near ? target ?? (short ? centre : null) : null; |
| 293 | if (at && flashCall(state, bot, ability, at, target)) { |
| 294 | bot.flashedAt = state.now; |
| 295 | return void tryCast(slot, at); |
| 296 | } |
| 297 | continue; |
| 298 | } |
| 299 | if (kind === "smoke") { |
| 300 | if (bot.side === "atk" && distToGoal < ability.range && distToGoal > 260) return void tryCast(slot, centre); |
| 301 | 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) |
| 302 | return void tryCast(slot, spike); |
| 303 | continue; |
| 304 | } |
| 305 | if (kind === "dart") { |
| 306 | if (distToGoal < ability.range && distToGoal > 200) return void tryCast(slot, centre); |
| 307 | continue; |
| 308 | } |
| 309 | if (kind === "wall") { |
| 310 | if (target && Math.hypot(target.x - bot.x, target.y - bot.y) < ability.range && bot.hp < 60) |
| 311 | return void tryCast(slot, target); |
| 312 | if (bot.side === "def" && !spike.planted && distToGoal < 200 && state.clock > 60) return void tryCast(slot, centre); |
| 313 | continue; |
| 314 | } |
| 315 | if (kind === "trip") { |
| 316 | 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 }); |
| 317 | continue; |
| 318 | } |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | /** Drive every seat that nobody is sitting in. */ |
| 323 | export function runBots(state: MatchState, dt: number): void { |
| 324 | for (const player of state.players) if (player.bot) botThink(state, player, dt); |
| 325 | } |