// Round simulation. Pure logic: no timers, no sockets, no room lifecycle.
// Smooth steering, curved light trails, shrinking bounds. All events go to
// this.events for the room to drain and broadcast.

export const CFG = {
  arena: 400,                // square, (0,0)..(arena,arena), meters
  tickRate: 30,
  speed: 30,                 // m/s
  boostSpeed: 48,
  turnRate: 2.6,             // rad/s at full steer
  steerAccel: 3.2,           // input units/s; keeps keyboard turns progressive
  boostDrain: 28,            // energy/s
  boostRegen: 9,
  energyMax: 100,
  trailGapDist: 5,           // sample a trail point at least every N meters
  trailGapAngle: 0.035,      // ... or when heading drifted this many rad; low = smooth curves
  selfSkipArc: 12,           // own trail within this arc distance of the head is inert
  trailMaxArc: 420,          // trails wane: older wall than this arc length vanishes
  shrinkAt: 60,              // seconds into the round the walls start closing
  shrinkDuration: 45,        // seconds until bounds collapse to nothing
  roundHardCap: 120,         // absolute round end
  spawnRadius: 140,
  // power-ups (spec Раздел II.3): rotating pickups, 1 carried, spawn cadence
  // (2026-07 feedback: cubes spawn faster and stack higher so fights happen)
  pickupFirst: 4,            // first pickup appears this many seconds in
  pickupEvery: 7,
  pickupMax: 10,
  pickupRadius: 3,
  phaseDuration: 1.5,        // Phase Shift: pass through any trail
  empRadius: 5,              // EMP Wave: erase enemy trails in this radius
  surgeDuration: 1,          // Overcharge Boost
  surgeMult: 3,              // ...at 300% speed
  snipNoTrail: 3,            // Trail Snip: seconds riding with no tail
  cannonShots: 3,            // Grid Cannon: charges per pickup
  shotSpeed: 95,             // ...bolt velocity, m/s
  shotLife: 2.2,             // ...seconds before a bolt fizzles
  shotHitRadius: 1.8,        // ...derez distance vs bikes
  shotCutRadius: 2.4,        // ...trail-carving distance along the flight path
  mineArm: 0.8,              // Trap Mine: arming delay after drop
  mineRadius: 2.8,           // ...trigger distance (owner immune)
  padDuration: 1.3,          // boost pads: free surge on touch
  padMult: 2,
  padRadius: 5,
  raceLaps: 3,               // race mode
  respawnDelay: 1,           // ...crash -> back on track in this many seconds
  respawnShield: 1.2,        // ...phase shield after respawn
};

export const ITEM_TYPES = ['phase', 'emp', 'surge', 'snip', 'cannon', 'trap'];

export const PALETTE = [
  '#00FFFF', '#00FF66', '#FF0055', '#FFE600',
  '#B000FF', '#FF6600', '#FFFFFF', '#4488FF',
  '#FF00FF', '#AAFF00', '#FF2222', '#00C8A0',
];

// --- geometry -----------------------------------------------------------------

// Distance from point (px,py) to segment a->b.
export function pointSegDist(px, py, ax, ay, bx, by) {
  const dx = bx - ax, dy = by - ay;
  const len2 = dx * dx + dy * dy;
  const t = len2 < 1e-12 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2));
  return Math.hypot(px - (ax + dx * t), py - (ay + dy * t));
}

// First hit of segment p0->p1 against segment a->b, as distance from p0.
// Handles the collinear case (driving straight into a straight trail).
// Returns Infinity when there is no hit.
export function segHitDist(p0x, p0y, p1x, p1y, ax, ay, bx, by) {
  const rx = p1x - p0x, ry = p1y - p0y;
  const sx = bx - ax, sy = by - ay;
  const denom = rx * sy - ry * sx;
  const qpx = ax - p0x, qpy = ay - p0y;
  const rlen = Math.hypot(rx, ry);
  if (rlen < 1e-9) return Infinity;

  if (Math.abs(denom) < 1e-9) {
    // parallel; hit only if collinear
    if (Math.abs(qpx * ry - qpy * rx) > 1e-6 * Math.max(1, rlen)) return Infinity;
    // project obstacle endpoints onto the movement direction
    const ux = rx / rlen, uy = ry / rlen;
    const ta = (ax - p0x) * ux + (ay - p0y) * uy;
    const tb = (bx - p0x) * ux + (by - p0y) * uy;
    const lo = Math.min(ta, tb), hi = Math.max(ta, tb);
    if (hi < 0 || lo > rlen) return Infinity;
    return Math.max(0, lo);
  }

  const t = (qpx * sy - qpy * sx) / denom;      // along movement, 0..1
  const u = (qpx * ry - qpy * rx) / denom;      // along obstacle, 0..1
  if (t < -1e-9 || t > 1 + 1e-9 || u < -1e-9 || u > 1 + 1e-9) return Infinity;
  return Math.max(0, t) * rlen;
}

// --- match --------------------------------------------------------------------

export class Match {
  // players: array of {id, name, color, shape, pattern, bot} — membership is the room's job
  constructor(players, rng = Math.random, cfg = {}) {
    this.cfg = { ...CFG, ...cfg };
    this.obstacles = this.cfg.obstacles || []; // lethal buildings (maps.js rects)
    this.rng = rng;
    this.time = 0;
    this.events = [];
    this.over = false;
    this.winner = null;
    this.bikes = new Map();
    this.pickups = new Map();
    this.pickupSeq = 0;
    this.nextPickupAt = this.cfg.pickupFirst;
    this.shots = new Map();
    this.shotSeq = 0;
    this.mines = new Map();
    this.mineSeq = 0;
    this.pads = this.makePads();
    this.race = this.cfg.race
      ? { gates: this.cfg.gates || [], finishOrder: [], nextPosAt: 0 }
      : null;

    const n = players.length;
    // Everyone rides clockwise so nobody meets head-on early. Beyond 8 riders a
    // single ring gets too crowded (~35% died <5s in bot sims), so big rounds
    // launch from two staggered rings; maps with obstacles pin the exact radii.
    const half = Math.ceil(n / 2);
    const rings = n > 8
      ? (this.cfg.spawnRings || [this.cfg.spawnRadius * 0.68, this.cfg.spawnRadius * 1.18])
      : [this.cfg.spawnRadius];
    players.forEach((info, i) => {
      let x, y, heading;
      if (this.cfg.spawnGrid) {
        // race start grid: parallel lanes behind the start line, nobody's
        // launch path crosses a neighbour's trail
        const gr = this.cfg.spawnGrid;
        x = gr.x0 + (i % gr.cols) * gr.dx;
        y = gr.y0 + Math.floor(i / gr.cols) * gr.dy;
        heading = gr.heading;
      } else {
        let ang, radius;
        if (rings.length < 2) {
          ang = (i / n) * Math.PI * 2;
          radius = rings[0];
        } else {
          const inner = i < half;
          const k = inner ? i : i - half, cnt = inner ? half : n - half;
          ang = (k / cnt) * Math.PI * 2 + (inner ? 0 : Math.PI / cnt);
          radius = inner ? rings[0] : rings[1];
        }
        const cx = this.cfg.arena / 2, cy = this.cfg.arena / 2;
        x = cx + Math.cos(ang) * radius;
        y = cy + Math.sin(ang) * radius;
        heading = ang + Math.PI / 2;
      }
      this.bikes.set(info.id, {
        id: info.id, name: info.name, color: info.color,
        shape: info.shape || 'blade', pattern: info.pattern || 'solid', bot: info.bot,
        alive: true,
        x, y, px: x, py: y, heading,
        steer: 0, steerTarget: 0, boosting: false, energy: this.cfg.energyMax,
        kills: 0, pickups: 0, uses: 0, boostTime: 0, padHits: 0, survived: 0,
        item: null, itemCharges: 0, phaseUntil: 0, surgeUntil: 0, padUntil: 0,
        noTrailUntil: 0, cuts: new Set(),
        // race state (ignored elsewhere): lap = completed laps, ghost = derezzed
        // and waiting for the 1s respawn on the crash spot
        lap: 0, nextCp: 0, ghost: false, respawnAt: 0,
        trail: [{ x, y }],       // sampled points; live segment = last point -> head
        trailArc: [0],           // cumulative arc length at each point
        trailStart: 0,           // segments before this index have waned away
        // Cheap broad-phase bounds for committed trail points. The reference
        // and count also let tests/tools replace trails without stale bounds.
        _trailBounds: { ref: null, count: 0, minX: x, minY: y, maxX: x, maxY: y },
        headingAtSample: heading,
      });
    });
    // race launch: brief trail immunity so the grid can merge into one line
    // without an instant pileup (walls stay lethal)
    if (this.race) {
      for (const b of this.bikes.values()) b.phaseUntil = 2.5;
    }
  }

  // Boost pads: either pinned by the map (race straights) or scattered at
  // round start — never on top of an obstacle, never inside another pad.
  makePads() {
    const c = this.cfg;
    if (c.padSpots) return c.padSpots.map((p, i) => ({ id: i + 1, x: p.x, y: p.y }));
    const pads = [];
    const count = c.padCount || 0;
    const margin = 30;
    for (let i = 0; i < count; i++) {
      let x, y, tries = 30;
      do {
        x = margin + this.rng() * (c.arena - margin * 2);
        y = margin + this.rng() * (c.arena - margin * 2);
      } while (--tries > 0 && (this.insideObstacle(x, y, c.padRadius + 3)
        || pads.some(p => Math.hypot(p.x - x, p.y - y) < 45)));
      if (tries > 0) pads.push({ id: i + 1, x, y });
    }
    return pads;
  }

  setSteer(id, v) {
    const b = this.bikes.get(id);
    if (b && b.alive && Number.isFinite(v)) b.steerTarget = Math.max(-1, Math.min(1, v));
  }

  setBoost(id, on) {
    const b = this.bikes.get(id);
    if (b && b.alive) b.boosting = !!on && b.energy > 0;
  }

  bounds() {
    const c = this.cfg;
    const t = this.time;
    if (t <= c.shrinkAt) return [0, 0, c.arena, c.arena];
    const f = Math.min(1, (t - c.shrinkAt) / c.shrinkDuration);
    const inset = (c.arena / 2) * f;
    return [inset, inset, c.arena - inset, c.arena - inset];
  }

  step(dt) {
    if (this.over) return;
    this.time += dt;
    if (this.race) this.stepRespawns();
    const alive = [...this.bikes.values()].filter(b => b.alive && !b.ghost);

    // move
    for (const b of alive) {
      if (b.boosting) {
        b.energy -= this.cfg.boostDrain * dt;
        b.boostTime += dt;
        if (b.energy <= 0) { b.energy = 0; b.boosting = false; }
      } else {
        b.energy = Math.min(this.cfg.energyMax, b.energy + this.cfg.boostRegen * dt);
      }
      const steerStep = this.cfg.steerAccel * dt;
      b.steer += Math.max(-steerStep, Math.min(steerStep, b.steerTarget - b.steer));
      b.heading += b.steer * this.cfg.turnRate * dt;
      let sp = b.boosting ? this.cfg.boostSpeed : this.cfg.speed;
      if (b.surgeUntil > this.time) sp = this.cfg.speed * this.cfg.surgeMult;
      else if (b.padUntil > this.time) sp = Math.max(sp, this.cfg.speed * this.cfg.padMult);
      b.px = b.x; b.py = b.y;
      b.x += Math.cos(b.heading) * sp * dt;
      b.y += Math.sin(b.heading) * sp * dt;

      if (b.noTrailUntil > 0) {
        if (this.time < b.noTrailUntil) {
          // sniped: the tail restarts from wherever the bike is right now
          b.trail[0].x = b.x; b.trail[0].y = b.y;
          b._trailBounds = { ref: b.trail, count: 1, minX: b.x, minY: b.y, maxX: b.x, maxY: b.y };
          continue;
        }
        b.noTrailUntil = 0;
        b.trail[0].x = b.x; b.trail[0].y = b.y;
        b.headingAtSample = b.heading;
        this.events.push({ t: 'tp', id: b.id, x: r1(b.x), y: r1(b.y) });
        continue;
      }

      // trail sampling
      const lastIdx = b.trail.length - 1;
      const last = b.trail[lastIdx];
      const d = Math.hypot(b.x - last.x, b.y - last.y);
      if (d >= this.cfg.trailGapDist || Math.abs(b.heading - b.headingAtSample) >= this.cfg.trailGapAngle) {
        b.trail.push({ x: b.x, y: b.y });
        b.trailArc.push(b.trailArc[lastIdx] + d);
        b.headingAtSample = b.heading;
        this.events.push({ t: 'tp', id: b.id, x: r1(b.x), y: r1(b.y) });
      }

      // wane: committed segments further than trailMaxArc behind the head are
      // gone — invisible on clients and inert here. Indices never shift, so
      // EMP cut sets stay valid.
      const headArc = b.trailArc[b.trail.length - 1]
        + Math.hypot(b.x - b.trail[b.trail.length - 1].x, b.y - b.trail[b.trail.length - 1].y);
      while (b.trailStart + 1 < b.trail.length
        && headArc - b.trailArc[b.trailStart + 1] > this.cfg.trailMaxArc) b.trailStart++;
    }

    this.stepPickups(alive);
    this.stepPads(alive);
    this.stepShots(dt, alive);
    this.stepMines(alive);

    // collide (everyone moved: order-independent)
    const dead = [];
    for (const b of alive) {
      if (!b.alive || b.ghost) continue; // already claimed by a shot or mine
      const hit = this.collide(b);
      if (hit) dead.push({ b, hit });
    }
    for (const { b, hit } of dead) this.kill(b, hit);

    if (this.race) this.stepRace(alive);
    if (this.over) return; // a race finish already closed the round

    // round end?
    const remaining = [...this.bikes.values()].filter(b => b.alive);
    if (this.race) {
      if (this.time >= this.cfg.roundHardCap || remaining.length === 0) this.finish(remaining);
    } else if (remaining.length <= 1 || this.time >= this.cfg.roundHardCap) {
      this.finish(remaining);
    }
  }

  stepPickups(alive) {
    const c = this.cfg;
    const [minx, miny, maxx, maxy] = this.bounds();
    // spawn on the 20s cadence, inside the (possibly shrunk) bounds
    if (this.time >= this.nextPickupAt && this.pickups.size < c.pickupMax
      && maxx - minx > 60) {
      this.nextPickupAt = this.time + c.pickupEvery;
      const margin = 18;
      let x, y, tries = 20;
      do {
        if (c.pickupSpots) {
          // race: cubes ride the corridor, near the gates with a little drift
          const s = c.pickupSpots[Math.floor(this.rng() * c.pickupSpots.length)];
          x = s.x + (this.rng() - 0.5) * 24;
          y = s.y + (this.rng() - 0.5) * 24;
        } else {
          x = minx + margin + this.rng() * (maxx - minx - margin * 2);
          y = miny + margin + this.rng() * (maxy - miny - margin * 2);
        }
      } while (--tries > 0 && this.insideObstacle(x, y, c.pickupRadius + 2));
      if (tries > 0) {
        const type = ITEM_TYPES[Math.floor(this.rng() * ITEM_TYPES.length)];
        const id = ++this.pickupSeq;
        this.pickups.set(id, { id, type, x, y });
        this.events.push({ t: 'pu+', id, type, x: r1(x), y: r1(y) });
      }
    }
    for (const p of this.pickups.values()) {
      // the shrinking wall eats uncollected pickups
      if (p.x < minx || p.x > maxx || p.y < miny || p.y > maxy) {
        this.pickups.delete(p.id);
        this.events.push({ t: 'pu-', id: p.id, by: 0 });
        continue;
      }
      for (const b of alive) {
        if (b.item || !b.alive) continue; // carry only one
        if (Math.hypot(b.x - p.x, b.y - p.y) <= c.pickupRadius) {
          b.item = p.type;
          b.itemCharges = p.type === 'cannon' ? c.cannonShots : 1;
          b.pickups++;
          this.pickups.delete(p.id);
          this.events.push({ t: 'pu-', id: p.id, by: b.id, type: p.type });
          break;
        }
      }
    }
  }

  useItem(id) {
    const b = this.bikes.get(id);
    if (!b || !b.alive || b.ghost || !b.item || this.over) return;
    const type = b.item;
    b.uses++;
    const c = this.cfg;
    if (type === 'cannon') {
      // one of three bolts; flies straight, derezzes bikes, carves trails
      b.itemCharges--;
      if (b.itemCharges <= 0) { b.item = null; b.itemCharges = 0; }
      const id2 = ++this.shotSeq;
      const x = b.x + Math.cos(b.heading) * 2.6;
      const y = b.y + Math.sin(b.heading) * 2.6;
      this.shots.set(id2, { id: id2, by: b.id, x, y, h: b.heading, life: c.shotLife });
      this.events.push({
        t: 'sh+', id: id2, by: b.id, x: r1(x), y: r1(y), h: r3(b.heading), left: b.itemCharges,
      });
      return;
    }
    b.item = null;
    b.itemCharges = 0;
    if (type === 'trap') {
      // drop an armed mine just behind the rear wheel
      const id2 = ++this.mineSeq;
      const x = b.x - Math.cos(b.heading) * 4;
      const y = b.y - Math.sin(b.heading) * 4;
      this.mines.set(id2, { id: id2, by: b.id, x, y, armAt: this.time + c.mineArm });
      this.events.push({ t: 'mine+', id: id2, by: b.id, x: r1(x), y: r1(y) });
      return;
    }
    if (type === 'phase') {
      b.phaseUntil = this.time + c.phaseDuration;
    } else if (type === 'surge') {
      b.surgeUntil = this.time + c.surgeDuration;
    } else if (type === 'snip') {
      resetTrail(b, b.x, b.y);
      b.noTrailUntil = this.time + c.snipNoTrail;
    }
    let cuts;
    if (type === 'emp') {
      // erase enemy trail segments inside the blast radius, opening a passage;
      // the exact segment list rides on the event so clients match the server
      cuts = {};
      for (const q of this.bikes.values()) {
        if (q === b || !q.alive) continue;
        const pts = q.trail;
        const fresh = [];
        for (let i = q.trailStart; i + 1 < pts.length; i++) {
          if (q.cuts.has(i)) continue;
          if (pointSegDist(b.x, b.y, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y) <= c.empRadius) {
            q.cuts.add(i);
            fresh.push(i);
          }
        }
        if (fresh.length) cuts[q.id] = fresh;
      }
    }
    this.events.push({
      t: 'use', id: b.id, type, x: r1(b.x), y: r1(b.y),
      r: type === 'emp' ? c.empRadius : undefined, cuts,
    });
  }

  stepPads(alive) {
    if (!this.pads.length) return;
    const c = this.cfg;
    for (const b of alive) {
      if (b.padUntil > this.time) continue;
      for (const p of this.pads) {
        if (Math.hypot(b.x - p.x, b.y - p.y) <= c.padRadius) {
          b.padUntil = this.time + c.padDuration;
          b.padHits++;
          this.events.push({ t: 'pad', id: p.id, by: b.id });
          break;
        }
      }
    }
  }

  stepShots(dt, alive) {
    if (!this.shots.size) return;
    const c = this.cfg;
    const [minx, miny, maxx, maxy] = this.bounds();
    const kills = [];
    for (const s of [...this.shots.values()]) {
      const nx = s.x + Math.cos(s.h) * c.shotSpeed * dt;
      const ny = s.y + Math.sin(s.h) * c.shotSpeed * dt;
      s.life -= dt;
      let gone = s.life <= 0 || nx < minx || nx > maxx || ny < miny || ny > maxy;
      let boom = false;
      if (!gone && this.obstacles.length && this.hitObstacle(s.x, s.y, nx, ny)) {
        gone = true;
        boom = true;
      }
      if (!boom) {
        for (const b of alive) {
          if (b.id === s.by || !b.alive || b.ghost) continue;
          if (b.phaseUntil > this.time) continue; // phased riders shrug bolts off
          if (pointSegDist(b.x, b.y, s.x, s.y, nx, ny) <= c.shotHitRadius) {
            kills.push({ b, hit: { kind: 'shot', owner: this.bikes.get(s.by) } });
            gone = true;
            boom = true;
            break;
          }
        }
      }
      // carve a passage through any wall the bolt crosses (own included)
      const cuts = {};
      for (const q of this.bikes.values()) {
        if (!q.alive) continue;
        const pts = q.trail;
        const bounds = this.getTrailBounds(q);
        if (!aabbOverlap(
          Math.min(s.x, nx) - c.shotCutRadius, Math.min(s.y, ny) - c.shotCutRadius,
          Math.max(s.x, nx) + c.shotCutRadius, Math.max(s.y, ny) + c.shotCutRadius,
          bounds.minX, bounds.minY, bounds.maxX, bounds.maxY)) continue;
        const fresh = [];
        for (let i = q.trailStart; i + 1 < pts.length; i++) {
          if (q.cuts.has(i)) continue;
          if (segHitDist(s.x, s.y, nx, ny, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y) < Infinity
            || pointSegDist(pts[i].x, pts[i].y, s.x, s.y, nx, ny) <= c.shotCutRadius
            || pointSegDist(pts[i + 1].x, pts[i + 1].y, s.x, s.y, nx, ny) <= c.shotCutRadius) {
            q.cuts.add(i);
            fresh.push(i);
          }
        }
        if (fresh.length) cuts[q.id] = fresh;
      }
      if (Object.keys(cuts).length) this.events.push({ t: 'cut', cuts });
      s.x = nx;
      s.y = ny;
      if (gone) {
        this.shots.delete(s.id);
        this.events.push({ t: 'sh-', id: s.id, x: r1(nx), y: r1(ny), boom: boom ? 1 : 0 });
      }
    }
    for (const { b, hit } of kills) if (b.alive && !b.ghost) this.kill(b, hit);
  }

  stepMines(alive) {
    if (!this.mines.size) return;
    const c = this.cfg;
    const [minx, miny, maxx, maxy] = this.bounds();
    const kills = [];
    for (const m of [...this.mines.values()]) {
      if (m.x < minx || m.x > maxx || m.y < miny || m.y > maxy) {
        // the shrinking wall eats mines too
        this.mines.delete(m.id);
        this.events.push({ t: 'mine-', id: m.id, boom: 0 });
        continue;
      }
      if (this.time < m.armAt) continue;
      for (const b of alive) {
        if (b.id === m.by || !b.alive || b.ghost) continue;
        if (Math.hypot(b.x - m.x, b.y - m.y) <= c.mineRadius) {
          kills.push({ b, hit: { kind: 'mine', owner: this.bikes.get(m.by) } });
          this.mines.delete(m.id);
          this.events.push({ t: 'mine-', id: m.id, boom: 1 });
          break;
        }
      }
    }
    for (const { b, hit } of kills) if (b.alive && !b.ghost) this.kill(b, hit);
  }

  insideObstacle(x, y, pad = 0) {
    for (const o of this.obstacles) {
      if (x >= o.minX - pad && x <= o.maxX + pad && y >= o.minY - pad && y <= o.maxY + pad) return true;
    }
    return false;
  }

  // movement segment vs building walls; Phase Shift is trails-only, so this
  // runs before the phase check
  hitObstacle(p0x, p0y, p1x, p1y) {
    const mnx = Math.min(p0x, p1x), mny = Math.min(p0y, p1y);
    const mxx = Math.max(p0x, p1x), mxy = Math.max(p0y, p1y);
    for (const o of this.obstacles) {
      if (!aabbOverlap(mnx, mny, mxx, mxy, o.minX, o.minY, o.maxX, o.maxY)) continue;
      for (const [ax, ay, bx, by] of o.segs) {
        if (segHitDist(p0x, p0y, p1x, p1y, ax, ay, bx, by) < Infinity) return true;
      }
    }
    return false;
  }

  collide(b) {
    const [minx, miny, maxx, maxy] = this.bounds();
    if (b.x < minx || b.x > maxx || b.y < miny || b.y > maxy) return { kind: 'wall' };
    if (this.obstacles.length && this.hitObstacle(b.px, b.py, b.x, b.y)) return { kind: 'building' };
    if (b.phaseUntil > this.time) return null; // phased: through any trail

    let bestD = Infinity, bestOwner = null;
    const moveMinX = Math.min(b.px, b.x), moveMinY = Math.min(b.py, b.y);
    const moveMaxX = Math.max(b.px, b.x), moveMaxY = Math.max(b.py, b.y);
    for (const q of this.bikes.values()) {
      if (!q.alive || q.ghost) continue; // trails derez on death / crash
      const pts = q.trail;
      const bounds = this.getTrailBounds(q);
      // Other riders also expose their live last-point -> previous-head segment.
      const trailMinX = q === b ? bounds.minX : Math.min(bounds.minX, q.px);
      const trailMinY = q === b ? bounds.minY : Math.min(bounds.minY, q.py);
      const trailMaxX = q === b ? bounds.maxX : Math.max(bounds.maxX, q.px);
      const trailMaxY = q === b ? bounds.maxY : Math.max(bounds.maxY, q.py);
      if (!aabbOverlap(moveMinX, moveMinY, moveMaxX, moveMaxY,
        trailMinX, trailMinY, trailMaxX, trailMaxY)) continue;
      const total = q.trailArc[q.trailArc.length - 1];
      // segments: pts[i]->pts[i+1], plus live segment pts[last]->(q.px,q.py)
      for (let i = q.trailStart; i + 1 < pts.length; i++) {
        if (q === b && total - q.trailArc[i + 1] < this.cfg.selfSkipArc) break;
        if (q.cuts.size && q.cuts.has(i)) continue; // EMP'd gap
        const d = segHitDist(b.px, b.py, b.x, b.y, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y);
        if (d < bestD) { bestD = d; bestOwner = q; }
      }
      if (q !== b) {
        const lastPt = pts[pts.length - 1];
        const d = segHitDist(b.px, b.py, b.x, b.y, lastPt.x, lastPt.y, q.px, q.py);
        if (d < bestD) { bestD = d; bestOwner = q; }
      }
    }
    if (bestD === Infinity) return null;
    return { kind: 'trail', owner: bestOwner };
  }

  kill(b, hit) {
    let byId = 0, byName = hit.kind === 'building' ? 'NIGHT CITY' : 'THE WALL';
    if (hit.kind === 'trail') {
      byId = hit.owner.id;
      byName = hit.owner === b ? 'their own trail' : hit.owner.name;
      if (hit.owner !== b) hit.owner.kills++;
    } else if ((hit.kind === 'shot' || hit.kind === 'mine') && hit.owner) {
      byId = hit.owner.id;
      byName = hit.owner.name;
      if (hit.owner !== b) hit.owner.kills++;
    }
    if (this.race) {
      // race crash: derez on the spot, back on track a second later. Rewind to
      // the last legal position so nobody revives inside the wall they hit.
      b.x = b.px;
      b.y = b.py;
      b.ghost = true;
      b.respawnAt = this.time + this.cfg.respawnDelay;
      b.boosting = false;
      resetTrail(b, b.x, b.y);
      this.events.push({
        t: 'die', id: b.id, by: byId, byName, k: hit.kind, x: r1(b.x), y: r1(b.y), respawn: 1,
      });
      return;
    }
    b.alive = false;
    b.survived = this.time;
    this.events.push({ t: 'die', id: b.id, by: byId, byName, k: hit.kind, x: r1(b.x), y: r1(b.y) });
  }

  // rider left the room mid-round: out for good, race or not
  retire(b) {
    if (!b.alive) return;
    b.alive = false;
    b.ghost = false;
    b.survived = this.time;
    this.events.push({ t: 'die', id: b.id, by: 0, byName: 'THE WALL', k: 'wall', x: r1(b.x), y: r1(b.y) });
  }

  stepRespawns() {
    for (const b of this.bikes.values()) {
      if (!b.alive || !b.ghost || this.time < b.respawnAt) continue;
      b.ghost = false;
      // aim the revived bike along the racing line so it rejoins the flow
      const wps = this.cfg.waypoints;
      if (wps && wps.length) {
        let best = 0, bd = Infinity;
        for (let i = 0; i < wps.length; i++) {
          const d = Math.hypot(wps[i].x - b.x, wps[i].y - b.y);
          if (d < bd) { bd = d; best = i; }
        }
        // first line-of-sight point ahead — never aim straight back into the
        // wall that caused the crash. Pocketed with no view of the line at
        // all: turn around, the way in is the way out.
        let next = null;
        for (let k = 1; k <= 6; k++) {
          const cand = wps[(best + k) % wps.length];
          if (!this.hitObstacle(b.x, b.y, cand.x, cand.y)) { next = cand; break; }
        }
        b.heading = next
          ? Math.atan2(next.y - b.y, next.x - b.x)
          : b.heading + Math.PI;
      } else {
        const g = this.race.gates[b.nextCp];
        if (g) b.heading = Math.atan2(g.my - b.y, g.mx - b.x);
      }
      b.steer = 0;
      b.steerTarget = 0;
      b.phaseUntil = this.time + this.cfg.respawnShield;
      b.px = b.x;
      b.py = b.y;
      resetTrail(b, b.x, b.y);
      b.headingAtSample = b.heading;
      this.events.push({ t: 'rs', id: b.id, x: r1(b.x), y: r1(b.y), h: r3(b.heading) });
    }
  }

  stepRace(alive) {
    const r = this.race;
    if (!r.gates.length) return;
    for (const b of alive) {
      if (!b.alive || b.ghost) continue;
      const g = r.gates[b.nextCp];
      if (segHitDist(b.px, b.py, b.x, b.y, g.x1, g.y1, g.x2, g.y2) === Infinity) continue;
      b.nextCp++;
      if (b.nextCp >= r.gates.length) {
        b.nextCp = 0;
        b.lap++;
        this.events.push({ t: 'lap', id: b.id, lap: Math.min(b.lap + 1, this.cfg.raceLaps) });
        if (b.lap >= this.cfg.raceLaps && !r.finishOrder.includes(b.id)) {
          r.finishOrder.push(b.id);
          this.events.push({ t: 'fin', id: b.id, place: r.finishOrder.length });
        }
      }
    }
    if (this.time >= r.nextPosAt) {
      r.nextPosAt = this.time + 0.5;
      this.events.push({
        t: 'racepos',
        o: this.raceOrder().map(b => [b.id, Math.min(b.lap + 1, this.cfg.raceLaps),
          r.finishOrder.includes(b.id) ? 1 : 0]),
      });
    }
    if (r.finishOrder.length) this.finish([...this.bikes.values()].filter(b => b.alive));
  }

  raceScore(b) {
    const r = this.race;
    const fin = r.finishOrder.indexOf(b.id);
    if (fin !== -1) return 1e9 - fin;
    const g = r.gates[b.nextCp];
    const d = g ? Math.hypot(g.mx - b.x, g.my - b.y) : 0;
    return b.lap * 1e6 + b.nextCp * 1e4 + Math.max(0, 9999 - d * 10);
  }

  raceOrder() {
    return [...this.bikes.values()].filter(b => b.alive)
      .sort((a, c) => this.raceScore(c) - this.raceScore(a));
  }

  finish(remaining) {
    this.over = true;
    for (const b of remaining) b.survived = this.time;
    let ordered;
    if (this.race) {
      // podium by laps + gates + distance to the next gate; leavers sink
      ordered = [...this.raceOrder(), ...[...this.bikes.values()].filter(b => !b.alive)];
      this.winner = ordered[0] || null;
    } else {
      if (remaining.length === 1) {
        this.winner = remaining[0];
      } else if (remaining.length > 1) {
        // hard cap hit: most kills among survivors wins
        this.winner = remaining.slice().sort((a, c) => c.kills - a.kills)[0];
      }
      ordered = [...this.bikes.values()]
        .sort((a, c) => (c.alive - a.alive) || (c.kills - a.kills));
    }
    this.standings = ordered.map(b => ({
      id: b.id, name: b.name, kills: b.kills, alive: b.alive,
      pickups: b.pickups, uses: b.uses,
      survived: r1(b.survived), boosted: r1(b.boostTime), padHits: b.padHits,
      lap: this.race ? Math.min(b.lap + 1, this.cfg.raceLaps) : undefined,
      fin: this.race ? this.race.finishOrder.includes(b.id) : undefined,
    }));
    this.events.push({
      t: 'round_end',
      winner: this.winner ? this.winner.id : 0,
      winnerName: this.winner ? this.winner.name : null,
      race: this.race ? 1 : undefined,
      standings: this.standings,
    });
  }

  // distance to first obstacle from b's head at heading+angleOffset (bot vision)
  raycast(b, angleOffset, maxDist) {
    const a = b.heading + angleOffset;
    const x1 = b.x + Math.cos(a) * maxDist;
    const y1 = b.y + Math.sin(a) * maxDist;
    const rayMinX = Math.min(b.x, x1), rayMinY = Math.min(b.y, y1);
    const rayMaxX = Math.max(b.x, x1), rayMaxY = Math.max(b.y, y1);
    let best = maxDist;
    const [minx, miny, maxx, maxy] = this.bounds();
    for (const [wx0, wy0, wx1, wy1] of [
      [minx, miny, maxx, miny], [maxx, miny, maxx, maxy],
      [maxx, maxy, minx, maxy], [minx, maxy, minx, miny],
    ]) {
      const d = segHitDist(b.x, b.y, x1, y1, wx0, wy0, wx1, wy1);
      if (d < best) best = d;
    }
    for (const o of this.obstacles) {
      if (!aabbOverlap(rayMinX, rayMinY, rayMaxX, rayMaxY, o.minX, o.minY, o.maxX, o.maxY)) continue;
      for (const [ax, ay, bx, by] of o.segs) {
        const d = segHitDist(b.x, b.y, x1, y1, ax, ay, bx, by);
        if (d < best) best = d;
      }
    }
    for (const q of this.bikes.values()) {
      if (!q.alive || q.ghost) continue;
      const pts = q.trail;
      const bounds = this.getTrailBounds(q);
      if (!aabbOverlap(rayMinX, rayMinY, rayMaxX, rayMaxY,
        bounds.minX, bounds.minY, bounds.maxX, bounds.maxY)) continue;
      const total = q.trailArc[q.trailArc.length - 1];
      for (let i = q.trailStart; i + 1 < pts.length; i++) {
        if (q === b && total - q.trailArc[i + 1] < this.cfg.selfSkipArc) break;
        if (q.cuts.size && q.cuts.has(i)) continue;
        const d = segHitDist(b.x, b.y, x1, y1, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y);
        if (d < best) best = d;
      }
    }
    return best;
  }

  getTrailBounds(b) {
    const pts = b.trail;
    let bounds = b._trailBounds;
    if (!bounds || bounds.ref !== pts || bounds.count > pts.length) {
      const p = pts[0];
      bounds = b._trailBounds = {
        ref: pts, count: 0,
        minX: p.x, minY: p.y, maxX: p.x, maxY: p.y,
      };
    }
    // Normally this visits only the point appended during the current tick.
    for (let i = bounds.count; i < pts.length; i++) {
      const p = pts[i];
      bounds.minX = Math.min(bounds.minX, p.x);
      bounds.minY = Math.min(bounds.minY, p.y);
      bounds.maxX = Math.max(bounds.maxX, p.x);
      bounds.maxY = Math.max(bounds.maxY, p.y);
    }
    bounds.count = pts.length;
    return bounds;
  }

  snapshot() {
    const ps = [];
    for (const b of this.bikes.values()) {
      if (!b.alive || b.ghost) continue;
      const flags = (b.boosting ? 1 : 0)
        | (b.phaseUntil > this.time ? 2 : 0)
        | (b.surgeUntil > this.time ? 4 : 0)
        | (b.padUntil > this.time ? 8 : 0);
      ps.push([b.id, r1(b.x), r1(b.y), r3(b.heading), flags]);
    }
    return { t: 's', time: r2(this.time), ps, bounds: this.bounds().map(r1) };
  }

  fullState() {
    return {
      time: r2(this.time),
      bounds: this.bounds().map(r1),
      pickups: [...this.pickups.values()].map(p => ({ id: p.id, type: p.type, x: r1(p.x), y: r1(p.y) })),
      pads: this.pads.map(p => ({ id: p.id, x: r1(p.x), y: r1(p.y) })),
      mines: [...this.mines.values()].map(m => ({ id: m.id, by: m.by, x: r1(m.x), y: r1(m.y) })),
      race: this.race ? { laps: this.cfg.raceLaps } : undefined,
      bikes: [...this.bikes.values()].map(b => ({
        id: b.id, name: b.name, color: b.color, shape: b.shape, pattern: b.pattern,
        bot: b.bot, alive: b.alive, item: b.item, ghost: b.ghost || undefined,
        x: r1(b.x), y: r1(b.y), heading: r3(b.heading), kills: b.kills,
        // waned prefix is dropped for late joiners; cut indices shift with it
        trail: b.trail.slice(b.trailStart).map(p => ({ x: r1(p.x), y: r1(p.y) })),
        cuts: [...b.cuts].map(i => i - b.trailStart).filter(i => i >= 0),
      })),
    };
  }

  drainEvents() {
    const ev = this.events;
    this.events = [];
    return ev;
  }
}

function resetTrail(b, x, y) {
  b.trail = [{ x, y }];
  b.trailArc = [0];
  b.trailStart = 0;
  b.cuts = new Set();
  b._trailBounds = null; // ref mismatch forces a rebuild
  b.headingAtSample = b.heading;
}

function r1(n) { return Math.round(n * 10) / 10; }
function r2(n) { return Math.round(n * 100) / 100; }
function r3(n) { return Math.round(n * 1000) / 1000; }

// segHitDist accepts tiny endpoint tolerances, so expand rather than risk
// rejecting a boundary-touch that the exact narrow phase considers a hit.
const AABB_EPSILON = 1e-6;
function aabbOverlap(aMinX, aMinY, aMaxX, aMaxY, bMinX, bMinY, bMaxX, bMaxY) {
  return aMaxX + AABB_EPSILON >= bMinX && bMaxX + AABB_EPSILON >= aMinX
    && aMaxY + AABB_EPSILON >= bMinY && bMaxY + AABB_EPSILON >= aMinY;
}
