benjivers/val Public Public: anyone can read this; the crew can change it.

Files ecb025efab73708976ce1a513db2f3d585735455
411 lines · 13.9 KB · ~4.1k tok Raw History
1
import { expect, test } from "bun:test";
2
import { runBots } from "./bots.ts";
3
import {
4
  aliveOn,
5
  buyAbility,
6
  buyShield,
7
  buyWeapon,
8
  DEFUSE_CHECKPOINT,
9
  DEFUSE_TIME,
10
  FUSE_TIME,
11
  makePlayer,
12
  newMatch,
13
  PLANT_TIME,
14
  ROUND_TIME,
15
  setInput,
16
  SWAP_AFTER,
17
  tick,
18
  visible,
19
  WIN_ROUNDS,
20
} from "./match.ts";
21
import { siteCentre, spawnCells, wallBetween } from "./map.ts";
22
import type { MatchState, Player } from "./state.ts";
23
24
const live = (seed = 5): MatchState => {
25
  const state = newMatch(seed);
26
  state.phase = "live";
27
  state.clock = ROUND_TIME;
28
  return state;
29
};
30
const atk = (state: MatchState): Player[] => state.players.filter((p) => p.side === "atk");
31
const def = (state: MatchState): Player[] => state.players.filter((p) => p.side === "def");
32
const run = (state: MatchState, seconds: number): void => {
33
  for (let i = 0; i < Math.round(seconds * 30); i++) tick(state, 1 / 30);
34
};
35
36
/** Put a carrier on a site with the spike, everyone else out of the way. */
37
function onSite(state: MatchState): Player {
38
  const spot = siteCentre("A");
39
  const carrier = atk(state)[0]!;
40
  for (const player of state.players) {
41
    player.spike = false;
42
    player.x = 1200;
43
    player.y = 1320;
44
  }
45
  carrier.spike = true;
46
  carrier.x = spot.x;
47
  carrier.y = spot.y;
48
  state.spike = { carried: carrier.id, x: spot.x, y: spot.y, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 };
49
  return carrier;
50
}
51
52
test("a match starts in the buy phase with ten seats and a spike carrier", () => {
53
  const state = newMatch(1);
54
  expect(state.players.length).toBe(10);
55
  expect(atk(state).length).toBe(5);
56
  expect(def(state).length).toBe(5);
57
  expect(state.phase).toBe("buy");
58
  expect(state.players.some((p) => p.spike)).toBe(true);
59
  expect(state.players.every((p) => p.credits === 800)).toBe(true);
60
});
61
62
test("planting takes four seconds of standing on a site and arms the fuse", () => {
63
  const state = live();
64
  const carrier = onSite(state);
65
  carrier.input.use = true;
66
  run(state, PLANT_TIME - 1);
67
  expect(state.spike.planted).toBe(false);
68
  run(state, 1.2);
69
  expect(state.spike.planted).toBe(true);
70
  expect(state.spike.site).toBe("A");
71
  expect(Math.round(state.spike.fuse)).toBeLessThanOrEqual(FUSE_TIME);
72
  // The plant pays the attacking team and charges the planter's ult.
73
  expect(atk(state).every((p) => p.credits >= 800 + 300)).toBe(true);
74
  expect(carrier.ult).toBeGreaterThan(0);
75
});
76
77
test("a plant off a site does nothing at all", () => {
78
  const state = live();
79
  const carrier = onSite(state);
80
  carrier.x = 1200;
81
  carrier.y = 1320; // attacker spawn, not a site
82
  carrier.input.use = true;
83
  run(state, PLANT_TIME + 2);
84
  expect(state.spike.planted).toBe(false);
85
});
86
87
test("a defuse killed at the half way mark leaves the checkpoint for the next defender", () => {
88
  const state = live();
89
  const carrier = onSite(state);
90
  carrier.input.use = true;
91
  run(state, PLANT_TIME + 0.2);
92
  expect(state.spike.planted).toBe(true);
93
  carrier.input.use = false;
94
95
  const first = def(state)[0]!;
96
  first.x = state.spike.x;
97
  first.y = state.spike.y;
98
  first.input.use = true;
99
  run(state, DEFUSE_CHECKPOINT + 0.2);
100
  expect(state.spike.checkpoint).toBe(DEFUSE_CHECKPOINT);
101
  expect(state.spike.defuse).toBeGreaterThanOrEqual(DEFUSE_CHECKPOINT);
102
103
  // He dies here. The defuse falls back to the checkpoint, not to zero.
104
  first.hp = 1;
105
  first.alive = false;
106
  first.deaths += 1;
107
  state.spike.defuse = state.spike.checkpoint;
108
  expect(state.spike.defuse).toBe(DEFUSE_CHECKPOINT);
109
110
  const second = def(state)[1]!;
111
  second.x = state.spike.x;
112
  second.y = state.spike.y;
113
  second.input.use = true;
114
  // Half a defuse is left, so well under the full seven seconds finishes it.
115
  run(state, DEFUSE_TIME - DEFUSE_CHECKPOINT + 0.3);
116
  expect(state.phase).toBe("end");
117
  expect(state.events.some((e) => e.text.includes("defused"))).toBe(true);
118
});
119
120
test("an interrupted defuse before the checkpoint starts again from nothing", () => {
121
  const state = live();
122
  const carrier = onSite(state);
123
  carrier.input.use = true;
124
  run(state, PLANT_TIME + 0.2);
125
  carrier.input.use = false;
126
  const defender = def(state)[0]!;
127
  defender.x = state.spike.x;
128
  defender.y = state.spike.y;
129
  defender.input.use = true;
130
  run(state, 2);
131
  expect(state.spike.defuse).toBeGreaterThan(1.5);
132
  defender.input.use = false;
133
  run(state, 0.2);
134
  expect(state.spike.defuse).toBe(0);
135
  expect(state.spike.checkpoint).toBe(0);
136
});
137
138
test("the spike detonating wins the round for the attackers", () => {
139
  const state = live();
140
  const carrier = onSite(state);
141
  carrier.input.use = true;
142
  run(state, PLANT_TIME + 0.2);
143
  carrier.input.use = false;
144
  state.spike.fuse = 1;
145
  run(state, 1.5);
146
  expect(state.phase).toBe("end");
147
  expect(state.score[state.attackers]).toBe(1);
148
});
149
150
test("time running out with no plant is a defence win, but the clock stops mattering once it is down", () => {
151
  const timeout = live();
152
  timeout.clock = 0.5;
153
  run(timeout, 1);
154
  expect(timeout.phase).toBe("end");
155
  expect(timeout.score[timeout.attackers]).toBe(0);
156
157
  const planted = live();
158
  const carrier = onSite(planted);
159
  carrier.input.use = true;
160
  run(planted, PLANT_TIME + 0.2);
161
  carrier.input.use = false;
162
  planted.clock = 0.2;
163
  run(planted, 1);
164
  expect(planted.phase).toBe("live");
165
});
166
167
test("eliminating a team ends the round, and a planted spike still wins it", () => {
168
  const wipe = live();
169
  for (const player of def(wipe)) player.alive = false;
170
  run(wipe, 0.1);
171
  expect(wipe.phase).toBe("end");
172
  expect(wipe.score[wipe.attackers]).toBe(1);
173
174
  const traded = live();
175
  const carrier = onSite(traded);
176
  carrier.input.use = true;
177
  run(traded, PLANT_TIME + 0.2);
178
  for (const player of atk(traded)) player.alive = false;
179
  run(traded, 0.1);
180
  // Everyone is dead but the spike is armed: the round is not over yet.
181
  expect(traded.phase).toBe("live");
182
});
183
184
test("a buy is refused server-side when the credits are not there", () => {
185
  const state = newMatch(2);
186
  const player = state.players[0]!;
187
  player.agent = "ember";
188
  player.credits = 800;
189
  expect(buyWeapon(state, player, "vandal")).toBe(false);
190
  expect(player.weapon).toBe("classic");
191
  expect(buyWeapon(state, player, "ghost")).toBe(true);
192
  expect(player.credits).toBe(300);
193
  expect(buyShield(state, player, "heavy")).toBe(false);
194
  // 250 for the flash leaves fifty, and fifty buys nothing.
195
  expect(buyAbility(state, player, "c")).toBe(true);
196
  expect(player.credits).toBe(50);
197
  expect(buyAbility(state, player, "c")).toBe(false);
198
  expect(buyAbility(state, player, "q")).toBe(false);
199
  // And nothing is for sale once the round is live.
200
  player.credits = 9000;
201
  state.phase = "live";
202
  expect(buyWeapon(state, player, "vandal")).toBe(false);
203
});
204
205
test("a basic is bought by the charge and never past its limit", () => {
206
  const state = newMatch(2);
207
  const player = state.players.find((p) => p.agent === "veil")!;
208
  player.credits = 9000;
209
  expect(buyAbility(state, player, "c")).toBe(true);
210
  expect(buyAbility(state, player, "c")).toBe(true);
211
  expect(player.charges.c).toBe(2);
212
  expect(buyAbility(state, player, "c")).toBe(false);
213
  expect(buyAbility(state, player, "e")).toBe(false);
214
  expect(buyAbility(state, player, "x")).toBe(false);
215
});
216
217
test("sides swap after twelve rounds and a thirteenth round wins the match", () => {
218
  const state = newMatch(4);
219
  const started = state.attackers;
220
  state.round = SWAP_AFTER;
221
  state.phase = "live";
222
  state.score[started] = WIN_ROUNDS - 2;
223
  for (const player of def(state)) player.alive = false;
224
  run(state, 0.1);
225
  expect(state.phase).toBe("end");
226
  run(state, 6);
227
  expect(state.round).toBe(SWAP_AFTER + 1);
228
  expect(state.attackers).not.toBe(started);
229
  // One more round, taken by whoever now attacks the old defenders' side.
230
  const winners = state.attackers === started ? started : state.attackers;
231
  state.phase = "live";
232
  state.score[started] = WIN_ROUNDS - 1;
233
  for (const player of state.players) if (player.team !== started) player.alive = false;
234
  run(state, 0.1);
235
  expect(state.score[started]).toBe(WIN_ROUNDS);
236
  expect(state.phase).toBe("over");
237
  expect(state.result).toContain(`${started} win`);
238
  expect(winners.length).toBeGreaterThan(0);
239
});
240
241
test("an ended match never parks: it shows the result and starts a fresh one", () => {
242
  const state = newMatch(6);
243
  state.phase = "live";
244
  state.score[state.attackers] = WIN_ROUNDS - 1;
245
  for (const player of def(state)) player.alive = false;
246
  run(state, 0.1);
247
  expect(state.phase).toBe("over");
248
  expect(state.result).not.toBe(null);
249
  run(state, 13);
250
  expect(state.phase).toBe("buy");
251
  expect(state.round).toBe(1);
252
  expect(state.score).toEqual({ red: 0, blue: 0 });
253
  expect(state.result).toBe(null);
254
  expect(state.players.length).toBe(10);
255
});
256
257
test("line of sight is the server's answer: a smoke hides an enemy in the open", () => {
258
  const state = live();
259
  const viewer = atk(state)[0]!;
260
  const enemy = def(state)[0]!;
261
  viewer.x = 1200;
262
  viewer.y = 1320;
263
  enemy.x = 1200;
264
  enemy.y = 1000;
265
  expect(visible(state, viewer, enemy)).toBe(true);
266
  state.zones.push({
267
    id: 99, kind: "smoke", owner: "x", side: "def", x: 1200, y: 1160, r: 150,
268
    life: 8, fuse: 0, beat: 0, pulses: 0, damage: 0, blocks: true, solid: false,
269
  });
270
  expect(visible(state, viewer, enemy)).toBe(false);
271
  // A revealed enemy is seen through anything; so is a team mate.
272
  enemy.revealed = 2;
273
  expect(visible(state, viewer, enemy)).toBe(true);
274
});
275
276
test("a wall hides two players from each other, and open floor does not", () => {
277
  const state = live();
278
  const viewer = atk(state)[0]!;
279
  const enemy = def(state)[0]!;
280
  viewer.blind = 0;
281
  enemy.revealed = 0;
282
  // Either side of the block between A site and the middle of the map.
283
  viewer.x = 400;
284
  viewer.y = 400;
285
  enemy.x = 1200;
286
  enemy.y = 400;
287
  expect(wallBetween(viewer, enemy)).toBe(true);
288
  expect(visible(state, viewer, enemy)).toBe(false);
289
  // The same two down the open back corridor, with nothing in between.
290
  viewer.x = 400;
291
  viewer.y = 1400;
292
  enemy.x = 1000;
293
  enemy.y = 1400;
294
  expect(wallBetween(viewer, enemy)).toBe(false);
295
  expect(visible(state, viewer, enemy)).toBe(true);
296
});
297
298
test("no spawn can see a spawn: mid is staggered, not a corridor", () => {
299
  for (const from of spawnCells("atk"))
300
    for (const to of spawnCells("def")) expect(wallBetween(from, to)).toBe(true);
301
});
302
303
test("a dead player watches through the team, never through walls", () => {
304
  const state = live();
305
  const viewer = atk(state)[0]!;
306
  const mate = atk(state)[1]!;
307
  const enemy = def(state)[0]!;
308
  for (const player of atk(state)) {
309
    player.alive = false;
310
    player.x = 400;
311
    player.y = 400;
312
  }
313
  enemy.revealed = 0;
314
  enemy.x = 1000;
315
  enemy.y = 1400;
316
  // Every attacker is dead: the enemy across the map is nobody's to see.
317
  expect(visible(state, viewer, enemy)).toBe(false);
318
  // One team mate alive with a sight line, and the dead player sees it too.
319
  mate.alive = true;
320
  mate.x = 400;
321
  mate.y = 1400;
322
  expect(visible(state, viewer, enemy)).toBe(true);
323
  // That team mate behind a wall takes the vision away again.
324
  mate.x = 400;
325
  mate.y = 400;
326
  expect(visible(state, viewer, enemy)).toBe(false);
327
});
328
329
test("a blinded player sees nothing", () => {
330
  const state = live();
331
  const viewer = atk(state)[0]!;
332
  const enemy = def(state)[0]!;
333
  viewer.x = 1200;
334
  viewer.y = 1320;
335
  enemy.x = 1200;
336
  enemy.y = 1000;
337
  viewer.blind = 1.4;
338
  expect(visible(state, viewer, enemy)).toBe(false);
339
});
340
341
test("a killed carrier drops the spike where they fell", () => {
342
  const state = live();
343
  const carrier = onSite(state);
344
  const killer = def(state)[0]!;
345
  killer.x = carrier.x + 40;
346
  killer.y = carrier.y;
347
  killer.angle = Math.PI;
348
  killer.weapon = "operator";
349
  killer.input.fire = true;
350
  run(state, 1);
351
  if (!carrier.alive) {
352
    expect(state.spike.carried).toBe(null);
353
    expect(Math.hypot(state.spike.x - carrier.x, state.spike.y - carrier.y)).toBeLessThan(80);
354
  }
355
  expect(state.shots.length + state.events.length).toBeGreaterThan(0);
356
});
357
358
test("bots play the round: they buy, they take a site and they use their kit", () => {
359
  const state = newMatch(42);
360
  let casts = 0;
361
  let rounds = 0;
362
  for (let i = 0; i < 30 * 60 * 6; i++) {
363
    runBots(state, 1 / 30);
364
    tick(state, 1 / 30);
365
    casts += state.zones.length > 0 ? 1 : 0;
366
    if (state.round > rounds) rounds = state.round;
367
  }
368
  expect(rounds).toBeGreaterThan(1);
369
  expect(state.score.red + state.score.blue).toBeGreaterThan(1);
370
  // Something was on the map: smokes, flashes, mollies, walls.
371
  expect(casts).toBeGreaterThan(100);
372
  // And somebody bought a real gun with the credits they won.
373
  expect(state.players.some((p) => p.weapon !== "classic")).toBe(true);
374
  expect(aliveOn(state, "atk").length + aliveOn(state, "def").length).toBeGreaterThan(0);
375
});
376
377
378
test("no match ever has two players with the same name", () => {
379
  // The kill feed, the round log and the scoreboard all address people by
380
  // name; two Kits read as a team kill this game cannot have. A thousand
381
  // rosters, no collision.
382
  for (let seed = 0; seed < 1000; seed++) {
383
    const names = newMatch(seed).players.map((p) => p.name);
384
    expect(names.length).toBe(10);
385
    expect(new Set(names).size).toBe(10);
386
  }
387
});
388
389
test("a seat made on its own still gets a name of its own", () => {
390
  const state = newMatch(1234);
391
  const names = new Set<string>();
392
  for (const team of ["red", "blue"] as const)
393
    for (let i = 0; i < 5; i++) names.add(makePlayer(state, team, i).name);
394
  expect(names.size).toBe(10);
395
});
396
397
test("an aim angle from a client is kept in range, not taken as given", () => {
398
  const state = newMatch(9);
399
  const player = state.players[0]!;
400
  setInput(player, { angle: 99999 });
401
  expect(player.input.angle).toBeGreaterThanOrEqual(-Math.PI);
402
  expect(player.input.angle).toBeLessThanOrEqual(Math.PI);
403
  // A plain, already-sane angle is unchanged in any way that matters.
404
  setInput(player, { angle: 1.2 });
405
  expect(player.input.angle).toBeCloseTo(1.2, 9);
406
});
407
408
test("the deal is the seeded shuffle, so the same match replays the same", () => {
409
  expect(newMatch(77).players.map((p) => p.name)).toEqual(newMatch(77).players.map((p) => p.name));
410
  expect(newMatch(77).players.map((p) => p.name)).not.toEqual(newMatch(78).players.map((p) => p.name));
411
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts