pike/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine
Files 229dad72036da758215f63a6f96e2ff048659d3d
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
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts