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

forked from benjivers/val

workbenchthis machine
Files 9437cb3d70375fd62d025a6905df31145f578113
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 { pathStep, siteAt, siteCentre, walkDistance, type Point, type Site } from "./map.ts";
12
import { buyAbility, buyShield, buyWeapon, random, SPIKE_REACH, visible } from "./match.ts";
13
import type { MatchState, Player, Slot } from "./state.ts";
14
import { WEAPONS, type WeaponId } from "./weapons.ts";
15
16
/**
17
 * The site the attacking bots agree on this round. It comes from the match
18
 * id and the round number, never the running seed: five bots have to reach
19
 * the same answer on every tick or they walk in circles between the sites.
20
 */
21
export function attackSite(state: MatchState): Site {
22
  let hash = 0;
23
  for (const ch of state.id) hash = (hash * 31 + ch.charCodeAt(0)) % 9973;
24
  return (hash + state.round) % 2 === 0 ? "A" : "B";
25
}
26
27
const holdSpots = (state: MatchState): Point[] => {
28
  const a = siteCentre("A");
29
  const b = siteCentre("B");
30
  return [
31
    { x: a.x + 60, y: a.y + 90 },
32
    { x: a.x - 40, y: a.y - 20 },
33
    { x: 1200, y: 640 },
34
    { x: b.x - 60, y: b.y + 90 },
35
    { x: b.x + 40, y: b.y - 20 },
36
  ];
37
};
38
39
const ladder: { weapon: WeaponId; shield: "light" | "heavy" | null; floor: number }[] = [
40
  { weapon: "operator", shield: "heavy", floor: 6200 },
41
  { weapon: "vandal", shield: "heavy", floor: 4400 },
42
  { weapon: "phantom", shield: "heavy", floor: 4200 },
43
  { weapon: "spectre", shield: "heavy", floor: 2900 },
44
  { weapon: "spectre", shield: "light", floor: 2100 },
45
  { weapon: "sheriff", shield: "light", floor: 1300 },
46
  { weapon: "ghost", shield: "light", floor: 950 },
47
  { weapon: "classic", shield: "light", floor: 400 },
48
];
49
50
/**
51
 * What a bot buys. It saves on a broken economy — under 2000 with an empty
52
 * gun rack is an eco round, and the credits are worth more next round.
53
 */
54
export function botBuy(state: MatchState, player: Player): void {
55
  const saving = player.credits < 2000 && state.round > 1 && player.weapon === "classic" && state.lossStreak[player.team] > 0;
56
  if (!saving) {
57
    const pick = ladder.find((step) => player.credits >= step.floor);
58
    if (pick && WEAPONS[pick.weapon].cost > 0) buyWeapon(state, player, pick.weapon);
59
    if (pick?.shield) buyShield(state, player, player.credits > 3500 ? "heavy" : pick.shield);
60
  }
61
  const agent = agentById(player.agent);
62
  // Abilities last: a flash is worth more than the last hundred credits.
63
  for (const slot of ["c", "q", "c", "q"] as Slot[]) {
64
    const ability = agent.abilities[slot];
65
    if (player.credits >= ability.cost + (saving ? 400 : 0)) buyAbility(state, player, slot);
66
  }
67
}
68
69
const enemiesSeen = (state: MatchState, bot: Player): Player[] =>
70
  state.players.filter((p) => p.alive && p.side !== bot.side && visible(state, bot, p));
71
72
const towards = (bot: Player, point: Point): { mx: number; my: number } => {
73
  const dx = point.x - bot.x;
74
  const dy = point.y - bot.y;
75
  const len = Math.hypot(dx, dy) || 1;
76
  return { mx: dx / len, my: dy / len };
77
};
78
79
const turn = (bot: Player, target: number, rate: number): void => {
80
  let diff = ((target - bot.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
81
  bot.input.angle = bot.angle + Math.max(-rate, Math.min(rate, diff));
82
};
83
84
/** One bot's decision for this tick. */
85
export function botThink(state: MatchState, bot: Player, dt: number): void {
86
  if (!bot.alive) return;
87
  if (state.phase === "buy") {
88
    if (bot.think <= 0) {
89
      botBuy(state, bot);
90
      bot.think = 99;
91
    }
92
    bot.input.mx = 0;
93
    bot.input.my = 0;
94
    return;
95
  }
96
  // The buy left the timer parked; the first live tick starts it thinking.
97
  if (bot.think > 5) bot.think = 0;
98
  bot.think -= dt;
99
  const spike = state.spike;
100
  const site = spike.planted && spike.site ? spike.site : attackSite(state);
101
  const target = enemiesSeen(state, bot).sort(
102
    (a, b) => Math.hypot(a.x - bot.x, a.y - bot.y) - Math.hypot(b.x - bot.x, b.y - bot.y),
103
  )[0];
104
105
  // --- where to be
106
  let goal: Point = siteCentre(site);
107
  if (bot.side === "atk") {
108
    if (spike.planted) goal = { x: spike.x + (random(state) - 0.5) * 220, y: spike.y + (random(state) - 0.5) * 220 };
109
    else if (!spike.carried && !bot.spike) {
110
      const loose = { x: spike.x, y: spike.y };
111
      if (walkDistance(bot, loose) < 8) goal = loose;
112
    }
113
  } else {
114
    const spots = holdSpots(state);
115
    const index = Number(bot.id.slice(-1)) % spots.length;
116
    goal = spike.planted ? { x: spike.x, y: spike.y } : spots[index]!;
117
  }
118
  bot.goal = goal;
119
120
  const distToGoal = Math.hypot(goal.x - bot.x, goal.y - bot.y);
121
  const step = pathStep(bot, goal);
122
  const walk = towards(bot, step);
123
  bot.input.mx = walk.mx;
124
  bot.input.my = walk.my;
125
126
  // --- the kit
127
  if (bot.think <= 0) {
128
    bot.think = 0.4 + random(state) * 0.5;
129
    useKit(state, bot, target ?? null, site, distToGoal);
130
  }
131
132
  // --- the spike
133
  bot.input.use = false;
134
  if (bot.side === "atk" && bot.spike && !spike.planted) {
135
    const onSite = siteAt(bot.x, bot.y) === site;
136
    if (onSite && (!target || bot.planting > 0)) {
137
      bot.input.use = true;
138
      bot.input.mx = 0;
139
      bot.input.my = 0;
140
    }
141
  }
142
  if (bot.side === "def" && spike.planted && Math.hypot(bot.x - spike.x, bot.y - spike.y) < SPIKE_REACH - 6) {
143
    const safe = !target || spike.fuse < 8;
144
    if (safe) {
145
      bot.input.use = true;
146
      bot.input.mx = 0;
147
      bot.input.my = 0;
148
    }
149
  }
150
  if (bot.blind > 0) {
151
    // Blinded: back off and stop shooting, like a person would.
152
    bot.input.fire = false;
153
    bot.input.mx *= -0.6;
154
    bot.input.my *= -0.6;
155
    return;
156
  }
157
158
  // --- the gun
159
  bot.input.reload = bot.ammo === 0;
160
  if (!target) {
161
    bot.input.fire = false;
162
    if (Math.hypot(bot.input.mx, bot.input.my) > 0.01)
163
      turn(bot, Math.atan2(bot.input.my, bot.input.mx), 6 * dt);
164
    return;
165
  }
166
  const range = Math.hypot(target.x - bot.x, target.y - bot.y);
167
  const aim = Math.atan2(target.y - bot.y, target.x - bot.x) + (random(state) - 0.5) * 0.06;
168
  turn(bot, aim, 7 * dt);
169
  const off = Math.abs(((aim - bot.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI);
170
  const weapon = WEAPONS[bot.weapon];
171
  bot.input.fire = off < 0.07 && range < weapon.range && bot.ammo > 0;
172
  // Stand still to shoot at range, close the gap up front.
173
  if (range > 420) {
174
    bot.input.mx *= 0.15;
175
    bot.input.my *= 0.15;
176
  } else if (range < 160) {
177
    const away = towards(bot, target);
178
    bot.input.mx = -away.mx * 0.5;
179
    bot.input.my = -away.my * 0.5;
180
  }
181
}
182
183
/** Ability decisions, kept in one place so each is a sentence you can read. */
184
function useKit(state: MatchState, bot: Player, target: Player | null, site: Site, distToGoal: number): void {
185
  const agent = agentById(bot.agent);
186
  const spike = state.spike;
187
  const centre = siteCentre(site);
188
  const aimAt = (point: Point): void => {
189
    bot.input.angle = Math.atan2(point.y - bot.y, point.x - bot.x);
190
    bot.angle = bot.input.angle;
191
  };
192
  const tryCast = (slot: Slot, point: Point): boolean => {
193
    if (!castable(bot, slot)) return false;
194
    aimAt(point);
195
    bot.input.cast = slot;
196
    return true;
197
  };
198
199
  for (const slot of ["x", "c", "q", "e"] as Slot[]) {
200
    const ability = agent.abilities[slot];
201
    if (!castable(bot, slot)) continue;
202
    const kind = ability.kind;
203
204
    if (kind === "rage" || kind === "haste") {
205
      if (bot.hp < 70 || (target && distToGoal < 500)) return void tryCast(slot, centre);
206
      continue;
207
    }
208
    if (kind === "revealall" || kind === "slowall") {
209
      if (target || distToGoal < 420) return void tryCast(slot, centre);
210
      continue;
211
    }
212
    if (kind === "heal") {
213
      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);
214
      if (hurt) return void tryCast(slot, hurt);
215
      continue;
216
    }
217
    if (kind === "molly") {
218
      // Fire on the planted spike, or on the choke ahead of us.
219
      if (bot.side === "def" && spike.planted && Math.hypot(bot.x - spike.x, bot.y - spike.y) < ability.range)
220
        return void tryCast(slot, spike);
221
      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)
222
        return void tryCast(slot, target);
223
      continue;
224
    }
225
    if (kind === "flash") {
226
      const enemyClose = target && Math.hypot(target.x - bot.x, target.y - bot.y) < ability.range;
227
      if (enemyClose) return void tryCast(slot, target!);
228
      if (bot.side === "atk" && distToGoal < ability.range && distToGoal > 150) return void tryCast(slot, centre);
229
      continue;
230
    }
231
    if (kind === "smoke") {
232
      if (bot.side === "atk" && distToGoal < ability.range && distToGoal > 260) return void tryCast(slot, centre);
233
      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)
234
        return void tryCast(slot, spike);
235
      continue;
236
    }
237
    if (kind === "dart") {
238
      if (distToGoal < ability.range && distToGoal > 200) return void tryCast(slot, centre);
239
      continue;
240
    }
241
    if (kind === "wall") {
242
      if (target && Math.hypot(target.x - bot.x, target.y - bot.y) < ability.range && bot.hp < 60)
243
        return void tryCast(slot, target);
244
      if (bot.side === "def" && !spike.planted && distToGoal < 200 && state.clock > 60) return void tryCast(slot, centre);
245
      continue;
246
    }
247
    if (kind === "trip") {
248
      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 });
249
      continue;
250
    }
251
  }
252
}
253
254
/** Drive every seat that nobody is sitting in. */
255
export function runBots(state: MatchState, dt: number): void {
256
  for (const player of state.players) if (player.bot) botThink(state, player, dt);
257
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts