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

forked from benjivers/val

workbenchthis machine
Files 967e12d2fa288842a7c42ddda720f0c8a28efc34
1
/**
2
 * The bots' flashes, measured.
3
 *
4
 * A flash used to reach 900 units — 70% of the map — and a bot threw every
5
 * charge the moment it was bought, so in a bot round somebody was blind
6
 * most of the time. The numbers below are the bug and the fix: a headless
7
 * match, the share of live player-seconds spent blind, and how many flashes
8
 * go off a round. Before this file: 4.4% blind, 6.5 flashes a round, and a
9
 * flash blinded 0.86 players on average — 0.20 of them the thrower, 0.43 a
10
 * team mate, 0.23 an enemy.
11
 */
12
13
import { expect, test } from "bun:test";
14
import { agentById } from "./abilities.ts";
15
import { attackSite, FLASH_REPEAT, FLASH_SPACING, flashCall, flashSpot, runBots } from "./bots.ts";
16
import { siteCentre } from "./map.ts";
17
import { newMatch, ROUND_TIME, tick } from "./match.ts";
18
import type { MatchState, Player } from "./state.ts";
19
20
const flashZones = (state: MatchState): Set<number> =>
21
  new Set(state.zones.filter((z) => z.kind === "flash").map((z) => z.id));
22
23
/** Held where it stands: a plant in progress is the one thing that sets a body's speed to zero. */
24
const pin = (p: Player): void => {
25
  p.planting = 1;
26
};
27
28
/**
29
 * One live round in the side corridor of the site the attack is going to:
30
 * an Ember bot with two flashes at the bottom of it, 860 from the site — an
31
 * entry, as far as its kit is concerned — and an enemy standing still at
32
 * the top facing down it, 800 away, so a 620 throw pops 180 in front of
33
 * the enemy's face. Everybody else is parked on the far site, not looking.
34
 */
35
function corridor(): { state: MatchState; bot: Player; enemy: Player; mates: Player[]; x: number } {
36
  const state = newMatch(21);
37
  state.phase = "live";
38
  state.clock = ROUND_TIME;
39
  const site = attackSite(state);
40
  const x = site === "A" ? 120 : 2280;
41
  const far = siteCentre(site === "A" ? "B" : "A");
42
  const atk = state.players.filter((p) => p.side === "atk");
43
  const def = state.players.filter((p) => p.side === "def");
44
  for (const p of state.players) {
45
    p.bot = false;
46
    p.x = far.x;
47
    p.y = far.y;
48
    p.angle = Math.PI / 2;
49
    p.input.angle = p.angle;
50
    p.input.mx = 0;
51
    p.input.my = 0;
52
    p.spike = false;
53
  }
54
  state.spike.carried = null;
55
  state.spike.x = far.x;
56
  state.spike.y = far.y;
57
  const bot = atk[0]!;
58
  bot.bot = true;
59
  bot.agent = "ember";
60
  bot.charges = { c: 2, q: 0, e: 0, x: 0 };
61
  bot.x = x;
62
  bot.y = 1150;
63
  bot.angle = -Math.PI / 2;
64
  bot.input.angle = bot.angle;
65
  pin(bot);
66
  const enemy = def[0]!;
67
  enemy.x = x;
68
  enemy.y = 350;
69
  enemy.angle = Math.PI / 2;
70
  enemy.input.angle = Math.PI / 2;
71
  // A sandbag: the bot shoots at it too, and these tests are about the flash.
72
  enemy.hp = 1e6;
73
  return { state, bot, enemy, mates: atk.slice(1), x };
74
}
75
76
const play = (state: MatchState, seconds: number, seen?: Set<number>): void => {
77
  for (let i = 0; i < Math.round(seconds * 30); i++) {
78
    runBots(state, 1 / 30);
79
    tick(state, 1 / 30);
80
    if (seen) for (const id of flashZones(state)) seen.add(id);
81
  }
82
};
83
84
test("a bot throws one flash at the enemy in front of it, not both charges at once", () => {
85
  const { state, bot, enemy } = corridor();
86
  const seen = new Set<number>();
87
  play(state, 0.1, seen);
88
  expect(seen.size).toBe(1);
89
  // The one that went does its job: the enemy facing the thrower is blinded.
90
  play(state, 1.5, seen);
91
  expect(enemy.blind).toBeGreaterThan(0);
92
  // The second charge stays in hand for the repeat, however often it thinks.
93
  play(state, FLASH_REPEAT - 2, seen);
94
  expect(seen.size).toBe(1);
95
  expect(bot.charges.c).toBe(1);
96
  // And it is not lost: once the repeat has passed it goes.
97
  for (let i = 0; i < 3 * 30 && seen.size < 2; i++) play(state, 1 / 30, seen);
98
  expect(seen.size).toBe(2);
99
});
100
101
test("a team waits between flashes: one bot's throw holds a team mate's", () => {
102
  const { state, bot, mates, x } = corridor();
103
  const mate = mates[0]!;
104
  // A second thrower right beside the first, with two flashes of its own.
105
  mate.bot = true;
106
  mate.agent = "ember";
107
  mate.charges = { c: 2, q: 0, e: 0, x: 0 };
108
  mate.x = x;
109
  mate.y = 1190;
110
  mate.angle = -Math.PI / 2;
111
  mate.input.angle = mate.angle;
112
  pin(mate);
113
  play(state, FLASH_SPACING - 0.5);
114
  expect(4 - bot.charges.c - mate.charges.c).toBe(1);
115
  // Past the team's spacing the other thrower's own goes.
116
  play(state, 1.5);
117
  expect(4 - bot.charges.c - mate.charges.c).toBe(2);
118
  expect(bot.charges.c).toBe(1);
119
  expect(mate.charges.c).toBe(1);
120
});
121
122
test("a bot holds the flash while a team mate stands in it facing the pop", () => {
123
  const { state, bot, mates, enemy, x } = corridor();
124
  const mate = mates[0]!;
125
  mate.x = x;
126
  mate.y = 650;
127
  mate.angle = -Math.PI / 2;
128
  mate.input.angle = mate.angle;
129
  play(state, 2);
130
  expect(bot.charges.c).toBe(2);
131
  expect(flashZones(state).size).toBe(0);
132
  // The same team mate back beside the thrower, looking the other way, is
133
  // not in the flash's business: the throw goes, and lands on the enemy.
134
  mate.y = 1200;
135
  mate.angle = Math.PI / 2;
136
  mate.input.angle = mate.angle;
137
  play(state, 2);
138
  expect(bot.charges.c).toBe(1);
139
  expect(mate.blind).toBe(0);
140
  expect(enemy.blind).toBeGreaterThan(0);
141
});
142
143
test("a bot never throws a flash it could run into, or one that pops behind the enemy's head", () => {
144
  const { state, bot, enemy, x } = corridor();
145
  const ability = agentById("ember").abilities.c;
146
  // Into the wall right behind it: the pop would land inside its own radius.
147
  expect(flashCall(state, bot, ability, { x, y: 1440 }, null)).toBe(false);
148
  // Up the corridor at the enemy 800 out: the pop lands in front of it.
149
  const spot = flashSpot(bot, ability, enemy);
150
  expect(spot.y).toBeGreaterThan(enemy.y);
151
  expect(flashCall(state, bot, ability, enemy, enemy)).toBe(true);
152
  // An enemy 450 out facing the thrower: the throw flies 620 and pops behind
153
  // them, where they are not looking. Not thrown.
154
  enemy.y = 700;
155
  expect(flashCall(state, bot, ability, enemy, enemy)).toBe(false);
156
  // Turned around, that same pop is right in front of them. Thrown.
157
  enemy.angle = -Math.PI / 2;
158
  expect(flashCall(state, bot, ability, enemy, enemy)).toBe(true);
159
  enemy.angle = Math.PI / 2;
160
  enemy.input.angle = enemy.angle;
161
  play(state, 2);
162
  expect(bot.charges.c).toBe(2);
163
});
164
165
test("a flash is for taking a site, not for every duel on the way", () => {
166
  const { state, bot } = corridor();
167
  // The same bot and the same enemy, but far from the site it is attacking.
168
  bot.y = 1400;
169
  const enemyY = 600;
170
  const enemy = state.players.find((p) => p.side === "def" && p.y === 350)!;
171
  enemy.y = enemyY;
172
  play(state, 2);
173
  expect(bot.charges.c).toBe(2);
174
});
175
176
/** A headless match, and the numbers the issue is about. */
177
function measure(seeds: number[], minutes: number) {
178
  let liveSeconds = 0;
179
  let blindSeconds = 0;
180
  let anyBlindSeconds = 0;
181
  let flashes = 0;
182
  let rounds = 0;
183
  const hit = { self: 0, own: 0, enemy: 0 };
184
  for (const seed of seeds) {
185
    const state = newMatch(seed);
186
    const seen = new Set<number>();
187
    const before = new Map<string, number>();
188
    let round = 0;
189
    for (let i = 0; i < 30 * 60 * minutes && state.phase !== "over"; i++) {
190
      if (state.round !== round) {
191
        round = state.round;
192
        rounds++;
193
      }
194
      runBots(state, 1 / 30);
195
      const popping = state.phase === "live" ? state.zones.filter((z) => z.kind === "flash" && z.fuse > 0 && z.fuse - 1 / 30 <= 0) : [];
196
      for (const p of state.players) before.set(p.id, p.blind);
197
      tick(state, 1 / 30);
198
      for (const id of flashZones(state))
199
        if (!seen.has(id)) {
200
          seen.add(id);
201
          flashes++;
202
        }
203
      const pop = popping[0];
204
      if (pop)
205
        for (const p of state.players)
206
          if (p.blind > (before.get(p.id) ?? 0)) hit[pop.owner === p.id ? "self" : pop.side === p.side ? "own" : "enemy"]++;
207
      if (state.phase !== "live") continue;
208
      const alive = state.players.filter((p) => p.alive);
209
      liveSeconds += alive.length / 30;
210
      const blind = alive.filter((p) => p.blind > 0).length;
211
      blindSeconds += blind / 30;
212
      if (blind > 0) anyBlindSeconds += 1 / 30;
213
    }
214
  }
215
  const per = (n: number) => n / Math.max(1, flashes);
216
  return {
217
    rounds,
218
    flashesPerRound: flashes / rounds,
219
    blindShare: blindSeconds / liveSeconds,
220
    anyoneBlindShare: anyBlindSeconds / (liveSeconds / 10),
221
    perFlash: { self: per(hit.self), own: per(hit.own), enemy: per(hit.enemy) },
222
  };
223
}
224
225
test("a flash is a tool, not weather: the blind share of a bot match, measured", () => {
226
  const m = measure([1, 2, 3, 4], 10);
227
  const pct = (n: number) => `${(n * 100).toFixed(2)}%`;
228
  const f = (n: number) => n.toFixed(2);
229
  console.log(
230
    `flashes: ${m.rounds} rounds, ${f(m.flashesPerRound)} a round (was 6.48); blind ${pct(m.blindShare)} of live player-seconds (was 4.36%), ` +
231
      `somebody blind ${pct(m.anyoneBlindShare)} of live seconds (was 15.97%); a flash blinds ${f(m.perFlash.self)} self / ${f(m.perFlash.own)} own / ${f(m.perFlash.enemy)} enemy (was 0.20 / 0.43 / 0.23)`,
232
  );
233
  expect(m.rounds).toBeGreaterThan(20);
234
  expect(m.flashesPerRound).toBeLessThan(2);
235
  expect(m.blindShare).toBeLessThan(0.015);
236
  expect(m.anyoneBlindShare).toBeLessThan(0.1);
237
  // A flash is thrown at the other team: it blinds them, and not its own.
238
  expect(m.perFlash.self).toBeLessThan(0.01);
239
  expect(m.perFlash.own).toBeLessThan(0.05);
240
  expect(m.perFlash.enemy).toBeGreaterThan(0.5);
241
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts