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

forked from benjivers/val

workbenchthis machine
Files 00e29188271b60cb71e377df120e603f5649cfdb
1
/**
2
 * The match: one round loop, server-authoritative, and the only place a
3
 * number changes.
4
 *
5
 * `tick(state, dt)` is the whole game — call it with a fixed step and it
6
 * runs without a clock, a socket or a browser, which is how the tests play
7
 * whole rounds in a millisecond. Inputs arrive as intent (a direction, an
8
 * angle, a held key); nothing a client says is taken as a position.
9
 */
10
11
import { cast, castable, agentById, buyableSlots, flashDuration, sightBlocked, SLOTS } from "./abilities.ts";
12
import { award, clampCredits, roundReward, spend, START_CREDITS } from "./economy.ts";
13
import {
14
  blockedCircle,
15
  clearPaths,
16
  moveCircle,
17
  orbSpots,
18
  rayHitWall,
19
  segmentPoint,
20
  siteAt,
21
  spawnCells,
22
  type Point,
23
} from "./map.ts";
24
import { emptyInput, type Input, type MatchState, type Player, type Side, type Slot, type Team } from "./state.ts";
25
import { applyDamage, damage, shotSpread, SHIELD, WEAPONS, zoneFromMiss, type WeaponId } from "./weapons.ts";
26
27
export const PLAYER_R = 16;
28
export const RUN_SPEED = 250;
29
export const BUY_TIME = 14;
30
export const ROUND_TIME = 100;
31
export const PLANT_TIME = 4;
32
export const FUSE_TIME = 45;
33
export const DEFUSE_TIME = 7;
34
export const DEFUSE_CHECKPOINT = 3.5;
35
export const END_TIME = 5;
36
export const OVER_TIME = 12;
37
export const SWAP_AFTER = 12;
38
export const WIN_ROUNDS = 13;
39
export const ROSTER = 5;
40
export const SPIKE_REACH = 46;
41
42
const TEAMS: Team[] = ["red", "blue"];
43
44
/** Deterministic randomness: the same seed plays the same round. */
45
export function random(state: MatchState): number {
46
  state.seed = (state.seed + 0x6d2b79f5) | 0;
47
  let t = state.seed;
48
  t = Math.imul(t ^ (t >>> 15), t | 1);
49
  t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
50
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
51
}
52
53
const say = (state: MatchState, text: string): void => {
54
  state.events.unshift({ text, age: 0 });
55
  state.events.length = Math.min(state.events.length, 12);
56
};
57
58
export const sideOf = (state: MatchState, team: Team): Side =>
59
  team === state.attackers ? "atk" : "def";
60
61
export const teammates = (state: MatchState, side: Side): Player[] =>
62
  state.players.filter((p) => p.side === side);
63
64
export const aliveOn = (state: MatchState, side: Side): Player[] =>
65
  state.players.filter((p) => p.side === side && p.alive);
66
67
const BOT_NAMES = [
68
  "Ash", "Bex", "Cade", "Dune", "Enzo", "Fay", "Gus", "Hale", "Ivo", "Juno",
69
  "Kit", "Lux", "Mox", "Nell", "Orla", "Pike", "Quill", "Rio", "Sage", "Tove",
70
];
71
72
export function makePlayer(state: MatchState, team: Team, index: number): Player {
73
  const agent = ["ember", "veil", "sable", "mender", "rook"][index % 5]!;
74
  const name = BOT_NAMES[(TEAMS.indexOf(team) * 7 + index * 3 + Math.floor(random(state) * 20)) % BOT_NAMES.length]!;
75
  return {
76
    id: `bot-${team}-${index}`,
77
    name: `${name}`,
78
    bot: true,
79
    agent,
80
    team,
81
    side: sideOf(state, team),
82
    x: 0,
83
    y: 0,
84
    angle: 0,
85
    hp: 100,
86
    armour: 0,
87
    alive: true,
88
    credits: START_CREDITS,
89
    weapon: "classic",
90
    ammo: WEAPONS.classic.magazine,
91
    reloadIn: 0,
92
    fireIn: 0,
93
    charges: { c: 0, q: 0, e: 1, x: 0 },
94
    cooldown: { c: 0, q: 0, e: 0, x: 0 },
95
    ult: 0,
96
    blind: 0,
97
    slow: 0,
98
    haste: 0,
99
    revealed: 0,
100
    spike: false,
101
    planting: 0,
102
    defusing: 0,
103
    kills: 0,
104
    deaths: 0,
105
    assists: 0,
106
    damageDone: 0,
107
    input: emptyInput(),
108
    goal: null,
109
    think: 0,
110
    spray: 0,
111
    joinedAt: 0,
112
  };
113
}
114
115
export function newMatch(seed = Date.now() % 100000): MatchState {
116
  const state: MatchState = {
117
    id: `m${seed.toString(36)}${Math.floor(seed / 7) % 997}`,
118
    phase: "buy",
119
    clock: BUY_TIME,
120
    round: 1,
121
    score: { red: 0, blue: 0 },
122
    attackers: "red",
123
    lossStreak: { red: 0, blue: 0 },
124
    players: [],
125
    zones: [],
126
    nextZone: 1,
127
    spike: { carried: null, x: 0, y: 0, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 },
128
    orbs: orbSpots().map((p) => ({ ...p, taken: false })),
129
    shots: [],
130
    events: [],
131
    result: null,
132
    seed,
133
    now: 0,
134
  };
135
  for (const team of TEAMS)
136
    for (let i = 0; i < ROSTER; i++) state.players.push(makePlayer(state, team, i));
137
  startRound(state, true);
138
  say(state, "Round 1 — buy phase");
139
  return state;
140
}
141
142
/** A fresh match on the same seats: an ended match never parks. */
143
export function resetMatch(state: MatchState): void {
144
  const fresh = newMatch((state.seed + 7919) % 100000);
145
  fresh.players = state.players;
146
  fresh.attackers = state.attackers === "red" ? "blue" : "red";
147
  for (const player of fresh.players) {
148
    player.kills = 0;
149
    player.deaths = 0;
150
    player.assists = 0;
151
    player.damageDone = 0;
152
    player.ult = 0;
153
    player.credits = START_CREDITS;
154
    player.weapon = "classic";
155
  }
156
  Object.assign(state, fresh);
157
  startRound(state, true);
158
  say(state, "New match — round 1");
159
}
160
161
function placeAtSpawn(state: MatchState, player: Player, index: number): void {
162
  const cells = spawnCells(player.side);
163
  const spot = cells[index % cells.length]!;
164
  player.x = spot.x + (random(state) - 0.5) * 20;
165
  player.y = spot.y + (random(state) - 0.5) * 20;
166
  const centre = player.side === "atk" ? { x: 1200, y: 400 } : { x: 1200, y: 1200 };
167
  player.angle = Math.atan2(centre.y - player.y, centre.x - player.x);
168
}
169
170
export function startRound(state: MatchState, first = false): void {
171
  clearPaths();
172
  state.phase = "buy";
173
  state.clock = BUY_TIME;
174
  state.zones = [];
175
  state.shots = [];
176
  state.spike = { carried: null, x: 0, y: 0, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 };
177
  state.orbs = orbSpots().map((p) => ({ ...p, taken: false }));
178
179
  const byTeam: Record<Team, number> = { red: 0, blue: 0 };
180
  for (const player of state.players) {
181
    player.side = sideOf(state, player.team);
182
    // A gun survives the round its owner does; armour never does.
183
    if (!player.alive || first) player.weapon = "classic";
184
    player.armour = 0;
185
    player.hp = 100;
186
    player.alive = true;
187
    player.ammo = WEAPONS[player.weapon].magazine;
188
    player.reloadIn = 0;
189
    player.fireIn = 0;
190
    player.blind = 0;
191
    player.slow = 0;
192
    player.haste = 0;
193
    player.revealed = 0;
194
    player.spike = false;
195
    player.planting = 0;
196
    player.defusing = 0;
197
    player.spray = 0;
198
    player.goal = null;
199
    player.think = 0;
200
    player.input = emptyInput();
201
    // Basics are bought fresh every round; the signature comes back free.
202
    player.charges = { c: 0, q: 0, e: 1, x: 0 };
203
    player.cooldown = { c: 0, q: 0, e: 0, x: 0 };
204
    if (first) player.credits = START_CREDITS;
205
    placeAtSpawn(state, player, byTeam[player.team]++);
206
  }
207
  giveSpike(state);
208
}
209
210
function giveSpike(state: MatchState): void {
211
  const attackers = aliveOn(state, "atk");
212
  if (attackers.length === 0) return;
213
  const carrier = attackers[Math.floor(random(state) * attackers.length)]!;
214
  carrier.spike = true;
215
  state.spike.carried = carrier.id;
216
  state.spike.x = carrier.x;
217
  state.spike.y = carrier.y;
218
}
219
220
/** Buying is refused server-side: no credits, no gun, no argument. */
221
export function buyWeapon(state: MatchState, player: Player, id: WeaponId): boolean {
222
  if (state.phase !== "buy") return false;
223
  const cost = WEAPONS[id].cost;
224
  const paid = spend(player.credits, cost);
225
  if (!paid.ok) return false;
226
  player.credits = paid.credits;
227
  player.weapon = id;
228
  player.ammo = WEAPONS[id].magazine;
229
  return true;
230
}
231
232
export function buyShield(state: MatchState, player: Player, kind: "light" | "heavy"): boolean {
233
  if (state.phase !== "buy") return false;
234
  const shield = SHIELD[kind];
235
  if (player.armour >= shield.armour) return false;
236
  const paid = spend(player.credits, shield.cost);
237
  if (!paid.ok) return false;
238
  player.credits = paid.credits;
239
  player.armour = shield.armour;
240
  return true;
241
}
242
243
export function buyAbility(state: MatchState, player: Player, slot: Slot): boolean {
244
  if (state.phase !== "buy" || !buyableSlots.includes(slot)) return false;
245
  const ability = agentById(player.agent).abilities[slot];
246
  if (player.charges[slot] >= ability.charges) return false;
247
  const paid = spend(player.credits, ability.cost);
248
  if (!paid.ok) return false;
249
  player.credits = paid.credits;
250
  player.charges[slot] += 1;
251
  return true;
252
}
253
254
export function pickAgent(state: MatchState, player: Player, agent: string): boolean {
255
  if (state.phase !== "buy" || player.charges.c > 0 || player.charges.q > 0) return false;
256
  player.agent = agent;
257
  return true;
258
}
259
260
// ---------------------------------------------------------------- the tick
261
262
export function tick(state: MatchState, dt: number): void {
263
  state.now += dt;
264
  for (const shot of state.shots) shot.age += dt;
265
  state.shots = state.shots.filter((s) => s.age < 0.12);
266
  for (const event of state.events) event.age += dt;
267
268
  if (state.phase === "over") {
269
    state.clock -= dt;
270
    if (state.clock <= 0) resetMatch(state);
271
    return;
272
  }
273
  if (state.phase === "end") {
274
    state.clock -= dt;
275
    if (state.clock <= 0) nextRound(state);
276
    return;
277
  }
278
279
  state.clock -= dt;
280
  if (state.phase === "buy") {
281
    for (const player of state.players) movePlayer(state, player, dt, true);
282
    if (state.clock <= 0) {
283
      state.phase = "live";
284
      state.clock = ROUND_TIME;
285
      say(state, "Round live");
286
    }
287
    return;
288
  }
289
290
  for (const player of state.players) {
291
    if (player.blind > 0) player.blind = Math.max(0, player.blind - dt);
292
    if (player.slow > 0) player.slow = Math.max(0, player.slow - dt);
293
    if (player.haste > 0) player.haste = Math.max(0, player.haste - dt);
294
    if (player.revealed > 0) player.revealed = Math.max(0, player.revealed - dt);
295
    for (const slot of SLOTS)
296
      if (player.cooldown[slot] > 0) {
297
        player.cooldown[slot] = Math.max(0, player.cooldown[slot] - dt);
298
        // The signature charge comes back on its own when the wait is over.
299
        if (player.cooldown[slot] === 0) player.charges[slot] = Math.max(player.charges[slot], 1);
300
      }
301
    player.spray = Math.max(0, player.spray - dt * 6);
302
    if (!player.alive) continue;
303
    movePlayer(state, player, dt, false);
304
    useAbility(state, player);
305
    shoot(state, player, dt);
306
    spikeWork(state, player, dt);
307
    orbs(state, player);
308
  }
309
310
  if (state.spike.planted) state.spike.fuse = Math.max(0, state.spike.fuse - dt);
311
  zones(state, dt);
312
  endConditions(state);
313
}
314
315
function speedOf(player: Player): number {
316
  let speed = RUN_SPEED;
317
  if (player.haste > 0) speed *= 1.35;
318
  if (player.slow > 0) speed *= 0.55;
319
  if (player.planting > 0 || player.defusing > 0) speed = 0;
320
  if (player.weapon === "operator") speed *= 0.86;
321
  return speed;
322
}
323
324
function movePlayer(state: MatchState, player: Player, dt: number, penned: boolean): void {
325
  if (!player.alive) return;
326
  const input = player.input;
327
  if (Number.isFinite(input.angle)) player.angle = input.angle;
328
  let mx = input.mx;
329
  let my = input.my;
330
  const len = Math.hypot(mx, my);
331
  if (len > 1) {
332
    mx /= len;
333
    my /= len;
334
  }
335
  const speed = speedOf(player) * dt;
336
  if (speed === 0 || (mx === 0 && my === 0)) return;
337
  let next = moveCircle(player.x, player.y, mx * speed, my * speed, PLAYER_R);
338
  // In the buy phase nobody leaves their own spawn.
339
  if (penned) {
340
    const cells = spawnCells(player.side);
341
    const near = cells.reduce((best, c) => (Math.hypot(c.x - next.x, c.y - next.y) < Math.hypot(best.x - next.x, best.y - next.y) ? c : best));
342
    if (Math.hypot(near.x - next.x, near.y - next.y) > 120) return;
343
  }
344
  // Walls put down by abilities are as solid as the map's.
345
  for (const zone of state.zones) {
346
    if (!zone.solid || zone.fuse > 0) continue;
347
    const gap = Math.hypot(next.x - zone.x, next.y - zone.y);
348
    if (gap < zone.r + PLAYER_R) {
349
      const push = zone.r + PLAYER_R - gap;
350
      const angle = gap === 0 ? 0 : Math.atan2(next.y - zone.y, next.x - zone.x);
351
      const shoved = { x: next.x + Math.cos(angle) * push, y: next.y + Math.sin(angle) * push };
352
      if (!blockedCircle(shoved.x, shoved.y, PLAYER_R)) next = shoved;
353
      else next = { x: player.x, y: player.y };
354
    }
355
  }
356
  player.x = next.x;
357
  player.y = next.y;
358
}
359
360
function useAbility(state: MatchState, player: Player): void {
361
  const slot = player.input.cast;
362
  if (slot === null) return;
363
  player.input.cast = null;
364
  if (state.phase !== "live" || player.blind > 0.8) return;
365
  if (cast(state, player, slot)) {
366
    const ability = agentById(player.agent).abilities[slot];
367
    if (slot === "x") say(state, `${player.name} used ${ability.name}`);
368
  }
369
}
370
371
function shoot(state: MatchState, player: Player, dt: number): void {
372
  player.fireIn = Math.max(0, player.fireIn - dt);
373
  if (player.reloadIn > 0) {
374
    player.reloadIn -= dt;
375
    if (player.reloadIn <= 0) {
376
      player.ammo = WEAPONS[player.weapon].magazine;
377
      player.reloadIn = 0;
378
    }
379
    return;
380
  }
381
  const weapon = WEAPONS[player.weapon];
382
  if (player.input.reload && player.ammo < weapon.magazine) {
383
    player.input.reload = false;
384
    player.reloadIn = weapon.reload;
385
    return;
386
  }
387
  if (!player.input.fire || player.fireIn > 0) return;
388
  if (!weapon.auto) player.input.fire = false;
389
  if (player.ammo <= 0) {
390
    player.reloadIn = weapon.reload;
391
    return;
392
  }
393
  player.ammo -= 1;
394
  player.fireIn = 1 / weapon.rate;
395
  player.spray = Math.min(6, player.spray + 1);
396
397
  const moving = Math.hypot(player.input.mx, player.input.my) > 0.1 ? 1 : 0;
398
  // Blind aim is a guess: the shot goes somewhere near where you were looking.
399
  const wobble = player.blind > 0 ? 0.5 : 0;
400
  const spread = shotSpread(player.weapon, moving) + player.spray * 0.006 + wobble;
401
  const angle = player.angle + (random(state) - 0.5) * 2 * spread;
402
  const from: Point = { x: player.x, y: player.y };
403
  const wall = rayHitWall(from, angle, weapon.range);
404
  let stop = wall;
405
  // A wall an ability put up stops bullets too; smoke does not.
406
  for (const zone of state.zones) {
407
    if (!zone.solid || zone.fuse > 0) continue;
408
    const near = segmentPoint(from, wall, zone);
409
    if (near.dist <= zone.r && near.travel < Math.hypot(stop.x - from.x, stop.y - from.y))
410
      stop = { x: from.x + Math.cos(angle) * near.travel, y: from.y + Math.sin(angle) * near.travel };
411
  }
412
413
  let best: { target: Player; travel: number; miss: number } | null = null;
414
  for (const other of state.players) {
415
    if (!other.alive || other.side === player.side || other.id === player.id) continue;
416
    const near = segmentPoint(from, stop, other);
417
    if (near.dist > PLAYER_R || near.travel < 1) continue;
418
    if (best === null || near.travel < best.travel) best = { target: other, travel: near.travel, miss: near.dist };
419
  }
420
421
  const end = best ? { x: from.x + Math.cos(angle) * best.travel, y: from.y + Math.sin(angle) * best.travel } : stop;
422
  state.shots.push({ x1: from.x, y1: from.y, x2: end.x, y2: end.y, hit: best !== null, age: 0 });
423
  if (!best) return;
424
425
  const zone = zoneFromMiss(best.miss, PLAYER_R);
426
  const dealt = damage(player.weapon, zone, best.travel);
427
  hurt(state, best.target, dealt, player, zone === "head" ? "headshot" : "");
428
}
429
430
function hurt(state: MatchState, target: Player, amount: number, from: Player | null, note: string): void {
431
  if (!target.alive) return;
432
  const before = target.hp + target.armour;
433
  const after = applyDamage(target.hp, target.armour, amount);
434
  target.hp = after.hp;
435
  target.armour = after.armour;
436
  if (from) from.damageDone += Math.max(0, before - (target.hp + target.armour));
437
  if (target.hp > 0) return;
438
  target.alive = false;
439
  target.deaths += 1;
440
  target.planting = 0;
441
  // A defuse dies with the defender, back to the half-way checkpoint.
442
  if (target.defusing > 0) {
443
    target.defusing = 0;
444
    state.spike.defuse = state.spike.checkpoint;
445
  }
446
  if (target.spike) {
447
    target.spike = false;
448
    state.spike.carried = null;
449
    state.spike.x = target.x;
450
    state.spike.y = target.y;
451
  }
452
  if (from && from.side !== target.side) {
453
    from.kills += 1;
454
    from.credits = award(from.credits, 200);
455
    from.ult = Math.min(10, from.ult + 1);
456
    say(state, `${from.name} ${note === "headshot" ? "headshot" : "killed"} ${target.name}`);
457
  } else {
458
    say(state, `${target.name} died`);
459
  }
460
}
461
462
function spikeWork(state: MatchState, player: Player, dt: number): void {
463
  const spike = state.spike;
464
  if (player.side === "atk" && !spike.planted) {
465
    if (!spike.carried && !player.spike && Math.hypot(player.x - spike.x, player.y - spike.y) < SPIKE_REACH) {
466
      player.spike = true;
467
      spike.carried = player.id;
468
      say(state, `${player.name} picked up the spike`);
469
    }
470
    if (player.spike) {
471
      spike.x = player.x;
472
      spike.y = player.y;
473
      const site = siteAt(player.x, player.y);
474
      if (player.input.use && site) {
475
        player.planting += dt;
476
        if (player.planting >= PLANT_TIME) {
477
          spike.planted = true;
478
          spike.site = site;
479
          spike.fuse = FUSE_TIME;
480
          spike.carried = null;
481
          player.spike = false;
482
          player.planting = 0;
483
          player.ult = Math.min(10, player.ult + 1);
484
          for (const mate of teammates(state, "atk")) mate.credits = award(mate.credits, 300);
485
          say(state, `${player.name} planted on ${site}`);
486
        }
487
      } else player.planting = 0;
488
    }
489
    return;
490
  }
491
  if (player.side === "def" && spike.planted) {
492
    const close = Math.hypot(player.x - spike.x, player.y - spike.y) < SPIKE_REACH;
493
    if (player.input.use && close) {
494
      spike.defuse += dt;
495
      player.defusing = spike.defuse;
496
      // Half way is a checkpoint: the next defender starts from there.
497
      if (spike.defuse >= DEFUSE_CHECKPOINT && spike.checkpoint === 0) {
498
        spike.checkpoint = DEFUSE_CHECKPOINT;
499
        say(state, `${player.name} is half way through the defuse`);
500
      }
501
    } else if (player.defusing > 0) {
502
      player.defusing = 0;
503
      spike.defuse = spike.checkpoint;
504
    }
505
  }
506
}
507
508
function orbs(state: MatchState, player: Player): void {
509
  for (const orb of state.orbs)
510
    if (!orb.taken && Math.hypot(player.x - orb.x, player.y - orb.y) < 40) {
511
      orb.taken = true;
512
      player.ult = Math.min(10, player.ult + 1);
513
      say(state, `${player.name} took an ult orb`);
514
    }
515
}
516
517
function zones(state: MatchState, dt: number): void {
518
  for (const zone of state.zones) {
519
    if (zone.fuse > 0) {
520
      zone.fuse -= dt;
521
      if (zone.fuse > 0) continue;
522
      if (zone.kind === "flash") {
523
        // Pop: everyone with a sight line to it, blinded by how far their
524
        // back was turned. Smoke between them and it saves them.
525
        for (const player of state.players) {
526
          if (!player.alive) continue;
527
          const blocked = sightBlocked(zone, player, state.zones);
528
          const within = Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r;
529
          const blind = within ? flashDuration(zone.damage, player, zone, player.angle, blocked) : 0;
530
          if (blind > 0) player.blind = Math.max(player.blind, blind);
531
        }
532
      }
533
      continue;
534
    }
535
    zone.life -= dt;
536
    zone.beat -= dt;
537
    if (zone.kind === "molly" && zone.beat <= 0) {
538
      zone.beat = 0.5;
539
      const owner = state.players.find((p) => p.id === zone.owner) ?? null;
540
      for (const player of state.players)
541
        if (player.alive && player.side !== zone.side && Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r)
542
          hurt(state, player, Math.round(zone.damage / 2), owner, "");
543
    }
544
    if (zone.kind === "dart" && zone.beat <= 0 && zone.pulses > 0) {
545
      zone.beat = 1.6;
546
      zone.pulses -= 1;
547
      for (const player of state.players)
548
        if (player.alive && player.side !== zone.side && Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r && !sightBlocked(zone, player, state.zones))
549
          player.revealed = Math.max(player.revealed, 2);
550
    }
551
    if (zone.kind === "heal" && zone.beat <= 0) {
552
      zone.beat = 0.5;
553
      const target = state.players.find((p) => p.id === zone.owner);
554
      if (target && target.alive) {
555
        target.hp = Math.min(100, target.hp + zone.damage / 6);
556
        zone.x = target.x;
557
        zone.y = target.y;
558
      }
559
    }
560
    if (zone.kind === "trip" && zone.life > 0) {
561
      const caught = state.players.find(
562
        (p) => p.alive && p.side !== zone.side && Math.hypot(p.x - zone.x, p.y - zone.y) <= zone.r,
563
      );
564
      if (caught) {
565
        caught.revealed = Math.max(caught.revealed, 4);
566
        caught.slow = Math.max(caught.slow, 3);
567
        zone.life = 0;
568
        say(state, `${caught.name} tripped a wire`);
569
      }
570
    }
571
    // Blackout: a smoke that carries a slow (marked with negative damage).
572
    if (zone.kind === "smoke" && zone.damage < 0)
573
      for (const player of state.players)
574
        if (player.alive && player.side !== zone.side && Math.hypot(player.x - zone.x, player.y - zone.y) <= zone.r)
575
          player.slow = Math.max(player.slow, 0.5);
576
  }
577
  state.zones = state.zones.filter((z) => z.life > 0 || z.fuse > 0);
578
}
579
580
function endConditions(state: MatchState): void {
581
  const spike = state.spike;
582
  if (spike.planted && spike.defuse >= DEFUSE_TIME) return finishRound(state, "def", "Spike defused");
583
  if (aliveOn(state, "def").length === 0) return finishRound(state, "atk", "Defenders eliminated");
584
  if (aliveOn(state, "atk").length === 0 && !spike.planted)
585
    return finishRound(state, "def", "Attackers eliminated");
586
  if (spike.planted) {
587
    if (spike.fuse <= 0) return finishRound(state, "atk", "Spike detonated");
588
  } else if (state.clock <= 0) return finishRound(state, "def", "Time out");
589
}
590
591
function finishRound(state: MatchState, winners: Side, why: string): void {
592
  const winningTeam: Team = winners === "atk" ? state.attackers : state.attackers === "red" ? "blue" : "red";
593
  const losingTeam: Team = winningTeam === "red" ? "blue" : "red";
594
  state.score[winningTeam] += 1;
595
  state.lossStreak[losingTeam] += 1;
596
  state.lossStreak[winningTeam] = 0;
597
  for (const player of state.players) {
598
    const won = player.team === winningTeam;
599
    player.credits = clampCredits(
600
      player.credits + roundReward(won, state.lossStreak[player.team], state.spike.planted && player.side === "atk"),
601
    );
602
  }
603
  state.phase = "end";
604
  state.clock = END_TIME;
605
  say(state, `${why} — ${winningTeam} take round ${state.round} (${state.score.red}–${state.score.blue})`);
606
  if (state.score[winningTeam] >= WIN_ROUNDS) {
607
    state.phase = "over";
608
    state.clock = OVER_TIME;
609
    state.result = `${winningTeam} win ${state.score.red}–${state.score.blue}`;
610
    say(state, `Match over — ${state.result}. A new one starts in ${OVER_TIME} seconds.`);
611
  }
612
}
613
614
function nextRound(state: MatchState): void {
615
  state.round += 1;
616
  // Sides swap once twelve rounds are behind us.
617
  if (state.round === SWAP_AFTER + 1) {
618
    state.attackers = state.attackers === "red" ? "blue" : "red";
619
    say(state, "Sides swap");
620
  }
621
  startRound(state);
622
  say(state, `Round ${state.round} — buy phase`);
623
}
624
625
/** What one player is allowed to see: their own side, and what light reaches. */
626
export function visible(state: MatchState, viewer: Player, other: Player): boolean {
627
  if (other.id === viewer.id || other.side === viewer.side) return true;
628
  if (!other.alive) return false;
629
  if (other.revealed > 0) return true;
630
  if (viewer.blind > 0) return false;
631
  // A dead player watches the round out through their team's eyes, not
632
  // through the walls: what a living team mate can see, and nothing more.
633
  // Otherwise dying and staying connected would be a wallhack to call out.
634
  if (!viewer.alive)
635
    return state.players.some(
636
      (mate) => mate.side === viewer.side && mate.alive && mate.blind <= 0 && !sightBlocked(mate, other, state.zones),
637
    );
638
  return !sightBlocked(viewer, other, state.zones);
639
}
640
641
export function setInput(player: Player, input: Partial<Input>): void {
642
  const target = player.input;
643
  if (typeof input.mx === "number" && Number.isFinite(input.mx)) target.mx = Math.max(-1, Math.min(1, input.mx));
644
  if (typeof input.my === "number" && Number.isFinite(input.my)) target.my = Math.max(-1, Math.min(1, input.my));
645
  if (typeof input.angle === "number" && Number.isFinite(input.angle)) target.angle = input.angle;
646
  if (typeof input.fire === "boolean") target.fire = input.fire;
647
  if (typeof input.use === "boolean") target.use = input.use;
648
  if (typeof input.reload === "boolean") target.reload = input.reload;
649
  if (input.cast === "c" || input.cast === "q" || input.cast === "e" || input.cast === "x") target.cast = input.cast;
650
}
651
652
export { castable, agentById };
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts