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

forked from benjivers/val

workbenchthis machine
Files a0d9f596c8eff1543b804d104e75740aa115413c
230 lines · 8.4 KB · ~2.5k tok Raw History
1
/**
2
 * How a bot points a gun, tested as the pure maths it is, and then once
3
 * through a whole bot to prove the gate is actually wired to the trigger.
4
 */
5
6
import { expect, test } from "bun:test";
7
import {
8
  acquire,
9
  aimCone,
10
  canFire,
11
  CONE_FLOOR,
12
  crossRate,
13
  drift,
14
  emptyTrack,
15
  FORGET,
16
  heldFor,
17
  lose,
18
  REACTION_MAX,
19
  REACTION_MIN,
20
  reactionTime,
21
} from "./aim.ts";
22
import { botThink } from "./bots.ts";
23
import { newMatch, RUN_SPEED } from "./match.ts";
24
import type { MatchState, Player } from "./state.ts";
25
26
const rolls = (...values: number[]) => {
27
  let at = 0;
28
  return () => values[at++ % values.length]!;
29
};
30
31
test("the reaction a bot draws is between 200 and 350ms, whatever the roll", () => {
32
  expect(reactionTime(0)).toBeCloseTo(REACTION_MIN, 9);
33
  expect(reactionTime(1)).toBeCloseTo(REACTION_MAX, 9);
34
  expect(reactionTime(0.5)).toBeCloseTo((REACTION_MIN + REACTION_MAX) / 2, 9);
35
  // A roll out of range is still a reaction, not a bot that never shoots.
36
  expect(reactionTime(-3)).toBe(REACTION_MIN);
37
  expect(reactionTime(9)).toBe(REACTION_MAX);
38
});
39
40
test("the trigger gate is a pure function of when acquisition happened", () => {
41
  const track = emptyTrack();
42
  // Nothing in sight is never a shot, however long the round has run.
43
  expect(canFire(track, 100)).toBe(false);
44
45
  acquire(track, "bot-red-1", 10, { x: 0, y: 0 }, rolls(0, 0.5));
46
  expect(track.reaction).toBeCloseTo(REACTION_MIN, 9);
47
  expect(canFire(track, 10)).toBe(false);
48
  expect(canFire(track, 10.19)).toBe(false);
49
  expect(canFire(track, 10.21)).toBe(true);
50
  expect(canFire(track, 12)).toBe(true);
51
  expect(heldFor(track, 12)).toBeCloseTo(2, 9);
52
});
53
54
test("a new target starts the reaction again; the same one does not", () => {
55
  const track = emptyTrack();
56
  acquire(track, "one", 5, { x: 100, y: 100 }, rolls(1, 0.5));
57
  expect(track.since).toBe(5);
58
  acquire(track, "one", 5.3, { x: 180, y: 100 }, rolls(0, 0.5));
59
  expect(track.since).toBe(5); // the hold carries on
60
  expect(canFire(track, 5.4)).toBe(true);
61
  acquire(track, "two", 5.4, { x: 0, y: 0 }, rolls(0, 0.5));
62
  expect(track.since).toBe(5.4); // swinging onto somebody else costs again
63
  expect(canFire(track, 5.5)).toBe(false);
64
});
65
66
test("a hold survives a blink out of sight, and no longer", () => {
67
  const track = emptyTrack();
68
  acquire(track, "one", 1, { x: 0, y: 0 }, rolls(0, 0.5));
69
  lose(track, 1 + FORGET / 2);
70
  expect(track.id).toBe("one");
71
  // Back a moment later: the same hold, already through its reaction.
72
  acquire(track, "one", 1 + FORGET / 2, { x: 0, y: 0 }, rolls(0, 0.5));
73
  expect(track.since).toBe(1);
74
  // Gone for longer than that and it is a fresh acquisition.
75
  lose(track, 3);
76
  expect(track.id).toBe(null);
77
  acquire(track, "one", 3, { x: 0, y: 0 }, rolls(0, 0.5));
78
  expect(track.since).toBe(3);
79
  expect(canFire(track, 3.1)).toBe(false);
80
});
81
82
test("the cone starts wide, settles the longer one target is held, and never closes", () => {
83
  const swing = aimCone(0, 0);
84
  const second = aimCone(1, 0);
85
  const settled = aimCone(3, 0);
86
  expect(swing).toBeGreaterThan(0.08); // several degrees on the swing
87
  expect(second).toBeLessThan(swing / 2);
88
  expect(settled).toBeLessThan(second);
89
  expect(settled).toBeGreaterThanOrEqual(CONE_FLOOR);
90
  expect(aimCone(99, 0)).toBeCloseTo(CONE_FLOOR, 6);
91
});
92
93
test("a target crossing the view costs the bot accuracy", () => {
94
  expect(aimCone(3, 1)).toBeGreaterThan(aimCone(3, 0) * 3);
95
  expect(aimCone(3, 0.5)).toBeGreaterThan(aimCone(3, 0));
96
  expect(aimCone(3, 0.5)).toBeLessThan(aimCone(3, 1));
97
  // Nonsense from the caller is clamped, not multiplied out.
98
  expect(aimCone(3, 50)).toBeCloseTo(aimCone(3, 1), 9);
99
});
100
101
test("crossing is measured across the sight line, not along it", () => {
102
  const bot = { x: 0, y: 0 };
103
  const walking = emptyTrack();
104
  acquire(walking, "t", 0, { x: 300, y: 0 }, rolls(0, 0.5));
105
  // Straight down the barrel: nothing to track.
106
  expect(crossRate(walking, bot, { x: 260, y: 0 }, 0.1)).toBeCloseTo(0, 6);
107
  const strafing = emptyTrack();
108
  acquire(strafing, "t", 0, { x: 300, y: 0 }, rolls(0, 0.5));
109
  // Straight across it at a run: all of it but the turn of the sight line.
110
  expect(crossRate(strafing, bot, { x: 300, y: 25 }, 0.1)).toBeCloseTo(250, -1);
111
  // And it remembers where the target went, so the next tick measures again.
112
  expect(crossRate(strafing, bot, { x: 300, y: 25 }, 0.1)).toBeCloseTo(0, 6);
113
});
114
115
test("the error wanders inside the cone instead of being redrawn every tick", () => {
116
  const track = emptyTrack();
117
  acquire(track, "one", 0, { x: 0, y: 0 }, rolls(0, 1));
118
  // Drawn at the edge of the swing cone on acquisition.
119
  expect(Math.abs(track.error)).toBeCloseTo(aimCone(0, 0), 9);
120
  // One tick moves it a little, not to somewhere unrelated.
121
  const before = track.error;
122
  const after = drift(track, aimCone(0, 0), 1 / 30, rolls(0.9));
123
  expect(Math.abs(after - before)).toBeLessThan(aimCone(0, 0) / 4);
124
  // And it is squeezed toward the barrel as the cone closes around it.
125
  for (let i = 0; i < 90; i++) drift(track, aimCone(3, 0), 1 / 30, rolls(0.5, 1, 0));
126
  expect(Math.abs(track.error)).toBeLessThanOrEqual(aimCone(3, 0) + 1e-12);
127
});
128
129
// ------------------------------------------------------------- through a bot
130
131
const facing = (state: MatchState): { bot: Player; prey: Player } => {
132
  const bot = state.players.find((p) => p.side === "def")!;
133
  const prey = state.players.find((p) => p.side === "atk")!;
134
  // Nose to nose in the open, with nothing between them.
135
  bot.x = 1200;
136
  bot.y = 640;
137
  prey.x = 1200 + 200;
138
  prey.y = 640;
139
  bot.angle = 0;
140
  bot.input.angle = 0;
141
  bot.weapon = "vandal";
142
  bot.ammo = 25;
143
  return { bot, prey };
144
};
145
146
const live = (): MatchState => {
147
  const state = newMatch(4242);
148
  state.phase = "live";
149
  state.clock = 100;
150
  return state;
151
};
152
153
test("a bot that has just seen somebody does not shoot for a reaction", () => {
154
  const state = live();
155
  const { bot } = facing(state);
156
  let firstFire = -1;
157
  for (let i = 0; i < 30 && firstFire < 0; i++) {
158
    botThink(state, bot, 1 / 30);
159
    state.now += 1 / 30;
160
    if (bot.input.fire) firstFire = i / 30;
161
  }
162
  expect(firstFire).toBeGreaterThanOrEqual(REACTION_MIN);
163
  expect(firstFire).toBeLessThanOrEqual(REACTION_MAX + 0.15);
164
});
165
166
test("a bot that has been tracking you is far tighter than one that just swung on", () => {
167
  const state = live();
168
  const { bot, prey } = facing(state);
169
  const truth = () => Math.atan2(prey.y - bot.y, prey.x - bot.x);
170
  const spread: number[] = [];
171
  for (let i = 0; i < 120; i++) {
172
    botThink(state, bot, 1 / 30);
173
    state.now += 1 / 30;
174
    bot.angle = bot.input.angle; // the match loop's job, done by hand
175
    spread.push(Math.abs(bot.angle - truth()));
176
  }
177
  const swing = Math.max(...spread.slice(0, 15));
178
  const held = Math.max(...spread.slice(80));
179
  expect(swing).toBeGreaterThan(held * 2);
180
  expect(held).toBeLessThan(0.03);
181
});
182
183
test("a strafing target keeps a settled bot honest", () => {
184
  const still = live();
185
  const a = facing(still);
186
  const strafing = live();
187
  const b = facing(strafing);
188
  let stillWorst = 0;
189
  let strafeWorst = 0;
190
  for (let i = 0; i < 180; i++) {
191
    botThink(still, a.bot, 1 / 30);
192
    botThink(strafing, b.bot, 1 / 30);
193
    still.now += 1 / 30;
194
    strafing.now += 1 / 30;
195
    a.bot.angle = a.bot.input.angle;
196
    b.bot.angle = b.bot.input.angle;
197
    // One stands in the open; the other runs across the sight line.
198
    b.prey.y = 640 + Math.sin(i / 8) * 90;
199
    if (i > 60) {
200
      stillWorst = Math.max(stillWorst, Math.abs(a.bot.angle - Math.atan2(a.prey.y - a.bot.y, a.prey.x - a.bot.x)));
201
      strafeWorst = Math.max(strafeWorst, Math.abs(b.bot.angle - Math.atan2(b.prey.y - b.bot.y, b.prey.x - b.bot.x)));
202
    }
203
  }
204
  expect(strafeWorst).toBeGreaterThan(stillWorst);
205
});
206
207
test("the aim is still deterministic in the state: one seed, one round", () => {
208
  const play = (): number[] => {
209
    const state = live();
210
    const { bot, prey } = facing(state);
211
    const out: number[] = [];
212
    for (let i = 0; i < 60; i++) {
213
      botThink(state, bot, 1 / 30);
214
      state.now += 1 / 30;
215
      bot.angle = bot.input.angle;
216
      prey.x = 1400 + i;
217
      out.push(bot.angle, bot.input.fire ? 1 : 0);
218
    }
219
    return out;
220
  };
221
  expect(play()).toEqual(play());
222
});
223
224
test("a full run of speed is what the crossing term is measured against", () => {
225
  expect(RUN_SPEED).toBeGreaterThan(0);
226
  const track = emptyTrack();
227
  acquire(track, "t", 0, { x: 400, y: 0 }, rolls(0, 0.5));
228
  const rate = crossRate(track, { x: 0, y: 0 }, { x: 400, y: RUN_SPEED / 30 }, 1 / 30);
229
  expect(rate / RUN_SPEED).toBeCloseTo(1, 3);
230
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts