File size: 39,826 Bytes
217da1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
// Turfbreak simulation: operators with real 3D bodies (feet height, gravity, jump, mantling onto cover, crouch,
// slide, sprint, aim-down-sights), hitscan rays in 3D with head and body hitboxes (headshot multipliers), a sidearm
// slot, bots that take cover, crouch behind low walls, peek and shoot only along a real line of sight, charges, zones
// (Turf = Domination), killstreaks (Recon Drone, Sentry, Skyfall Strike), scoring and a 30 Hz history of the whole
// match for kill-cams from the killer's eyes and the "play of the match" replay. Pure and deterministic for a seed +
// inputs (fixed 60 Hz steps), no DOM, no three.js.
// Mature tone, no gore: a tagged-out operator breaks into sparks and redeploys at the crew's spawn.
// (c) 2026 CyberMax. All rights reserved.
import { rng, WEAPONS, WEAPON_IDS, ARMS, PERK_IDS, STREAKS, TEAMS, BODY, collide, collide3, groundAt, mantleTarget, rayHit3, los3, dir3, rayBody, field, steer, inBox } from './core.js';

export const DT = 1 / 60;
export const HS = 13; // floats per operator per history frame
// Movement feel (see FEEL.md): linear ground acceleration and friction, Quake-style air control that keeps momentum,
// a snappy 1.25 m jump with coyote time and a jump buffer, and a sprint-slide you can hop out of.
const GRAV = 20, JUMP = 7.05, WALK = 6.4, ACC_G = 70, FRIC = 64, ACC_A = 22, COYOTE = 0.1, JBUF = 0.12;
const SLIDE_V = 10.8, SLIDE_T = 0.75, SLIDE_FRIC = 5.5, SLIDE_CD = 0.9;
/** Spawn protection (s): no damage, bots ignore you; it ends early the moment you fire. */
export const PROT = 2.0;
const lerp = (a, b, t) => a + (b - a) * t;
const approach = (v, t, step) => (v < t ? Math.min(t, v + step) : Math.max(t, v - step));
const CALLSIGNS = ['RAZOR', 'VANTA', 'NOMAD', 'HEX', 'KILO', 'RUNE', 'SABLE', 'ONYX', 'DRIFT', 'VOLT', 'ASH', 'CIPHER', 'MOTH', 'FLINT', 'JINX', 'NOVA', 'RIFT', 'STATIC', 'WRAITH', 'BISHOP', 'ECHO', 'GRIT', 'HAVOC', 'LYNX'];

const angDiff = (a, b) => { let d = b - a; while (d > Math.PI) d -= 2 * Math.PI; while (d < -Math.PI) d += 2 * Math.PI; return d; };
/** Eye position of an operator (world). */
export const eyeOf = (e) => [e.x, e.y + BODY.eye + (BODY.eyeC - BODY.eye) * e.crouch, e.z];
const chestOf = (e) => [e.x, e.y + 1.05 - 0.32 * e.crouch, e.z];
const headOf = (e) => [e.x, e.y + BODY.head + (BODY.headC - BODY.head) * e.crouch, e.z];
/** The weapon an operator holds right now. */
export const armOf = (e) => ARMS[e.slot === 1 ? 'p9' : e.weapon];

/**
 * opts: { map, seed, mode: 'turf'|'showdown', you: {name, weapon, perk, skin}, hard, size (per team), time (s), target,
 *   skill (0..1, how sharp the bots against you are; 0.5 = normal, hard = 1), rookie (first match ever: one enemy
 *   walks straight to the middle zone so the new player meets an easy first fight) }
 */
export function createMatch(opts) {
  const { map } = opts;
  const R = rng((opts.seed || 1) ^ 0x9E3779B9);
  const mode = opts.mode || 'turf';
  const size = opts.size || (mode === 'showdown' ? 3 : 4);
  const skill = opts.hard ? 1 : Math.max(0, Math.min(1, opts.skill != null ? opts.skill : 0.5));
  const G = {
    map, mode, R, t: 0, tick: 0, over: false, winner: -1, hard: !!opts.hard, skill,
    time: opts.time || (mode === 'showdown' ? 60 : 180), left: 0, target: opts.target || 200,
    score: [0, 0], rounds: [0, 0], round: 1, roundOver: 0, toWin: 4,
    zones: map.zones.map((z) => ({ ...z, owner: -1, prog: 0, contest: false, n: [0, 0] })),
    ents: [], charges: [], strikes: [], sentries: [], recon: [0, 0], events: [], hist: [], histEv: [], feed: [],
    you: null, lastKill: null, cover: coverSpots(map),
  };
  G.left = G.time;
  const used = new Set(); const names = CALLSIGNS.slice();
  const pickName = () => { let n; do { n = names[Math.floor(R() * names.length)]; } while (used.has(n) && used.size < names.length); used.add(n); return n; };
  if (opts.you) used.add(opts.you.name);
  let id = 0;
  for (const team of [0, 1]) for (let k = 0; k < size; k++) {
    const isYou = team === 0 && k === 0 && !!opts.you;
    const w = isYou ? (opts.you.weapon || 'vk9') : WEAPON_IDS[Math.floor(R() * WEAPON_IDS.length)];
    const perk = isYou ? (opts.you.perk || 'plated') : PERK_IDS[Math.floor(R() * PERK_IDS.length)];
    const e = {
      id: id++, team, bot: !isYou, you: isYou, name: isYou ? opts.you.name : pickName(), skin: isYou ? (opts.you.skin || 0) : Math.floor(R() * 3),
      weapon: w, perk, x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0, yaw: 0, pitch: 0, crouch: 0, ground: true, sprint: false, ads: 0,
      hp: 100, maxHp: 100, alive: true, respawn: 0, spawnT: 0, slot: 0, mags: [WEAPONS[w].mag, ARMS.p9.mag], bloom: 0,
      ammo: WEAPONS[w].mag, reload: 0, cd: 0, firing: 0, dash: 0, dashCd: 0, nades: 2, lastHit: -9, lastFire: -9, lastHitBy: -1, trig: false,
      streak: 0, bestStreak: 0, kills: 0, deaths: 0, caps: 0, assists: 0, heads: 0, score: 0, dmgBy: {}, owned: [],
      ai: { zone: null, target: null, react: 0, err: 0, errP: 0, strafe: 1, strafeT: 0, think: 0, retreat: 0, nadeT: 2 + R() * 4, wander: 0, cover: null, coverT: 0, peekT: 0, lostT: 0, lastSeen: null, burst: 0 },
      killer: null, moving: 0, kick: 0, revealT: 0, slide: 0, slideCd: 0, airT: 0, jumpBuf: 0, jumped: false, prot: 0,
      // bots against you get the match skill; your own teammates are never worse than normal
      skill: !opts.you ? skill : team === 0 ? Math.max(skill, 0.55) : skill,
    };
    G.ents.push(e);
    if (isYou) G.you = e;
  }
  if (opts.you && opts.rookie) { // first match: the nearest enemy heads straight for B, a little slower on the trigger
    const r = G.ents.find((e) => e.team === 1); r.skill = Math.min(r.skill, 0.05); r.ai.zone = G.zones[1]; r.ai.think = 9; r.rookie = true;
  }
  for (const e of G.ents) spawn(G, e, true);
  return G;
}

/** Candidate cover spots: points hugging every box tall enough to hide a crouched body, on its four sides. */
function coverSpots(map) {
  const out = [];
  for (const o of map.obstacles) {
    if (o.h < 1.0 || Math.abs(o.x) > 46 || Math.abs(o.z) > 50 || o.kind === 'wall' || o.kind === 'lamp') continue;
    const low = o.h < 1.7;
    const pts = [];
    for (const s of [-1, 1]) {
      for (const f of (o.w > 4 ? [-0.3, 0.3] : [0])) pts.push([o.x + f * o.w, o.z + s * (o.d / 2 + 0.75)]);
      for (const f of (o.d > 4 ? [-0.3, 0.3] : [0])) pts.push([o.x + s * (o.w / 2 + 0.75), o.z + f * o.d]);
    }
    for (const [x, z] of pts) {
      if (Math.abs(x) > 45 || Math.abs(z) > 49) continue;
      if (map.obstacles.some((q) => inBox(q, x, z, 0.5))) continue;
      out.push({ x, z, low, o });
    }
  }
  return out;
}

function spawn(G, e, first) {
  const pts = G.map.spawns[e.team]; const k = G.ents.filter((o) => o.team === e.team).indexOf(e);
  let p = pts[k % pts.length];
  if (!first) { // redeploy at the spawn point farthest from every living enemy (no spawn-camping)
    let best = -1;
    for (const q of pts) { let near = 1e9; for (const o of G.ents) if (o.team !== e.team && o.alive) near = Math.min(near, Math.hypot(o.x - q.x, o.z - q.z)); near += G.R() * 3; if (near > best) { best = near; p = q; } }
  }
  e.x = p.x + (G.R() - 0.5) * 2; e.z = p.z + (G.R() - 0.5) * 1.5; e.yaw = p.yaw; e.pitch = 0; e.vx = e.vz = e.vy = 0; e.y = 0; e.ground = true; e.crouch = 0;
  e.maxHp = e.perk === 'plated' ? 130 : 100; e.hp = e.maxHp; e.alive = true; e.spawnT = G.t; e.prot = G.t + PROT; e.dmgBy = {};
  e.slot = 0; e.mags = [WEAPONS[e.weapon].mag, ARMS.p9.mag]; e.ammo = e.mags[0]; e.reload = 0; e.cd = 0.3; e.nades = 2; e.streak = 0; e.dash = 0; e.dashCd = 0; e.bloom = 0; e.ads = 0;
  e.slide = 0; e.slideCd = 0; e.airT = 0; e.jumpBuf = 0; e.jumped = false;
  const keepZone = first && e.rookie;
  e.ai.target = null; e.ai.react = 0; if (!keepZone) { e.ai.zone = null; e.ai.think = 0; } e.ai.cover = null; e.ai.lastSeen = null;
}

const ev = (G, e) => { e.tick = G.tick; G.events.push(e); G.histEv.push(e); };

/** Can `m` see operator `o`? (eye to head or chest, real 3D line of sight) */
function sees(G, m, o) {
  const a = eyeOf(m); const h = headOf(o); if (los3(G.map, a[0], a[1], a[2], h[0], h[1], h[2])) return 'head';
  const c = chestOf(o); if (los3(G.map, a[0], a[1], a[2], c[0], c[1], c[2])) return 'chest';
  return null;
}

export const canSee = (G, m, o) => !!sees(G, m, o);
/** Is enemy `o` visible to team `team`? (line of sight from any alive teammate, a fresh shot, or the recon drone) */
export function spotted(G, team, o) {
  if (!o.alive) return false;
  if (G.recon[team] > 0 && o.perk !== 'ghost') return true;
  if (G.t - o.lastFire < 0.7) return true;
  for (const m of G.ents) if (m.team === team && m.alive && Math.hypot(m.x - o.x, m.z - o.z) < 48 && sees(G, m, o)) return true;
  return false;
}

/** Current spread (radians, half-angle) of an operator's weapon: hip vs aimed, moving, airborne, crouched, bloom. */
export function spreadOf(e) {
  const W = armOf(e); const mv = Math.hypot(e.vx, e.vz);
  if (W.id === 'breach') return W.spread * (1 - 0.35 * e.ads) + e.bloom;
  // the first aimed shot from a steady stance goes exactly where the crosshair / dot is
  if (e.ads > 0.95 && mv < 1 && e.ground && e.bloom < 0.002) return 0;
  const s = W.spread * (1 - 0.88 * e.ads) * (mv > 1 ? 1 + 0.6 * Math.min(1, mv / WALK) : 0.8) * (e.ground ? 1 : 2.2) * (1 - 0.22 * e.crouch);
  return s + e.bloom * (1 - 0.5 * e.ads);
}

/** What the operator's crosshair is on: { ent, dist, head } for the first enemy along the exact view ray, else null. */
export function aimTarget(G, e, maxD = 80) {
  const o = eyeOf(e); const d = dir3(e.yaw, e.pitch);
  const wall = rayHit3(G.map, o[0], o[1], o[2], o[0] + d[0] * maxD, o[1] + d[1] * maxD, o[2] + d[2] * maxD).f * maxD;
  let best = null;
  for (const q of G.ents) { if (q.team === e.team || !q.alive) continue; const h = rayBody(o, d, q.x, q.y, q.z, q.crouch); if (h && h.t < wall && (!best || h.t < best.dist)) best = { ent: q, dist: h.t, head: h.head }; }
  return best;
}

// ---------------------------------------------------------------- shooting
function fire(G, e) {
  const W = armOf(e);
  e.cd = W.cd; e.ammo -= 1; e.mags[e.slot] = e.ammo; e.lastFire = G.t; e.firing = 0.08; e.kick = W.kick; e.prot = 0; // firing ends spawn protection
  const o = eyeOf(e); const sp = spreadOf(e);
  for (let p = 0; p < W.pellets; p++) {
    // spread: uniform in a cone; bots add their aim error
    const a = G.R() * Math.PI * 2, r = Math.sqrt(G.R()) * sp;
    const yaw = e.yaw + Math.cos(a) * r + (e.bot ? e.ai.err : 0), pitch = e.pitch + Math.sin(a) * r + (e.bot ? e.ai.errP : 0);
    const d = dir3(yaw, pitch); const L = W.range * 2.4;
    const wh = rayHit3(G.map, o[0], o[1], o[2], o[0] + d[0] * L, o[1] + d[1] * L, o[2] + d[2] * L); let best = wh.f * L; let hit = null, head = false;
    for (const q of G.ents) {
      if (q.team === e.team || !q.alive) continue;
      const h = rayBody(o, d, q.x, q.y, q.z, q.crouch);
      if (h && h.t < best) { best = h.t; hit = q; head = h.head; }
    }
    let hitS = null;
    for (const s of G.sentries) {
      if (s.team === e.team || s.hp <= 0) continue;
      const h = rayBody(o, d, s.x, 0, s.z, 0.55); if (h && h.t < best) { best = h.t; hit = null; head = false; hitS = s; }
    }
    const x1 = o[0] + d[0] * best, y1 = o[1] + d[1] * best, z1 = o[2] + d[2] * best;
    ev(G, { k: 'shot', from: e.id, w: W.id, x0: o[0], y0: o[1] - 0.12, z0: o[2], x1, y1, z1, hit: hit ? hit.id : hitS ? -2 : -1, hs: head, nx: hit || hitS ? 0 : wh.nx, ny: hit || hitS ? 0 : wh.ny, nz: hit || hitS ? 0 : wh.nz, team: e.team, world: !hit && !hitS && wh.f < 1 });
    if (hit) {
      const fall = W.id === 'breach' ? Math.max(0.3, 1 - best / W.range) : best > W.range ? 0.72 : best > W.range * 0.7 ? 0.86 : 1;
      // bots ease off a human while the match skill is low (new players), full damage from skill 1
      const easy = e.bot && hit.you ? lerp(0.55, 1, Math.min(1, e.skill * 1.6)) : 1;
      damage(G, hit, W.dmg * fall * (head ? W.hs : 1) * easy, e, W.id, head, [x1, y1, z1]);
    } else if (hitS) { hitS.hp -= W.dmg; if (hitS.hp <= 0) ev(G, { k: 'boom', x: hitS.x, y: 0.8, z: hitS.z, r: 2, small: true }); }
  }
  e.bloom = Math.min(W.spread * 2.5 + 0.03, e.bloom + W.bloom);
  if (e.ammo <= 0) startReload(e);
}
function startReload(e) { const W = armOf(e); if (e.reload > 0 || e.ammo >= W.mag) return; e.reload = W.reload * (e.perk === 'quick' ? 0.7 : 1); e.reloadMax = e.reload; }

function damage(G, o, dmg, by, weapon, head = false, at = null) {
  if (!o.alive || G.t < o.prot) return; // spawn protection
  o.hp -= dmg; o.lastHit = G.t; o.lastHitBy = by.id; o.dmgBy[by.id] = (o.dmgBy[by.id] || 0) + dmg;
  ev(G, { k: 'hit', id: o.id, by: by.id, dmg, x: o.x, z: o.z, y: at ? at[1] : o.y + 1, hx: at ? at[0] : o.x, hz: at ? at[2] : o.z, fx: by.x, fz: by.z, hs: head, you: by.you });
  if (o.bot && by.team !== o.team && (o.ai.target == null || G.ents[o.ai.target] && !G.ents[o.ai.target].alive)) { o.ai.target = by.id; o.ai.react = lerp(0.6, 0.2, o.skill); o.ai.lastSeen = { x: by.x, z: by.z, t: G.t }; }
  if (o.hp <= 0) kill(G, o, by, weapon, head);
}

function kill(G, o, by, weapon, head = false) {
  o.alive = false; o.hp = 0; o.deaths++; o.streak = 0; o.respawn = G.mode === 'showdown' ? 1e9 : 4.2; o.killer = by.id;
  by.kills++; by.streak++; by.bestStreak = Math.max(by.bestStreak, by.streak); by.score += 100;
  if (head) { by.heads++; by.score += 25; }
  const inZone = G.zones.find((z) => Math.hypot(o.x - z.x, o.z - z.z) < z.r + 1 && z.owner === by.team);
  if (inZone) by.score += 50;
  for (const [aid, d] of Object.entries(o.dmgBy)) { const a = G.ents[aid]; if (a && a !== by && a.team === by.team && d >= 35) { a.assists++; a.score += 50; } }
  const recent = G.feed.filter((f) => f.by === by.id && G.t - f.t < 4).length;
  const multi = recent + 1;
  if (multi >= 2) by.score += 50 * (multi - 1);
  G.feed.push({ t: G.t, by: by.id, victim: o.id, w: weapon, multi, hs: head });
  G.lastKill = { tick: G.tick, by: by.id, victim: o.id, w: weapon, multi, team: by.team, hs: head };
  ev(G, { k: 'kill', by: by.id, victim: o.id, w: weapon, x: o.x, y: o.y, z: o.z, multi, streak: by.streak, team: by.team, hs: head, fx: by.x, fz: by.z });
  // killstreaks (per life)
  const s = STREAKS.find((k) => k.at === by.streak);
  if (s && by.alive) { by.owned.push(s.id); ev(G, { k: 'streak', id: by.id, s: s.id }); if (by.bot || s.id === 'recon') useStreak(G, by, s.id); }
}

// ---------------------------------------------------------------- killstreaks
export function useStreak(G, e, id = null) {
  const sid = id || e.owned[e.owned.length - 1]; if (!sid) return false;
  const i = e.owned.lastIndexOf(sid); if (i < 0) return false; e.owned.splice(i, 1);
  if (sid === 'recon') { G.recon[e.team] = 12; ev(G, { k: 'recon', team: e.team, id: e.id }); }
  if (sid === 'sentry') { const s = { team: e.team, by: e.id, x: e.x + Math.cos(e.yaw) * 1.2, z: e.z + Math.sin(e.yaw) * 1.2, yaw: e.yaw, hp: 140, life: 22, cd: 0, firing: 0 }; [s.x, s.z] = collide(G.map, s.x, s.z, 0.7); G.sentries.push(s); ev(G, { k: 'sentry', team: e.team, x: s.x, z: s.z, id: e.id }); }
  if (sid === 'skyfall') {
    let a = e.yaw;
    if (e.bot) { // aim at the crowd
      let best = 0; for (const o of G.ents) if (o.team !== e.team && o.alive) { const n = G.ents.filter((p) => p.team === o.team && p.alive && Math.hypot(p.x - o.x, p.z - o.z) < 8).length; if (n > best) { best = n; a = Math.atan2(o.z - e.z, o.x - e.x); } }
    }
    const pts = []; for (let k = 0; k < 7; k++) { const d = 7 + k * 4.4; pts.push({ x: e.x + Math.cos(a) * d, z: e.z + Math.sin(a) * d, t: 1.6 + k * 0.17 }); }
    G.strikes.push({ team: e.team, by: e.id, pts, t: 0 });
    ev(G, { k: 'skyfall', team: e.team, id: e.id, pts: pts.map((p) => [p.x, p.z]) });
  }
  return true;
}

function explode(G, x, z, r, dmg, by, weapon, y = 0.3) {
  ev(G, { k: 'boom', x, y, z, r, by: by ? by.id : -1, w: weapon });
  for (const o of G.ents) {
    if (!o.alive || (by && o.team === by.team && o !== by)) continue;
    const d = Math.hypot(o.x - x, o.z - z, (o.y + 0.9 - y) * 0.6); if (d > r) continue;
    if (!los3(G.map, x, y + 0.4, z, o.x, o.y + 1.0, o.z) && !los3(G.map, x, y + 0.4, z, o.x, o.y + 1.5, o.z)) continue;
    damage(G, o, dmg * (1 - (d / r) * 0.6) * (o === by ? 0.4 : 1), by, weapon);
  }
}

// ---------------------------------------------------------------- bots
const PREF = { breach: 6, lancer: 24, wasp: 10, vk9: 15 };
function think(G, e) {
  const A = e.ai;
  // target: an enemy this bot can actually see (3D line of sight, in its field of view or very close / just fired)
  let tgt = A.target != null ? G.ents[A.target] : null;
  if (tgt && (!tgt.alive || G.t < tgt.prot || Math.hypot(tgt.x - e.x, tgt.z - e.z) > 52)) { tgt = null; A.target = null; }
  if (tgt) { if (sees(G, e, tgt)) { A.lastSeen = { x: tgt.x, z: tgt.z, t: G.t }; A.lostT = 0; } else { A.lostT += 1 / 15; if (A.lostT > 1.4) { tgt = null; A.target = null; } } }
  if (!tgt) {
    let bd = 44;
    for (const o of G.ents) {
      if (o.team === e.team || !o.alive || G.t < o.prot) continue; const d = Math.hypot(o.x - e.x, o.z - e.z);
      if (d >= bd) continue;
      const fov = Math.abs(angDiff(e.yaw, Math.atan2(o.z - e.z, o.x - e.x))) < 1.25;
      const noticed = G.recon[e.team] > 0 || fov || d < 7 || G.t - o.lastFire < 0.6;
      if (noticed && sees(G, e, o)) { bd = d; tgt = o; }
    }
    if (tgt) {
      A.target = tgt.id; A.lastSeen = { x: tgt.x, z: tgt.z, t: G.t }; A.lostT = 0;
      // reaction time and first-aim error follow the bot's skill (0 = rookie lobby, 0.5 = normal, 1 = Veteran)
      const k = e.skill;
      A.react = lerp(0.82, 0.22, k) + G.R() * lerp(0.3, 0.1, k) + (tgt.crouch > 0.5 ? 0.1 : 0) + (tgt.you && k < 0.3 ? 0.12 : 0);
      A.err = (G.R() - 0.5) * lerp(0.46, 0.14, k); A.errP = (G.R() - 0.5) * lerp(0.22, 0.07, k);
    }
  }
  // objective: a zone we don't hold (nearest), sometimes hold one we do
  if (!A.zone || A.think <= 0) {
    A.think = 2 + G.R() * 2;
    const zs = G.zones.slice().sort((a, b) => Math.hypot(a.x - e.x, a.z - e.z) - Math.hypot(b.x - e.x, b.z - e.z));
    const want = zs.filter((z) => z.owner !== e.team);
    A.zone = G.mode === 'showdown' ? G.zones[1] : (want.length && G.R() < 0.82 ? want[Math.min(want.length - 1, G.R() < 0.7 ? 0 : 1)] : zs[Math.floor(G.R() * zs.length)]);
    A.wander = G.R() * Math.PI * 2;
  }
  return tgt;
}

/** The nearest cover spot that blocks the threat's view of a (crouched) body there, and that we can walk to. */
function findCover(G, e, th) {
  let best = null, bd = 13;
  const te = eyeOf(th);
  for (const c of G.cover) {
    const d = Math.hypot(c.x - e.x, c.z - e.z); if (d > bd) continue;
    // the box must sit between the spot and the threat
    if ((c.o.x - c.x) * (th.x - c.x) + (c.o.z - c.z) * (th.z - c.z) <= 0) continue;
    const y = groundAt(G.map, c.x, c.z, 0);
    if (los3(G.map, te[0], te[1], te[2], c.x, y + (c.low ? 0.95 : 1.4), c.z)) continue;
    if (G.ents.some((m) => m !== e && m.alive && m.ai.cover === c)) continue;
    bd = d; best = c;
  }
  return best;
}

function botStep(G, e, dt) {
  const A = e.ai; const W = armOf(e);
  A.think -= dt; A.strafeT -= dt; A.nadeT -= dt; A.coverT -= dt; A.peekT -= dt;
  const tgt = (G.tick + e.id) % 4 === 0 || A.target == null ? think(G, e) : (A.target != null ? G.ents[A.target] : null);
  let mx = 0, mz = 0, fire = false, crouch = false, jump = false;
  const hurt = e.hp < e.maxHp * 0.45 && G.t - e.lastHit < 2.5;
  // switch to the sidearm when the main weapon is empty in a fight at close range, back when safe
  if (tgt && e.slot === 0 && e.ammo === 0 && Math.hypot(tgt.x - e.x, tgt.z - e.z) < 12 && e.mags[1] > 0) setSlot(e, 1);
  else if (!tgt && e.slot === 1 && e.reload <= 0) setSlot(e, 0);
  // want cover? (hurt, reloading or under fire from a target we can't beat in the open)
  if (tgt && !A.cover && A.coverT <= 0 && (hurt || e.reload > 0 || (G.t - e.lastHit < 0.6 && G.R() < 0.35))) {
    A.cover = findCover(G, e, tgt); A.coverT = A.cover ? 4 + G.R() * 2 : 1.5; A.peekT = 1.2 + G.R() * 1.4;
  }
  if (A.cover && (A.coverT <= 0 || !tgt && G.R() < 0.01)) { A.cover = null; A.coverT = 2.5 + G.R() * 2; }
  let lookAt = null;
  if (tgt) {
    const seen = A.lostT === 0;
    const aimP = seen ? (sees(G, e, tgt) === 'head' && G.R() < 0.4 ? headOf(tgt) : chestOf(tgt)) : [A.lastSeen.x, 1.2, A.lastSeen.z];
    const dx = aimP[0] - e.x, dz = aimP[2] - e.z; const d = Math.hypot(dx, dz);
    const eye = eyeOf(e); const wantY = Math.atan2(dz, dx), wantP = Math.atan2(aimP[1] - eye[1], d);
    const k = Math.min(1, dt * lerp(4.5, 13, e.skill)); const settle = lerp(0.45, 1.7, e.skill);
    e.yaw += angDiff(e.yaw, wantY + A.err) * k; e.pitch += (wantP + A.errP - e.pitch) * k;
    A.err *= Math.exp(-dt * settle); A.errP *= Math.exp(-dt * settle);
    A.react -= dt;
    const onTarget = Math.abs(angDiff(e.yaw, wantY)) < 0.22 && Math.abs(e.pitch - wantP) < 0.2;
    // fire in bursts (auto weapons) when it can see the target
    if (A.react <= 0 && seen && d < W.range * 1.5 && onTarget) {
      if (A.burst <= 0) A.burst = W.auto ? 0.35 + G.R() * 0.5 : 0.15; fire = true;
    }
    if (A.burst > 0) { A.burst -= dt; if (A.burst <= 0) { A.react = W.auto ? 0.12 + G.R() * 0.22 : 0.05; } }
    if (A.cover) {
      // move to cover, crouch behind low cover, reload there, then peek out (stand / strafe) to shoot
      const c = A.cover; const cd = Math.hypot(c.x - e.x, c.z - e.z);
      if (cd > 0.6) { const f = field(G.map.nav, c.x, c.z); const [sx, sz] = steer(G.map.nav, f, e.x, e.z); if (sx || sz) { mx = sx; mz = sz; } else { mx = (c.x - e.x) / cd; mz = (c.z - e.z) / cd; } fire = fire && cd < 4; }
      else {
        const peeking = A.peekT < 0 && A.peekT > -1.1 && e.reload <= 0 && !hurt;
        if (A.peekT < -1.1) A.peekT = 0.9 + G.R() * 1.6;
        if (c.low) crouch = !peeking; else if (peeking) { const ux = dx / (d || 1), uz = dz / (d || 1); mx = -uz * A.strafe * 0.9; mz = ux * A.strafe * 0.9; }
        if (!peeking) fire = false;
        if (e.reload <= 0 && e.ammo < W.mag * 0.5 && !peeking) startReload(e);
      }
    } else {
      if (A.strafeT <= 0) { A.strafe = G.R() < 0.5 ? -1 : 1; A.strafeT = 0.5 + G.R() * 1.0; if (G.R() < 0.12 && e.ground) jump = true; if (G.R() < 0.2) A.crouchT = 0.6; }
      const pref = PREF[W.id] || 12;
      const toward = !seen ? 1 : d > pref + 3 ? 1 : d < pref - 4 ? -0.8 : 0;
      const ux = dx / (d || 1), uz = dz / (d || 1);
      if (seen) { mx = ux * toward + -uz * A.strafe * 0.85; mz = uz * toward + ux * A.strafe * 0.85; }
      else { const f = field(G.map.nav, A.lastSeen.x, A.lastSeen.z); const [sx, sz] = steer(G.map.nav, f, e.x, e.z); mx = sx; mz = sz; }
      if ((A.crouchT || 0) > 0) { A.crouchT -= dt; crouch = true; }
      if (A.retreat > 0) { mx = -ux; mz = -uz; }
    }
    // charge at a target hiding behind cover
    if (A.nadeT <= 0 && e.nades > 0 && d > 7 && d < 15 && (!seen || tgt.crouch > 0.5) && G.R() < 0.5) { throwCharge(G, e, tgt.x + (G.R() - 0.5) * 2, tgt.z + (G.R() - 0.5) * 2); A.nadeT = 6 + G.R() * 8; }
    if (G.t - e.lastHit < 0.3 && e.dashCd <= 0 && G.R() < 0.06 && !A.cover) e.dash = 0.3;
    if (e.reload <= 0 && e.ammo === 0) startReload(e);
  } else {
    if (e.reload <= 0 && e.ammo < armOf(e).mag * 0.6) startReload(e);
    const z = A.zone; let gx = z.x, gz = z.z;
    const inZone = Math.hypot(e.x - z.x, e.z - z.z) < z.r - 0.5;
    if (inZone) { A.wander += dt * 0.6; gx = z.x + Math.cos(A.wander + e.id) * z.r * 0.5; gz = z.z + Math.sin(A.wander + e.id) * z.r * 0.5; }
    if (A.retreat > 0) { const sp = G.map.spawns[e.team][0]; gx = sp.x; gz = sp.z; }
    // heard a shot or got hit from somewhere: turn to look
    if (G.t - e.lastHit < 1.2 && e.lastHitBy >= 0) { const b = G.ents[e.lastHitBy]; lookAt = [b.x, b.z]; }
    const f = field(G.map.nav, gx, gz); const [sx, sz] = steer(G.map.nav, f, e.x, e.z);
    const dd = Math.hypot(gx - e.x, gz - e.z);
    if (sx || sz) { mx = sx; mz = sz; } else if (dd > 0.6) { mx = (gx - e.x) / dd; mz = (gz - e.z) / dd; }
    const la = lookAt ? Math.atan2(lookAt[1] - e.z, lookAt[0] - e.x) : (mx || mz) ? Math.atan2(mz, mx) : e.yaw;
    e.yaw += angDiff(e.yaw, la) * Math.min(1, dt * 6); e.pitch += (0 - e.pitch) * Math.min(1, dt * 4);
  }
  if (e.hp < e.maxHp * 0.3 && G.t - e.lastHit < 2 && G.R() < 0.02 && !A.cover) A.retreat = 2.5;
  A.retreat -= dt;
  return { mx, mz, fire, crouch, jump };
}
function setSlot(e, s) { if (e.slot === s) return; e.mags[e.slot] = e.ammo; e.slot = s; e.ammo = e.mags[s]; e.reload = 0; e.cd = Math.max(e.cd, 0.32); e.bloom = 0; }

// ---------------------------------------------------------------- charges
function throwCharge(G, e, tx, tz) {
  if (e.nades <= 0) return; e.nades--;
  let dx = tx - e.x, dz = tz - e.z; const d = Math.hypot(dx, dz) || 1; const L = Math.min(16, d); dx /= d; dz /= d;
  // stop at walls (anything taller than a thrown arc clears)
  const f = rayHit3(G.map, e.x, e.y + 1.6, e.z, e.x + dx * L, e.y + 1.6, e.z + dz * L).f; const land = Math.max(1, L * f - 0.6);
  const lx = e.x + dx * land, lz = e.z + dz * land;
  G.charges.push({ by: e.id, team: e.team, x0: e.x, z0: e.z, y0: e.y + 1.5, x: lx, z: lz, y: groundAt(G.map, lx, lz, 9, 0.1), t: 0, fuse: 1.15, fly: 0.55 });
  ev(G, { k: 'throw', id: e.id });
}

// ---------------------------------------------------------------- the step
/**
 * input (for your operator): { mx, mz (world move, |m| <= 1), yaw, pitch (absolute view), fire, ads, sprint, crouch,
 *   jump, slot (0 main | 1 sidearm), dash, nade, streak, reload }. Legacy top-down aim (ax, az) still works.
 */
export function step(G, input = {}) {
  const dt = DT; G.events.length = 0; if (G.over) return G;
  G.t += dt; G.tick++;
  if (G.roundOver > 0) { G.roundOver -= dt; if (G.roundOver <= 0) nextRound(G); record(G); return G; }
  G.left = Math.max(0, G.left - dt);
  G.recon[0] = Math.max(0, G.recon[0] - dt); G.recon[1] = Math.max(0, G.recon[1] - dt);
  for (const e of G.ents) {
    e.firing = Math.max(0, e.firing - dt); e.kick = Math.max(0, e.kick - dt * 6);
    if (!e.alive) { e.respawn -= dt; if (e.respawn <= 0 && G.mode === 'turf') { spawn(G, e); ev(G, { k: 'spawn', id: e.id }); } continue; }
    let mx = 0, mz = 0, wantFire = false, ads = false, sprint = false, crouch = false, jump = false, slide = false;
    if (e.bot) { const b = botStep(G, e, dt); mx = b.mx; mz = b.mz; wantFire = b.fire; crouch = b.crouch; jump = b.jump; ads = b.fire && armOf(e).id === 'lancer'; }
    else {
      mx = input.mx || 0; mz = input.mz || 0; const l = Math.hypot(mx, mz); if (l > 1) { mx /= l; mz /= l; }
      if (input.ax != null) e.yaw = Math.atan2(input.az - e.z, input.ax - e.x); else if (input.yaw != null) e.yaw = input.yaw;
      if (input.pitch != null) e.pitch = Math.max(-1.45, Math.min(1.45, input.pitch));
      wantFire = !!input.fire; ads = !!input.ads; sprint = !!input.sprint; crouch = !!input.crouch; jump = !!input.jump; slide = !!input.slide;
      if (input.slot != null) setSlot(e, input.slot ? 1 : 0);
      if (input.dash && e.dashCd <= 0) e.dash = 0.32;
      if (input.nade && e.nades > 0 && G.charges.filter((c) => c.by === e.id).length === 0) {
        if (input.ax != null) throwCharge(G, e, input.ax, input.az);
        else { const L = Math.max(4, Math.min(16, 9 + e.pitch * 18)); throwCharge(G, e, e.x + Math.cos(e.yaw) * L, e.z + Math.sin(e.yaw) * L); }
      }
      if (input.streak) useStreak(G, e);
      if (input.reload) startReload(e);
    }
    const W = armOf(e);
    // slide: sprint (or run fast) + crouch on the ground -> a burst of speed that glides out over 0.75 s
    const hv = Math.hypot(e.vx, e.vz);
    if (slide && e.ground && e.slideCd <= 0 && e.slide <= 0 && hv > 4.5) {
      const ux = e.vx / hv, uz = e.vz / hv; const v0 = Math.max(hv, SLIDE_V * W.speed);
      e.vx = ux * v0; e.vz = uz * v0; e.slide = SLIDE_T; e.slideCd = SLIDE_CD + SLIDE_T; ev(G, { k: 'slide', id: e.id });
    }
    e.slideCd = Math.max(0, e.slideCd - dt);
    // stance (linear, so crouch and aim-down-sights take a fixed, predictable time)
    e.crouch = approach(e.crouch, crouch || e.dash > 0 || e.slide > 0 ? 1 : 0, dt / 0.11);
    e.ads = approach(e.ads, ads && e.reload <= 0 && e.dash <= 0 && e.slide <= 0 ? 1 : 0, dt / (W.adsT || 0.16));
    // sprint only forward-ish, never while firing or aiming
    const fwd = mx * Math.cos(e.yaw) + mz * Math.sin(e.yaw);
    e.sprint = sprint && fwd > 0.5 && !wantFire && e.ads < 0.3 && e.crouch < 0.5 && e.slide <= 0;
    // movement
    let sp = WALK * W.speed * (wantFire && W.id === 'lancer' ? 0.8 : 1) * (e.sprint ? 1.34 : 1) * (1 - 0.45 * e.crouch) * (1 - 0.3 * e.ads);
    if (e.dash > 0) { if (e.dashCd <= 0) { e.dashCd = e.perk === 'quick' ? 2.2 : 3.2; ev(G, { k: 'dash', id: e.id }); } e.dash -= dt; sp = 13.5; if (!(mx || mz)) { mx = Math.cos(e.yaw); mz = Math.sin(e.yaw); } }
    e.dashCd = Math.max(0, e.dashCd - dt);
    const tvx = mx * sp, tvz = mz * sp;
    if (e.dash > 0) { const a = Math.min(1, 40 * dt); e.vx += (tvx - e.vx) * a; e.vz += (tvz - e.vz) * a; }
    else if (e.slide > 0 && e.ground) { // glide: low friction, a little steering, never faster
      e.slide -= dt; const v = Math.hypot(e.vx, e.vz); const nv = Math.max(0, v - SLIDE_FRIC * dt);
      if (mx || mz) { e.vx += mx * 9 * dt; e.vz += mz * 9 * dt; }
      const v2 = Math.hypot(e.vx, e.vz) || 1; e.vx *= nv / v2; e.vz *= nv / v2;
      if (nv < 3.2) e.slide = 0;
    } else if (e.ground) { // linear acceleration towards the wished velocity (friction when there is no input)
      let dvx = tvx - e.vx, dvz = tvz - e.vz; const dl = Math.hypot(dvx, dvz); const step = (mx || mz ? ACC_G : FRIC) * dt;
      if (dl > step) { dvx *= step / dl; dvz *= step / dl; } e.vx += dvx; e.vz += dvz;
    } else if (mx || mz) { // air control: steer freely, keep the momentum you had (slide-hops stay fast)
      const cur = Math.hypot(e.vx, e.vz); e.vx += mx * ACC_A * dt; e.vz += mz * ACC_A * dt;
      const cap = Math.max(cur, sp), now = Math.hypot(e.vx, e.vz); if (now > cap) { e.vx *= cap / now; e.vz *= cap / now; }
    }
    if (e.slide > 0 && !e.ground) e.slide = Math.max(0, e.slide - dt);
    // jump (with coyote time after leaving a ledge and a short buffer before landing), gravity, mantle, steps
    e.airT = e.ground ? 0 : e.airT + dt; if (jump) e.jumpBuf = JBUF; else e.jumpBuf = Math.max(0, e.jumpBuf - dt);
    const canJump = (e.ground || (e.airT < COYOTE && e.vy <= 0 && !e.jumped)) && (e.crouch < 0.6 || e.slide > 0);
    let jumpedNow = false;
    if (e.jumpBuf > 0 && canJump) { e.vy = JUMP; e.ground = false; e.jumped = true; e.jumpBuf = 0; e.slide = 0; jumpedNow = true; ev(G, { k: 'jump', id: e.id }); }
    const nx = e.x + e.vx * dt, nz = e.z + e.vz * dt;
    if (!e.ground || jumpedNow) {
      const m = mantleTarget(G.map, nx, nz, e.y + 0.05);
      if (m && e.vy < 3.2 && (mx || mz) && (Math.hypot(mx, mz) > 0.3)) { e.y = m.h; e.vy = 0; e.ground = true; e.jumped = false; ev(G, { k: 'mantle', id: e.id }); }
    }
    [e.x, e.z] = collide3(G.map, nx, nz, e.y, 0.45);
    // the part of the velocity pointing into what we hit is removed (the push direction is the contact normal); the
    // part along the surface is kept, so you slide along walls and around poles and corners instead of sticking
    { const px = e.x - nx, pz = e.z - nz, pl = Math.hypot(px, pz);
      if (pl > 1e-7) { const ux = px / pl, uz = pz / pl; const vn = e.vx * ux + e.vz * uz; if (vn < 0) { e.vx -= vn * ux; e.vz -= vn * uz; } } }
    const g = groundAt(G.map, e.x, e.z, e.y);
    if (e.ground && e.y > g + 0.01) { if (e.y - g <= BODY.step + 0.01 && e.vy <= 0) e.y = g; else e.ground = false; }
    if (!e.ground) { e.vy -= GRAV * dt; e.y += e.vy * dt; if (e.y <= g) { if (e.vy < -7.4) ev(G, { k: 'land', id: e.id, v: e.vy }); e.y = g; e.vy = 0; e.ground = true; e.jumped = false; } }
    else if (e.y < g) e.y = g;
    e.moving = Math.hypot(e.vx, e.vz);
    // weapon
    e.cd -= dt; e.bloom = Math.max(0, e.bloom - dt * (W.bloom * 9 + 0.02));
    const trigger = wantFire && (W.auto || !e.trig); e.trig = wantFire;
    if (e.reload > 0) { e.reload -= dt; if (e.reload <= 0) { e.ammo = W.mag; e.mags[e.slot] = e.ammo; ev(G, { k: 'reloaded', id: e.id }); } }
    else if (trigger && e.cd <= 0 && e.ammo > 0 && G.t - e.spawnT > 0.3 && !e.sprint) fire(G, e);
    else if (wantFire && e.ammo <= 0 && e.reload <= 0) startReload(e);
    // regen
    if (G.t - e.lastHit > 3.6 && e.hp < e.maxHp) e.hp = Math.min(e.maxHp, e.hp + 34 * dt);
  }
  // charges
  for (const c of G.charges) { c.t += dt; if (c.t >= c.fuse && !c.done) { c.done = true; explode(G, c.x, c.z, 4.4, 96, G.ents[c.by], 'charge', (c.y || 0) + 0.2); } }
  G.charges = G.charges.filter((c) => !c.done);
  // skyfall
  for (const s of G.strikes) { s.t += dt; for (const p of s.pts) if (!p.done && s.t >= p.t) { p.done = true; explode(G, p.x, p.z, 4.8, 130, G.ents[s.by], 'skyfall'); } }
  G.strikes = G.strikes.filter((s) => s.pts.some((p) => !p.done));
  // sentries
  for (const s of G.sentries) {
    s.life -= dt; s.cd -= dt; s.firing = Math.max(0, s.firing - dt); if (s.hp <= 0 || s.life <= 0) continue;
    let tgt = null, bd = 20;
    for (const o of G.ents) if (o.team !== s.team && o.alive) { const d = Math.hypot(o.x - s.x, o.z - s.z); const c = chestOf(o); if (d < bd && los3(G.map, s.x, 0.9, s.z, c[0], c[1], c[2])) { bd = d; tgt = o; } }
    if (tgt) {
      const want = Math.atan2(tgt.z - s.z, tgt.x - s.x); s.yaw += angDiff(s.yaw, want) * Math.min(1, dt * 8);
      if (s.cd <= 0 && Math.abs(angDiff(s.yaw, want)) < 0.2) {
        s.cd = 0.13; s.firing = 0.06; const c = chestOf(tgt); const p = Math.atan2(c[1] - 0.9, bd);
        const d = dir3(s.yaw + (G.R() - 0.5) * 0.08, p + (G.R() - 0.5) * 0.06); const o = [s.x + d[0] * 0.6, 0.9, s.z + d[2] * 0.6];
        let best = 22 * rayHit3(G.map, o[0], o[1], o[2], o[0] + d[0] * 22, o[1] + d[1] * 22, o[2] + d[2] * 22).f; let hit = null;
        for (const q of G.ents) { if (q.team === s.team || !q.alive) continue; const h = rayBody(o, d, q.x, q.y, q.z, q.crouch); if (h && h.t < best) { best = h.t; hit = q; } }
        ev(G, { k: 'shot', from: -1, w: 'sentry', x0: o[0], y0: o[1], z0: o[2], x1: o[0] + d[0] * best, y1: o[1] + d[1] * best, z1: o[2] + d[2] * best, hit: hit ? hit.id : -1, team: s.team, nx: 0, ny: 0, nz: 0 });
        if (hit) damage(G, hit, 10, G.ents[s.by], 'sentry');
      }
    }
  }
  G.sentries = G.sentries.filter((s) => s.hp > 0 && s.life > 0);
  // zones
  for (const z of G.zones) {
    z.n = [0, 0];
    if (G.mode === 'showdown' && z.id !== 'B') continue;
    for (const e of G.ents) if (e.alive && Math.hypot(e.x - z.x, e.z - z.z) < z.r && e.y < 3) z.n[e.team]++;
    z.contest = z.n[0] > 0 && z.n[1] > 0;
    if (z.contest) continue;
    const t = z.n[0] > 0 ? 0 : z.n[1] > 0 ? 1 : -1; if (t < 0) continue;
    const dir = t === 0 ? 1 : -1; const rate = 0.3 * Math.min(3, z.n[t]) ** 0.6;
    const before = z.prog; z.prog = Math.max(-1, Math.min(1, z.prog + dir * rate * dt));
    if (z.owner !== t && Math.sign(before) !== Math.sign(z.prog) && z.owner !== -1 && Math.abs(before) > 0) { z.owner = -1; ev(G, { k: 'neutral', zone: z.id }); }
    if (Math.abs(z.prog) >= 1 && z.owner !== t) {
      z.owner = t; ev(G, { k: 'cap', zone: z.id, team: t });
      for (const e of G.ents) if (e.alive && e.team === t && Math.hypot(e.x - z.x, e.z - z.z) < z.r) { e.caps++; e.score += 150; }
      if (G.mode === 'showdown') { endRound(G, t); break; }
    }
  }
  // scoring
  if (G.mode === 'turf') {
    for (const z of G.zones) if (z.owner >= 0) G.score[z.owner] += dt;
    for (const e of G.ents) if (e.alive) for (const z of G.zones) if (z.owner === e.team && Math.hypot(e.x - z.x, e.z - z.z) < z.r) e.score += 10 * dt;
    if (G.score[0] >= G.target || G.score[1] >= G.target || G.left <= 0) finish(G, G.score[0] > G.score[1] ? 0 : G.score[1] > G.score[0] ? 1 : -1);
  } else if (!G.roundOver) {
    const alive = [0, 1].map((t) => G.ents.some((e) => e.team === t && e.alive));
    if (!alive[0] || !alive[1]) endRound(G, alive[0] ? 0 : 1);
    else if (G.left <= 0) { const z = G.zones[1]; endRound(G, z.owner >= 0 ? z.owner : z.prog > 0.05 ? 0 : z.prog < -0.05 ? 1 : (G.ents.filter((e) => e.team === 0 && e.alive).length >= G.ents.filter((e) => e.team === 1 && e.alive).length ? 0 : 1)); }
  }
  record(G);
  return G;
}

function endRound(G, t) {
  if (G.roundOver > 0 || G.over) return;
  G.rounds[t]++; ev(G, { k: 'round', team: t, rounds: G.rounds.slice() });
  for (const e of G.ents) if (e.team === t) e.score += 100;
  if (G.rounds[t] >= G.toWin) finish(G, t); else G.roundOver = 3;
}
function nextRound(G) {
  G.round++; G.left = G.time; G.charges = []; G.strikes = []; G.sentries = []; G.recon = [0, 0];
  for (const z of G.zones) { z.owner = -1; z.prog = 0; }
  for (const e of G.ents) { spawn(G, e, true); e.owned = []; }
  ev(G, { k: 'roundStart', round: G.round });
}
function finish(G, winner) {
  if (G.over) return; G.over = true; G.winner = winner;
  for (const e of G.ents) if (e.team === winner) e.score += 500;
  ev(G, { k: 'end', winner });
}

// ---------------------------------------------------------------- history (30 Hz) for kill-cams and replays
function record(G) {
  if (G.tick % 2) return;
  const s = new Float32Array(G.ents.length * HS);
  G.ents.forEach((e, i) => {
    const o = i * HS; s[o] = e.x; s[o + 1] = e.z; s[o + 2] = e.yaw; s[o + 3] = e.alive ? 1 : 0; s[o + 4] = e.moving; s[o + 5] = e.firing; s[o + 6] = e.hp / e.maxHp; s[o + 7] = e.dash > 0 ? 1 : 0; s[o + 8] = e.kick;
    s[o + 9] = e.y; s[o + 10] = e.pitch; s[o + 11] = e.crouch; s[o + 12] = e.slot + (e.reload > 0 ? 2 : 0) + e.ads * 0.5;
  });
  G.hist.push({ tick: G.tick, t: G.t, s, sentries: G.sentries.map((q) => [q.x, q.z, q.yaw, q.team, q.firing]), zones: G.zones.map((z) => [z.owner, z.prog]), charges: G.charges.map((c) => [c.x0, c.z0, c.x, c.z, c.t, c.fly, c.y0 || 1.5, c.y || 0]) });
}
/** History frame index at or after tick. */
export function histAt(G, tick) { let lo = 0, hi = G.hist.length - 1; while (lo < hi) { const m = (lo + hi) >> 1; if (G.hist[m].tick < tick) lo = m + 1; else hi = m; } return lo; }
/** Events from the history between two ticks (for replays). */
export function histEvents(G, t0, t1) { return G.histEv.filter((e) => e.tick > t0 && e.tick <= t1); }

/** The player's best moment: the window around their biggest multi-kill / streak (else the last kill they made). */
export function bestMoment(G, who = G.you) {
  if (!who) return null;
  const kills = G.histEv.filter((e) => e.k === 'kill' && e.by === who.id);
  if (!kills.length) return null;
  let best = kills[kills.length - 1], score = -1;
  for (const k of kills) { const s = k.multi * 10 + (k.streak >= 5 ? 6 : k.streak >= 3 ? 3 : 0) + (k.w === 'skyfall' ? 4 : 0) + (k.hs ? 2 : 0); if (s >= score) { score = s; best = k; } }
  const first = kills.filter((k) => k.tick <= best.tick && best.tick - k.tick < 60 * 4.5)[0] || best;
  return { t0: Math.max(0, first.tick - 60 * 2.2), t1: best.tick + 60 * 1.4, multi: best.multi, streak: best.streak, w: best.w, hs: !!best.hs };
}

/** Result summary for the player (score card, contract check, challenge links). */
export function result(G) {
  const y = G.you; if (!y) return null;
  const mates = G.ents.slice().sort((a, b) => b.score - a.score);
  return {
    mode: G.mode, map: G.map.id, win: G.winner === y.team, draw: G.winner < 0, score: Math.round(y.score), kills: y.kills, deaths: y.deaths, assists: y.assists, heads: y.heads,
    caps: y.caps, bestStreak: y.bestStreak, weapon: y.weapon, perk: y.perk, team: [Math.round(G.score[0]), Math.round(G.score[1])], rounds: G.rounds.slice(),
    mvp: mates[0].id === y.id, rank: mates.indexOf(y) + 1, board: mates.map((e) => ({ name: e.name, team: e.team, you: e.you, k: e.kills, d: e.deaths, s: Math.round(e.score) })),
  };
}
export { TEAMS };