fable/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files 4990f87c0167ca7cfa9c9a4a67fa43bcfef26454
| 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 } from "./abilities.ts"; |
| 11 | import { acquire, aimCone, canFire, crossRate, drift, heldFor, lose } from "./aim.ts"; |
| 12 | import { pathStep, 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 | |
| 133 | // --- the spike |
| 134 | bot.input.use = false; |
| 135 | if (bot.side === "atk" && bot.spike && !spike.planted) { |
| 136 | const onSite = siteAt(bot.x, bot.y) === site; |
| 137 | if (onSite && (!target || bot.planting > 0)) { |
| 138 | bot.input.use = true; |
| 139 | bot.input.mx = 0; |
| 140 | bot.input.my = 0; |
| 141 | } |
| 142 | } |
| 143 | if (bot.side === "def" && spike.planted && Math.hypot(bot.x - spike.x, bot.y - spike.y) < SPIKE_REACH - 6) { |
| 144 | const safe = !target || spike.fuse < 8; |
| 145 | if (safe) { |
| 146 | bot.input.use = true; |
| 147 | bot.input.mx = 0; |
| 148 | bot.input.my = 0; |
| 149 | } |
| 150 | } |
| 151 | if (bot.blind > 0) { |
| 152 | // Blinded: back off and stop shooting, like a person would. |
| 153 | bot.input.fire = false; |
| 154 | bot.input.mx *= -0.6; |
| 155 | bot.input.my *= -0.6; |
| 156 | return; |
| 157 | } |
| 158 | |
| 159 | // --- the gun |
| 160 | bot.input.reload = bot.ammo === 0; |
| 161 | if (!target) { |
| 162 | lose(bot.aim, state.now); |
| 163 | bot.input.fire = false; |
| 164 | if (Math.hypot(bot.input.mx, bot.input.my) > 0.01) |
| 165 | turn(bot, Math.atan2(bot.input.my, bot.input.mx), 6 * dt); |
| 166 | return; |
| 167 | } |
| 168 | // A hold costs what a hold costs: a moment to notice, a barrel that starts |
| 169 | // wrong and settles, and more of both when the target is crossing the view. |
| 170 | const roll = () => random(state); |
| 171 | acquire(bot.aim, target.id, state.now, target, roll); |
| 172 | const crossing = crossRate(bot.aim, bot, target, dt) / RUN_SPEED; |
| 173 | const cone = aimCone(heldFor(bot.aim, state.now), crossing); |
| 174 | const range = Math.hypot(target.x - bot.x, target.y - bot.y); |
| 175 | const aim = Math.atan2(target.y - bot.y, target.x - bot.x) + drift(bot.aim, cone, dt, roll); |
| 176 | turn(bot, aim, 7 * dt); |
| 177 | const off = Math.abs(((aim - bot.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI); |
| 178 | const weapon = WEAPONS[bot.weapon]; |
| 179 | bot.input.fire = canFire(bot.aim, state.now) && off < 0.07 && range < weapon.range && bot.ammo > 0; |
| 180 | // Stand still to shoot at range, close the gap up front. |
| 181 | if (range > 420) { |
| 182 | bot.input.mx *= 0.15; |
| 183 | bot.input.my *= 0.15; |
| 184 | } else if (range < 160) { |
| 185 | const away = towards(bot, target); |
| 186 | bot.input.mx = -away.mx * 0.5; |
| 187 | bot.input.my = -away.my * 0.5; |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | /** Ability decisions, kept in one place so each is a sentence you can read. */ |
| 192 | function useKit(state: MatchState, bot: Player, target: Player | null, site: Site, distToGoal: number): void { |
| 193 | const agent = agentById(bot.agent); |
| 194 | const spike = state.spike; |
| 195 | const centre = siteCentre(site); |
| 196 | const aimAt = (point: Point): void => { |
| 197 | bot.input.angle = Math.atan2(point.y - bot.y, point.x - bot.x); |
| 198 | bot.angle = bot.input.angle; |
| 199 | }; |
| 200 | const tryCast = (slot: Slot, point: Point): boolean => { |
| 201 | if (!castable(bot, slot)) return false; |
| 202 | aimAt(point); |
| 203 | bot.input.cast = slot; |
| 204 | return true; |
| 205 | }; |
| 206 | |
| 207 | for (const slot of ["x", "c", "q", "e"] as Slot[]) { |
| 208 | const ability = agent.abilities[slot]; |
| 209 | if (!castable(bot, slot)) continue; |
| 210 | const kind = ability.kind; |
| 211 | |
| 212 | if (kind === "rage" || kind === "haste") { |
| 213 | if (bot.hp < 70 || (target && distToGoal < 500)) return void tryCast(slot, centre); |
| 214 | continue; |
| 215 | } |
| 216 | if (kind === "revealall" || kind === "slowall") { |
| 217 | if (target || distToGoal < 420) return void tryCast(slot, centre); |
| 218 | continue; |
| 219 | } |
| 220 | if (kind === "heal") { |
| 221 | 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); |
| 222 | if (hurt) return void tryCast(slot, hurt); |
| 223 | continue; |
| 224 | } |
| 225 | if (kind === "molly") { |
| 226 | // Fire on the planted spike, or on the choke ahead of us. |
| 227 | if (bot.side === "def" && spike.planted && Math.hypot(bot.x - spike.x, bot.y - spike.y) < ability.range) |
| 228 | return void tryCast(slot, spike); |
| 229 | 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) |
| 230 | return void tryCast(slot, target); |
| 231 | continue; |
| 232 | } |
| 233 | if (kind === "flash") { |
| 234 | const enemyClose = target && Math.hypot(target.x - bot.x, target.y - bot.y) < ability.range; |
| 235 | if (enemyClose) return void tryCast(slot, target!); |
| 236 | if (bot.side === "atk" && distToGoal < ability.range && distToGoal > 150) return void tryCast(slot, centre); |
| 237 | continue; |
| 238 | } |
| 239 | if (kind === "smoke") { |
| 240 | if (bot.side === "atk" && distToGoal < ability.range && distToGoal > 260) return void tryCast(slot, centre); |
| 241 | 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) |
| 242 | return void tryCast(slot, spike); |
| 243 | continue; |
| 244 | } |
| 245 | if (kind === "dart") { |
| 246 | if (distToGoal < ability.range && distToGoal > 200) return void tryCast(slot, centre); |
| 247 | continue; |
| 248 | } |
| 249 | if (kind === "wall") { |
| 250 | if (target && Math.hypot(target.x - bot.x, target.y - bot.y) < ability.range && bot.hp < 60) |
| 251 | return void tryCast(slot, target); |
| 252 | if (bot.side === "def" && !spike.planted && distToGoal < 200 && state.clock > 60) return void tryCast(slot, centre); |
| 253 | continue; |
| 254 | } |
| 255 | if (kind === "trip") { |
| 256 | 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 }); |
| 257 | continue; |
| 258 | } |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | /** Drive every seat that nobody is sitting in. */ |
| 263 | export function runBots(state: MatchState, dt: number): void { |
| 264 | for (const player of state.players) if (player.bot) botThink(state, player, dt); |
| 265 | } |