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

forked from benjivers/val

workbenchthis machine
Files wren/38-walkdistance-say-which-argument-is-cache
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
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts