/* Nexus City - the game loop, the police, and everything that ties the rest
 * together.
 *
 * The world is 6720x6720 and the screen shows maybe a fortieth of it, so
 * nothing outside the camera is simulated at full fidelity: pedestrians and
 * traffic are STREAMED, spawned just off-screen and retired once they are far
 * enough away that nobody could tell. What the player experiences is a city
 * that is busy everywhere; what actually runs is about eighty entities.
 *
 * The wanted level is the one system deliberately exempt from that. Police
 * persist regardless of distance, because a chase you can end by driving two
 * blocks and letting the pursuit despawn is not a chase.
 */
const G = {
  canvas: null, ctx: null,
  W: 0, H: 0, dpr: 1,
  player: null,
  peds: [], cars: [], cops: [], bullets: [], fx: [], pickups: [],
  cam: { x: 0, y: 0, zoom: 1, x0: 0, y0: 0, x1: 0, y1: 0, shake: 0 },
  input: { up: 0, down: 0, left: 0, right: 0, fire: 0, sprint: 0, aimX: 0, aimY: 0 },
  t: 0, last: 0, paused: false, showMap: false,
  msgs: [],
  heat: 0,                 // the raw number behind the star rating
  heatSeen: 0,             // time since the police last had eyes on you
  mission: null, missionMarker: null, nextMissionAt: 6,
  missionsDone: 0,
  minimap: null,
  started: false,

  /* ── boot ─────────────────────────────────────────────────────────────── */
  init() {
    this.canvas = document.getElementById('game');
    this.ctx = this.canvas.getContext('2d', { alpha: false });
    this.resize();
    window.addEventListener('resize', () => this.resize());

    World.generate(20260811);
    this.buildMinimap();
    /* The survivor recolours are only the fallback now - seven distinct
     * characters out of the Enemy packs beat one man in six coats. Baking 30
     * tinted sheets when nothing will draw them is ~29MB for nothing, so it
     * only happens when the packs are actually missing. */
    if (!Atlas.has('ped_gang', 'idle')) {
      Atlas.buildTints(['handgun', 'knife', 'flashlight', 'feet', 'rifle']);
    }

    // start somewhere legal: the middle of Downtown, nudged onto open ground
    let sx = WORLD_W / 2, sy = WORLD_H / 2;
    const spot = World.randomWalk(Math.random, sx, sy, 0, 600);
    if (spot) { sx = spot.x; sy = spot.y; }
    this.player = makePlayer(sx, sy);
    this.load();

    this.cam.x = this.player.x; this.cam.y = this.player.y;
    this.bindInput();
    this.seedTraffic();

    this.say('Nexus City. ' + World.districtAt(sx, sy).name + '.');
    this.say('WASD to move, mouse to aim, F to take a car.');

    this.started = true;
    this.last = performance.now();
    requestAnimationFrame((t) => this.frame(t));
  },

  resize() {
    this.dpr = Math.min(2, window.devicePixelRatio || 1);
    this.W = window.innerWidth; this.H = window.innerHeight;
    this.canvas.width = this.W * this.dpr;
    this.canvas.height = this.H * this.dpr;
    this.canvas.style.width = this.W + 'px';
    this.canvas.style.height = this.H + 'px';
  },

  buildMinimap() {
    // One pixel per tile, drawn once. Redrawing 28k tiles every frame for a
    // 190px widget would cost more than the entire rest of the render.
    const c = document.createElement('canvas');
    c.width = MAP_W; c.height = MAP_H;
    const x = c.getContext('2d');
    const img = x.createImageData(MAP_W, MAP_H);
    const col = {
      0: [36, 38, 44], 1: [58, 61, 69], 2: [92, 98, 116],
      3: [43, 64, 48], 4: [46, 48, 54], 5: [20, 28, 40],
    };
    for (let i = 0; i < MAP_W * MAP_H; i++) {
      const c3 = col[World.tiles[i]] || col[0];
      img.data[i * 4] = c3[0]; img.data[i * 4 + 1] = c3[1];
      img.data[i * 4 + 2] = c3[2]; img.data[i * 4 + 3] = 255;
    }
    x.putImageData(img, 0, 0);
    this.minimap = c;
  },

  bindInput() {
    const K = {};
    const set = (e, v) => {
      const k = e.key.toLowerCase();
      K[k] = v;
      const i = this.input;
      i.up = K.w || K.arrowup ? 1 : 0;
      i.down = K.s || K.arrowdown ? 1 : 0;
      i.left = K.a || K.arrowleft ? 1 : 0;
      i.right = K.d || K.arrowright ? 1 : 0;
      i.sprint = K.shift ? 1 : 0;
      if (['w', 'a', 's', 'd', ' ', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].indexOf(k) >= 0)
        e.preventDefault();
    };
    window.addEventListener('keydown', (e) => {
      if (e.repeat) { set(e, 1); return; }
      set(e, 1);
      const k = e.key.toLowerCase();
      if (k === 'f') this.toggleCar();
      else if (k === 'r') Player.reload(this.player);
      else if (k === 'q') Player.cycle(this.player, -1);
      else if (k === 'e') Player.cycle(this.player, 1);
      else if (k === 'tab') { this.showMap = !this.showMap; e.preventDefault(); }
      else if (k === 'escape') this.paused = !this.paused;
      else if (k >= '1' && k <= '6') Player.select(this.player, WEAPON_ORDER[+k - 1]);
    });
    window.addEventListener('keyup', (e) => set(e, 0));
    window.addEventListener('blur', () => {
      for (const k in K) K[k] = 0;
      this.input.up = this.input.down = this.input.left = this.input.right = 0;
      this.input.fire = 0;
    });

    const aim = (e) => {
      const r = this.canvas.getBoundingClientRect();
      const sx = e.clientX - r.left, sy = e.clientY - r.top;
      this.input.aimX = this.cam.x + (sx - this.W / 2) / this.cam.zoom;
      this.input.aimY = this.cam.y + (sy - this.H / 2) / this.cam.zoom;
    };
    this.canvas.addEventListener('mousemove', aim);
    this.canvas.addEventListener('mousedown', (e) => { aim(e); this.input.fire = 1; });
    window.addEventListener('mouseup', () => { this.input.fire = 0; });
    this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
    this.canvas.addEventListener('wheel', (e) => {
      Player.cycle(this.player, e.deltaY > 0 ? 1 : -1);
      e.preventDefault();
    }, { passive: false });
  },

  say(text) {
    this.msgs.push({ text: text, t: 5.2 });
    if (this.msgs.length > 5) this.msgs.shift();
  },

  /* ── spawning and streaming ───────────────────────────────────────────── */
  seedTraffic() {
    for (let i = 0; i < 26; i++) this.spawnCar(true);
    for (let i = 0; i < 40; i++) this.spawnPed(true);
    // a car parked right by the player, so the first thing you can do is drive
    const near = World.randomRoad(Math.random, this.player.x, this.player.y, 90, 260);
    if (near) {
      const c = Vehicles.configure(makeCar(near.x, near.y, 'Audi'));
      c.ang = Math.random() * 6.283;
      this.cars.push(c);
    }
  },

  spawnCar(anywhere) {
    const p = this.player;
    const spot = anywhere
      ? World.randomRoad(Math.random)
      : World.randomRoad(Math.random, p.x, p.y, 700, 1040);
    if (!spot) return;
    const name = CIVILIAN_CARS[(Math.random() * CIVILIAN_CARS.length) | 0];
    const c = Vehicles.configure(makeCar(spot.x, spot.y, name));
    c.ang = (Math.random() * 4 | 0) * Math.PI / 2;
    const moving = Math.random() < 0.72;
    c.ai = moving ? { t: 0, gas: 0.55 + Math.random() * 0.35, target: c.ang } : null;
    this.cars.push(c);
  },

  spawnPed(anywhere) {
    const p = this.player;
    const spot = anywhere
      ? World.randomWalk(Math.random, WORLD_W / 2, WORLD_H / 2, 0, WORLD_W / 2)
      // A tighter ring than the vehicles get. People are small and usually
      // appear from behind a building, so spawning them closer costs no
      // visible pop-in but multiplies how many are actually on screen - the
      // wide ring put 83 pedestrians across an area 9x the size of the view,
      // which is a busy city that always looks deserted.
      : World.randomWalk(Math.random, p.x, p.y, 480, 860);
    if (!spot) return;
    this.peds.push(makePed(spot.x, spot.y, Math.random));
  },

  stream(dt) {
    const p = this.player;
    const d = World.districtAt(p.x, p.y);
    const wantPeds = Math.round(64 * d.peds);
    const wantCars = Math.round(34 * d.traffic);

    // Retire the far-away, then top back up just off-screen. The cut is well
    // outside the spawn ring so nothing can spawn and immediately be culled,
    // which would burn the budget respawning the same crowd forever.
    /* Comfortably outside BOTH spawn rings (peds 480-860, cars 760-1200) so
     * nothing can be created and culled on consecutive frames.
     *
     * Tightened from 1500: the same 83 people spread over a 1500px disc put
     * only ~6 of them in a view that covers 7% of it, which is a busy city
     * that always looks abandoned. Over 1150 it is closer to 13. */
    const cut = 1150;
    this.peds = this.peds.filter((q) =>
      (q.dead && q.deadT > PED_CORPSE_LIFE) ? false : Math.hypot(q.x - p.x, q.y - p.y) < cut);
    this.cars = this.cars.filter((c) =>
      c === p.car || c.siren || Math.hypot(c.x - p.x, c.y - p.y) < cut);
    this.pickups = this.pickups.filter((k) => k.t > 0 && Math.hypot(k.x - p.x, k.y - p.y) < cut);

    /* Refill in proportion to how empty it is. A flat one-per-frame chance
     * takes ~2 seconds to repopulate after a fast drive across a district,
     * and an empty street for two seconds is exactly when the city stops
     * feeling like one. Several per frame while the deficit is large, tapering
     * to a trickle, keeps it full without a spawn storm. */
    const alive = this.peds.filter((q) => !q.dead).length;
    for (let i = Math.min(4, Math.ceil((wantPeds - alive) / 8)); i > 0; i--) this.spawnPed(false);
    for (let i = Math.min(3, Math.ceil((wantCars - this.cars.length) / 6)); i > 0; i--) this.spawnCar(false);
  },

  /* ── the wanted level ─────────────────────────────────────────────────── */
  stars() { return Math.max(0, Math.min(5, Math.floor(this.heat))); },

  crime(amount, why) {
    const before = this.stars();
    this.heat = Math.min(5.99, this.heat + amount);
    this.player.lastCrimeAt = this.t;
    this.player.crimes++;
    const after = this.stars();
    if (after > before) {
      this.say(after === 1 ? 'Someone called it in. ' + '★'.repeat(after)
                           : 'Wanted level up. ' + '★'.repeat(after));
    }
    if (why) this.player.lastCrimeWhy = why;
  },

  updateWanted(dt) {
    const p = this.player;
    const s = this.stars();

    // do the police currently have eyes on you?
    let seen = false;
    for (const c of this.cops) {
      if (c.dead) continue;
      if (Math.hypot(c.x - p.x, c.y - p.y) < 640 && World.lineClear(c.x, c.y, p.x, p.y)) {
        seen = true; break;
      }
    }
    for (const c of this.cars) {
      if (!c.siren || c.driver === 'player') continue;
      if (Math.hypot(c.x - p.x, c.y - p.y) < 700) { seen = true; break; }
    }
    this.heatSeen = seen ? 0 : this.heatSeen + dt;

    // Cool off only once you have actually broken contact. Time alone would
    // mean standing still in front of a squad car eventually clears you.
    if (this.heat > 0 && this.t - p.lastCrimeAt > 5 && this.heatSeen > 6) {
      this.heat = Math.max(0, this.heat - dt * 0.115);
      if (this.stars() === 0 && s > 0) this.say('You lost them.');
    }

    // keep the right number of police in the field
    this.cops = this.cops.filter((c) => !(c.dead && c.deadT > PED_CORPSE_LIFE));
    const aliveCops = this.cops.filter((c) => !c.dead).length;
    const wantCops = s === 0 ? 0 : Math.min(12, s * 2);
    if (aliveCops < wantCops && Math.random() < 0.5) {
      const spot = World.randomWalk(Math.random, p.x, p.y, 620, 1050);
      if (spot) this.cops.push(makeCop(spot.x, spot.y, Math.random));
    }

    // and the squad cars, from two stars up
    const wantChase = s <= 1 ? 0 : Math.min(5, s - 1);
    const chasing = this.cars.filter((c) => c.siren && c.driver !== 'player').length;
    if (chasing < wantChase && Math.random() < 0.25) {
      const spot = World.randomRoad(Math.random, p.x, p.y, 750, 1300);
      if (spot) {
        const c = Vehicles.configure(makeCar(spot.x, spot.y, s >= 4 && Math.random() < 0.3 ? 'Ambulance' : 'Police'));
        c.siren = true;
        c.ai = { t: 0, gas: 1, target: c.ang, chase: true };
        c.hp = 300;
        this.cars.push(c);
      }
    }
  },

  updateChaseCar(c, dt) {
    const p = this.player;
    const dx = p.x - c.x, dy = p.y - c.y;
    const dist = Math.hypot(dx, dy);
    const want = Math.atan2(dy, dx);
    let d = want - c.ang;
    while (d > Math.PI) d -= 6.283185;
    while (d < -Math.PI) d += 6.283185;

    // Steer toward the player, but if a wall is straight ahead, steer around
    // it instead - otherwise a squad car just grinds into the nearest building
    // for the rest of the chase.
    const look = 90 + Math.abs(c.speed) * 0.4;
    let steer = Math.max(-1, Math.min(1, d * 2.0));
    if (World.solidAt(c.x + Math.cos(c.ang) * look, c.y + Math.sin(c.ang) * look)) {
      const l = World.solidAt(c.x + Math.cos(c.ang - 0.8) * look, c.y + Math.sin(c.ang - 0.8) * look);
      const r = World.solidAt(c.x + Math.cos(c.ang + 0.8) * look, c.y + Math.sin(c.ang + 0.8) * look);
      steer = l && !r ? 1 : (r && !l ? -1 : (d > 0 ? 1 : -1));
    }
    const gas = dist < 130 ? 0 : 1;
    Vehicles.drive(c, dt, { throttle: gas, steer: steer, brake: dist < 90 }, this);

    // officers bail out of a stopped car next to you
    if (dist < 240 && Math.abs(c.speed) < 60 && !c.emptied) {
      c.emptied = true;
      for (let i = 0; i < 2; i++) {
        const cop = makeCop(c.x + (Math.random() - .5) * 60, c.y + (Math.random() - .5) * 60, Math.random);
        this.cops.push(cop);
      }
    }
  },

  /* ── weapons ──────────────────────────────────────────────────────────── */
  shoot(from, def, ang, byPlayer) {
    const n = def.pellets || 1;
    for (let i = 0; i < n; i++) {
      const a = ang + (Math.random() - 0.5) * (def.spread || 0.02) * (n > 1 ? 2.4 : 1);
      const muzzle = 26;
      this.bullets.push({
        x: from.x + Math.cos(a) * muzzle, y: from.y + Math.sin(a) * muzzle,
        vx: Math.cos(a) * def.speed, vy: Math.sin(a) * def.speed,
        dmg: def.dmg, life: (def.range || 600) / def.speed,
        player: !!byPlayer,
      });
    }
    this.fx.push({ kind: 'flash', x: from.x + Math.cos(ang) * 30 * CHAR_SCALE / 0.4,
                   y: from.y + Math.sin(ang) * 30 * CHAR_SCALE / 0.4, t: 0.05, r: 15 * CHAR_SCALE / 0.4 });
    this.cam.shake = Math.min(9, this.cam.shake + (def.pellets ? 6 : 2.4));
    if (byPlayer) this.player.noise = 1;
  },

  copShoot(cop, ang) {
    this.bullets.push({
      x: cop.x + Math.cos(ang) * 28, y: cop.y + Math.sin(ang) * 28,
      vx: Math.cos(ang) * 1500, vy: Math.sin(ang) * 1500,
      dmg: 11, life: 0.42, player: false,
    });
    this.fx.push({ kind: 'flash', x: cop.x + Math.cos(ang) * 30, y: cop.y + Math.sin(ang) * 30, t: 0.05, r: 12 });
  },

  meleeSwing(p, def) {
    const tx = p.x + Math.cos(p.ang) * def.reach * 0.7;
    const ty = p.y + Math.sin(p.ang) * def.reach * 0.7;
    let hitAny = false;
    const swing = (list, isCop) => {
      for (const q of list) {
        if (q.dead) continue;
        if (Math.hypot(q.x - tx, q.y - ty) > def.reach) continue;
        this.damagePed(q, def.dmg, isCop);
        hitAny = true;
      }
    };
    swing(this.peds, false);
    swing(this.cops, true);
    if (hitAny) this.fx.push({ kind: 'hit', x: tx, y: ty, t: 0.18 });
  },

  updateBullets(dt) {
    const p = this.player;
    for (let i = this.bullets.length - 1; i >= 0; i--) {
      const b = this.bullets[i];
      b.life -= dt;
      if (b.life <= 0) { this.bullets.splice(i, 1); continue; }

      // Step the bullet in slices. At 1750 px/s and 60fps a whole-frame step is
      // 29px, wider than a person - fast rounds would pass straight through
      // anyone they hit. Four slices puts the step below the hit radius.
      const steps = 4;
      let done = false;
      for (let s = 0; s < steps && !done; s++) {
        b.x += b.vx * dt / steps;
        b.y += b.vy * dt / steps;

        if (World.solidAt(b.x, b.y)) {
          this.fx.push({ kind: 'spark', x: b.x, y: b.y, t: 0.16 });
          done = true; break;
        }
        if (b.player) {
          for (const q of this.peds) {
            if (q.dead || Math.hypot(q.x - b.x, q.y - b.y) > HIT_R) continue;
            this.damagePed(q, b.dmg, false); done = true; break;
          }
          if (done) break;
          for (const c of this.cops) {
            if (c.dead || Math.hypot(c.x - b.x, c.y - b.y) > HIT_R) continue;
            this.damagePed(c, b.dmg, true); done = true; break;
          }
          if (done) break;
          for (const c of this.cars) {
            if (c === p.car) continue;
            if (Math.hypot(c.x - b.x, c.y - b.y) > c.r * 0.8) continue;
            c.hp -= b.dmg;
            this.fx.push({ kind: 'spark', x: b.x, y: b.y, t: 0.14 });
            if (c.hp <= 0 && !c.burning) { c.burning = 2.2; this.crime(0.5); }
            done = true; break;
          }
        } else {
          const target = p.car ? { x: p.car.x, y: p.car.y, r: p.car.r * 0.7 } : { x: p.x, y: p.y, r: HIT_R };
          if (Math.hypot(target.x - b.x, target.y - b.y) < target.r) {
            this.hurtPlayer(b.dmg);
            done = true;
          }
        }
      }
      if (done) this.bullets.splice(i, 1);
    }
  },

  damagePed(q, dmg, isCop, byPlayer) {
    q.hp -= dmg;
    q.panic = 4;
    this.fx.push({ kind: 'blood', x: q.x, y: q.y, t: 0.4, a: Math.random() * 6.283 });
    if (q.hp <= 0 && !q.dead) {
      q.dead = true; q.deadT = 0;
      this.player.kills++;
      // A heavier spray on the killing blow, so the moment of death is
      // distinguishable from the hits leading up to it.
      for (let i = 0; i < 4; i++) {
        this.fx.push({ kind: 'blood', x: q.x, y: q.y, t: 0.5 + i * 0.08,
                       a: Math.random() * 6.283 });
      }
      // and a marker over the body: the one unambiguous "that one is down"
      this.fx.push({ kind: 'kill', x: q.x, y: q.y, t: 0.85, cop: !!isCop });
      if (isCop) {
        this.crime(1.15, 'killed an officer');
      } else {
        this.crime(0.85, 'killed a civilian');
        if (q.cash > 0) this.pickups.push({ kind: 'cash', x: q.x, y: q.y, v: q.cash, t: 26 });
      }
    }
  },

  hurtPlayer(dmg) {
    const p = this.player;
    if (p.dead) return;
    if (p.armour > 0) {
      const soak = Math.min(p.armour, dmg * 0.6);
      p.armour -= soak; dmg -= soak;
    }
    p.hp -= dmg;
    this.cam.shake = Math.min(14, this.cam.shake + 3);
    this.fx.push({ kind: 'blood', x: p.x, y: p.y, t: 0.3, a: Math.random() * 6.283 });
    if (p.hp <= 0) this.killPlayer();
  },

  killPlayer() {
    const p = this.player;
    p.dead = true; p.hp = 0;
    p.respawnAt = this.t + 3.2;
    if (p.car) { p.car.driver = null; p.car = null; }
    const fee = Math.min(p.cash, 250 + this.stars() * 150);
    p.cash -= fee;
    this.say('Wasted. Hospital took $' + fee + '.');
    this.heat = 0;
    this.cops.length = 0;
    for (const c of this.cars) c.siren = false;
  },

  respawn() {
    const p = this.player;
    const spot = World.randomWalk(Math.random, p.x, p.y, 400, 900) ||
                 { x: WORLD_W / 2, y: WORLD_H / 2 };
    p.x = spot.x; p.y = spot.y;
    p.vx = p.vy = 0;
    p.hp = p.maxHp; p.armour = 0;
    p.dead = false;
    // keep the guns, empty the mags - losing the arsenal on every death makes
    // dying feel like a punishment rather than a setback
    for (const w in p.ammo) p.ammo[w].in = Math.min(p.ammo[w].in, WEAPONS[w].mag);
    this.say('Patched up in ' + World.districtAt(p.x, p.y).name + '.');
    this.save();
  },

  carCrash(c, impact) {
    this.fx.push({ kind: 'spark', x: c.x, y: c.y, t: 0.2 });
    this.cam.shake = Math.min(16, this.cam.shake + impact * 0.03);
    c.hp -= impact * 0.06;
    if (c.driver === 'player' && impact > 340) this.hurtPlayer((impact - 340) * 0.045);
  },

  /* ── cars ─────────────────────────────────────────────────────────────── */
  toggleCar() {
    const p = this.player;
    if (p.dead || p.enterCd > 0) return;
    p.enterCd = 0.35;

    if (p.car) {
      const c = p.car;
      // step out to the side, and only somewhere there is actually room
      for (const side of [-1, 1]) {
        const ox = Math.cos(c.ang + Math.PI / 2 * side) * (c.wide * 0.5 + 22);
        const oy = Math.sin(c.ang + Math.PI / 2 * side) * (c.wide * 0.5 + 22);
        const nx = c.x + ox, ny = c.y + oy;
        if (!World.solidAt(nx, ny)) { p.x = nx; p.y = ny; break; }
        p.x = c.x; p.y = c.y;
      }
      p.vx = p.vy = 0;
      c.driver = null;
      if (!c.siren) c.ai = { t: 0, gas: 0, target: c.ang };
      p.car = null;
      this.say('Out.');
      return;
    }

    let best = null, bd = 92;
    for (const c of this.cars) {
      const d = Math.hypot(c.x - p.x, c.y - p.y);
      if (d < bd) { bd = d; best = c; }
    }
    if (!best) { this.say('No car in reach.'); return; }

    p.car = best; best.driver = 'player'; best.ai = null; best.emptied = false;
    if (best.siren) {
      this.crime(1.2, 'stole a police car');
      this.say('You took a squad car. That was noticed.');
    } else {
      this.crime(0.45, 'stole a car');
      this.say(best.name.replace(/_/g, ' ') + '. Go.');
    }
  },

  /* ── missions ─────────────────────────────────────────────────────────── */
  offerMission() {
    const p = this.player;
    const spot = World.randomWalk(Math.random, p.x, p.y, 500, 1100);
    if (!spot) return;
    this.missionMarker = { x: spot.x, y: spot.y, r: 44 };
    this.say('Work available. Yellow marker on the map.');
  },

  startMission() {
    const p = this.player;
    const kinds = ['delivery', 'hit', 'sweep'];
    const kind = kinds[(Math.random() * kinds.length) | 0];
    const far = World.randomRoad(Math.random, p.x, p.y, 900, 1900) ||
                World.randomWalk(Math.random, p.x, p.y, 700, 1400);
    if (!far) return;

    if (kind === 'delivery') {
      this.mission = { kind: kind, x: far.x, y: far.y, r: 52, pay: 600 + (Math.random() * 700 | 0),
                       label: 'Drive the package across town', needCar: true, t: 150 };
      this.say('Delivery. Take a car and get it there. $' + this.mission.pay);
    } else if (kind === 'hit') {
      const spot = World.randomWalk(Math.random, far.x, far.y, 0, 300) || far;
      const target = makePed(spot.x, spot.y, Math.random);
      target.hp = 90; target.cash = 0; target.mark = true; target.speed = 90;
      this.peds.push(target);
      this.mission = { kind: kind, target: target, x: spot.x, y: spot.y, r: 40,
                       pay: 900 + (Math.random() * 900 | 0), label: 'Deal with the marked target', t: 210 };
      this.say('Contract. The marked one. $' + this.mission.pay);
    } else {
      this.mission = { kind: kind, x: far.x, y: far.y, r: 60, pay: 500 + (Math.random() * 500 | 0),
                       label: 'Collect the drop', t: 130 };
      this.say('Pickup waiting. $' + this.mission.pay);
    }
    this.missionMarker = null;
  },

  updateMission(dt) {
    const p = this.player;
    if (!this.mission) {
      if (!this.missionMarker) {
        this.nextMissionAt -= dt;
        if (this.nextMissionAt <= 0) { this.offerMission(); this.nextMissionAt = 9999; }
      } else if (Math.hypot(p.x - this.missionMarker.x, p.y - this.missionMarker.y) < this.missionMarker.r) {
        this.startMission();
      }
      return;
    }

    const m = this.mission;
    m.t -= dt;
    if (m.t <= 0) { this.say('Too slow. Job is off.'); this.mission = null; this.nextMissionAt = 12; return; }

    if (m.kind === 'hit') {
      if (m.target.dead) this.finishMission();
      else { m.x = m.target.x; m.y = m.target.y; }
      return;
    }
    const d = Math.hypot(p.x - m.x, p.y - m.y);
    if (d < m.r) {
      if (m.needCar && !p.car) {
        if (!m.warned) { m.warned = true; this.say('It has to arrive in a vehicle.'); }
        return;
      }
      this.finishMission();
    }
  },

  finishMission() {
    const m = this.mission;
    const bonus = Math.round(m.pay * (1 + this.missionsDone * 0.06));
    this.player.cash += bonus;
    this.missionsDone++;
    this.say('Paid. +$' + bonus + '  (' + this.missionsDone
           + (this.missionsDone === 1 ? ' job)' : ' jobs)'));
    this.mission = null;
    this.nextMissionAt = 10;
    this.save();
  },

  /* ── pickups ──────────────────────────────────────────────────────────── */
  updatePickups(dt) {
    const p = this.player;
    for (const k of this.pickups) {
      k.t -= dt;
      if (Math.hypot(k.x - p.x, k.y - p.y) > 30) continue;
      k.t = 0;
      if (k.kind === 'cash') { p.cash += k.v; this.say('+$' + k.v); }
      else if (k.kind === 'health') { p.hp = Math.min(p.maxHp, p.hp + 45); this.say('Patched.'); }
      else if (k.kind === 'armour') { p.armour = Math.min(100, p.armour + 60); this.say('Vest on.'); }
      else if (k.kind === 'weapon') {
        Player.give(p, k.w, 3);
        Player.select(p, k.w);
        this.say('Picked up: ' + WEAPONS[k.w].name);
      }
    }
    // keep a scattering of things worth walking to
    if (this.pickups.filter((k) => k.t > 0).length < 7 && Math.random() < 0.02) {
      const spot = World.randomWalk(Math.random, p.x, p.y, 400, 1100);
      if (spot) {
        const r = Math.random();
        let k;
        if (r < 0.34) k = { kind: 'cash', v: 60 + (Math.random() * 220 | 0) };
        else if (r < 0.55) k = { kind: 'health' };
        else if (r < 0.70) k = { kind: 'armour' };
        else {
          const pool = ['knife', 'handgun', 'shotgun', 'rifle', 'flashlight'];
          k = { kind: 'weapon', w: pool[(Math.random() * pool.length) | 0] };
        }
        k.x = spot.x; k.y = spot.y; k.t = 90;
        this.pickups.push(k);
      }
    }
  },

  /* ── the loop ─────────────────────────────────────────────────────────── */
  frame(now) {
    // A tab left in the background hands back a multi-second dt, which would
    // teleport every car through a wall in one step. Clamp it.
    let dt = Math.min(0.05, (now - this.last) / 1000);
    this.last = now;
    requestAnimationFrame((t) => this.frame(t));
    if (this.paused) { this.render(0); return; }
    this.t += dt;

    const p = this.player;
    if (p.dead) {
      if (this.t >= p.respawnAt) this.respawn();
    } else {
      Player.update(p, dt, this.input, this);
    }

    for (const c of this.cars) {
      if (c.driver === 'player') continue;
      if (c.siren && c.ai && c.ai.chase) this.updateChaseCar(c, dt);
      else Vehicles.updateAI(c, dt, this);
      if (c.burning > 0) {
        c.burning -= dt;
        this.fx.push({ kind: 'fire', x: c.x + (Math.random() - .5) * 20, y: c.y + (Math.random() - .5) * 20, t: 0.4 });
        if (c.burning <= 0) {
          this.fx.push({ kind: 'boom', x: c.x, y: c.y, t: 0.5 });
          this.cam.shake = 18;
          for (const q of this.peds.concat(this.cops)) {
            if (!q.dead && Math.hypot(q.x - c.x, q.y - c.y) < 130) this.damagePed(q, 90, !!q.cop);
          }
          if (Math.hypot(p.x - c.x, p.y - c.y) < 140) this.hurtPlayer(48);
          c.dead = true;
        }
      }
    }
    Vehicles.collidePairs(this.cars, this);
    if (p.car) { p.x = p.car.x; p.y = p.car.y; }   // a shunt moves the driver too
    this.cars = this.cars.filter((c) => !c.dead);

    for (const q of this.peds) Peds.update(q, dt, this);
    for (const c of this.cops) Cops.update(c, dt, this);

    // a car at speed hurts whoever it hits
    const pc = p.car;
    if (pc) {
      const sp = Math.hypot(pc.vx, pc.vy);
      if (sp > 130) {
        for (const q of this.peds.concat(this.cops)) {
          if (q.dead) continue;
          if (Math.hypot(q.x - pc.x, q.y - pc.y) > pc.r * 0.85) continue;
          this.damagePed(q, sp * 0.28, !!q.cop);
          q.vx = pc.vx * 0.4; q.vy = pc.vy * 0.4;
          pc.vx *= 0.93; pc.vy *= 0.93;
        }
      }
    }

    this.updateBullets(dt);
    this.updateWanted(dt);
    this.updateMission(dt);
    this.updatePickups(dt);
    this.stream(dt);

    for (let i = this.fx.length - 1; i >= 0; i--) {
      this.fx[i].t -= dt;
      if (this.fx[i].t <= 0) this.fx.splice(i, 1);
    }
    for (let i = this.msgs.length - 1; i >= 0; i--) {
      this.msgs[i].t -= dt;
      if (this.msgs[i].t <= 0) this.msgs.splice(i, 1);
    }

    // camera: lead the player, pull back at speed
    const speed = Math.hypot(p.vx, p.vy);
    const lead = p.car ? 0.55 : 0.28;
    const tx = p.x + p.vx * lead, ty = p.y + p.vy * lead;
    const k = 1 - Math.pow(0.0012, dt);
    this.cam.x += (tx - this.cam.x) * k;
    this.cam.y += (ty - this.cam.y) * k;
    const wantZoom = p.car ? (1.02 - Math.min(0.24, speed / 3000)) : 1.16;
    this.cam.zoom += (wantZoom - this.cam.zoom) * (1 - Math.pow(0.01, dt));
    this.cam.shake = Math.max(0, this.cam.shake - dt * 26);

    if (this.t % 20 < dt) this.save();
    this.render(dt);
  },

  /* ── render ───────────────────────────────────────────────────────────── */
  render(dt) {
    const ctx = this.ctx, cam = this.cam, p = this.player;
    ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
    ctx.fillStyle = '#0d0f14';
    ctx.fillRect(0, 0, this.W, this.H);

    const sh = cam.shake;
    const ox = sh ? (Math.random() - .5) * sh : 0;
    const oy = sh ? (Math.random() - .5) * sh : 0;

    ctx.save();
    ctx.translate(this.W / 2 + ox, this.H / 2 + oy);
    ctx.scale(cam.zoom, cam.zoom);
    ctx.translate(-cam.x, -cam.y);

    const hw = this.W / 2 / cam.zoom + 60, hh = this.H / 2 / cam.zoom + 60;
    cam.x0 = cam.x - hw; cam.x1 = cam.x + hw;
    cam.y0 = cam.y - hh; cam.y1 = cam.y + hh;

    World.drawGround(ctx, cam);
    this.drawMarkers(ctx);
    this.drawPickups(ctx);

    for (const q of this.peds) if (q.dead) Peds.draw(ctx, q);
    for (const c of this.cops) if (c.dead) Cops.draw(ctx, c);

    for (const c of this.cars) Vehicles.draw(ctx, c);
    for (const q of this.peds) if (!q.dead) Peds.draw(ctx, q);
    for (const c of this.cops) if (!c.dead) Cops.draw(ctx, c);
    Player.draw(ctx, p);

    this.drawBullets(ctx);
    this.drawFx(ctx);

    // buildings last, so anything that walks behind one is hidden by it
    World.drawBuildings(ctx, cam);

    ctx.restore();

    this.drawHUD(ctx);
    if (this.showMap) this.drawBigMap(ctx);
    if (this.paused) this.drawPause(ctx);
  },

  drawMarkers(ctx) {
    const pulse = 0.6 + Math.sin(this.t * 4) * 0.25;
    const ring = (m, col) => {
      ctx.strokeStyle = col; ctx.lineWidth = 4;
      ctx.globalAlpha = pulse;
      ctx.beginPath(); ctx.arc(m.x, m.y, m.r, 0, 6.284); ctx.stroke();
      ctx.globalAlpha = pulse * 0.18;
      ctx.fillStyle = col;
      ctx.beginPath(); ctx.arc(m.x, m.y, m.r, 0, 6.284); ctx.fill();
      ctx.globalAlpha = 1;
    };
    if (this.missionMarker) ring(this.missionMarker, '#ffd166');
    if (this.mission && this.mission.kind !== 'hit') ring(this.mission, '#66e0ff');
    if (this.mission && this.mission.kind === 'hit' && !this.mission.target.dead) {
      const t = this.mission.target;
      ctx.strokeStyle = '#ff5f7a'; ctx.lineWidth = 3;
      ctx.globalAlpha = pulse;
      ctx.beginPath(); ctx.arc(t.x, t.y, 26, 0, 6.284); ctx.stroke();
      ctx.globalAlpha = 1;
    }
  },

  drawPickups(ctx) {
    for (const k of this.pickups) {
      if (k.t <= 0) continue;
      const bob = Math.sin(this.t * 3 + k.x * 0.01) * 3;
      const col = k.kind === 'cash' ? '#7bd88f' : k.kind === 'health' ? '#ff6b8a'
                : k.kind === 'armour' ? '#6cc7ff' : '#ffd166';
      ctx.save();
      ctx.translate(k.x, k.y + bob);
      ctx.globalAlpha = k.t < 4 ? (k.t % 0.5 > 0.25 ? 0.3 : 1) : 1;
      ctx.fillStyle = 'rgba(0,0,0,.4)';
      ctx.beginPath(); ctx.ellipse(2, 12 - bob, 11, 5, 0, 0, 6.284); ctx.fill();
      ctx.fillStyle = col;
      ctx.fillRect(-8, -8, 16, 16);
      ctx.fillStyle = 'rgba(0,0,0,.55)';
      ctx.font = 'bold 11px system-ui';
      ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
      ctx.fillText(k.kind === 'cash' ? '$' : k.kind === 'health' ? '+'
                 : k.kind === 'armour' ? 'V' : 'W', 0, 1);
      ctx.restore();
    }
  },

  drawBullets(ctx) {
    ctx.lineWidth = 2;
    for (const b of this.bullets) {
      ctx.strokeStyle = b.player ? 'rgba(255,232,150,.95)' : 'rgba(255,140,140,.95)';
      ctx.beginPath();
      ctx.moveTo(b.x, b.y);
      ctx.lineTo(b.x - b.vx * 0.012, b.y - b.vy * 0.012);
      ctx.stroke();
    }
  },

  drawFx(ctx) {
    for (const f of this.fx) {
      if (f.kind === 'flash') {
        ctx.fillStyle = 'rgba(255,220,130,' + (f.t / 0.05 * 0.8) + ')';
        ctx.beginPath(); ctx.arc(f.x, f.y, f.r, 0, 6.284); ctx.fill();
      } else if (f.kind === 'blood') {
        ctx.fillStyle = 'rgba(180,30,40,' + Math.min(1, f.t * 2) + ')';
        for (let i = 0; i < 5; i++) {
          const a = f.a + i * 1.2, d = (1 - f.t) * 26 + i * 3;
          ctx.beginPath(); ctx.arc(f.x + Math.cos(a) * d, f.y + Math.sin(a) * d, 3.2, 0, 6.284); ctx.fill();
        }
      } else if (f.kind === 'spark') {
        ctx.fillStyle = 'rgba(255,200,120,' + f.t * 5 + ')';
        ctx.beginPath(); ctx.arc(f.x, f.y, 5, 0, 6.284); ctx.fill();
      } else if (f.kind === 'hit') {
        ctx.strokeStyle = 'rgba(255,255,255,' + f.t * 4 + ')';
        ctx.lineWidth = 3;
        ctx.beginPath(); ctx.arc(f.x, f.y, (0.18 - f.t) * 180 + 6, 0, 6.284); ctx.stroke();
      } else if (f.kind === 'fire') {
        ctx.fillStyle = 'rgba(255,' + (80 + Math.random() * 120 | 0) + ',40,' + f.t * 2 + ')';
        ctx.beginPath(); ctx.arc(f.x, f.y, 8 + Math.random() * 10, 0, 6.284); ctx.fill();
      } else if (f.kind === 'kill') {
        // A ring that snaps outward and a cross at the centre. Red for a
        // civilian, blue for an officer, so the wanted-level consequence is
        // legible at the instant it happens rather than a second later when
        // the star lights up.
        const k = 1 - f.t / 0.85;
        const r = 8 + k * 26;
        const a = Math.max(0, 1 - k * 1.25);
        ctx.strokeStyle = f.cop ? 'rgba(90,170,255,' + a + ')' : 'rgba(255,70,90,' + a + ')';
        ctx.lineWidth = 2.5;
        ctx.beginPath(); ctx.arc(f.x, f.y, r, 0, 6.284); ctx.stroke();
        const s = 6;
        ctx.beginPath();
        ctx.moveTo(f.x - s, f.y - s); ctx.lineTo(f.x + s, f.y + s);
        ctx.moveTo(f.x + s, f.y - s); ctx.lineTo(f.x - s, f.y + s);
        ctx.stroke();
      } else if (f.kind === 'boom') {
        const r = (0.5 - f.t) * 300 + 20;
        ctx.fillStyle = 'rgba(255,170,60,' + f.t * 1.6 + ')';
        ctx.beginPath(); ctx.arc(f.x, f.y, r, 0, 6.284); ctx.fill();
      }
    }
  },

  /* HUD is drawn on the canvas rather than in DOM so the whole game is one
   * element - it drops into an iframe in the studio with no layout to fight. */
  drawHUD(ctx) {
    const p = this.player, W = this.W;
    ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
    ctx.textBaseline = 'top';

    // top-left: health, armour
    const barW = 208;
    ctx.fillStyle = 'rgba(8,10,14,.62)';
    ctx.fillRect(14, 14, barW + 20, p.armour > 0 ? 56 : 40);
    ctx.fillStyle = '#22262f';
    ctx.fillRect(24, 24, barW, 12);
    ctx.fillStyle = p.hp > 35 ? '#5fd97a' : '#ff5f6d';
    ctx.fillRect(24, 24, barW * Math.max(0, p.hp) / p.maxHp, 12);
    if (p.armour > 0) {
      ctx.fillStyle = '#22262f'; ctx.fillRect(24, 42, barW, 8);
      ctx.fillStyle = '#6cc7ff'; ctx.fillRect(24, 42, barW * p.armour / 100, 8);
    }

    // top-right: cash, stars, district
    ctx.textAlign = 'right';
    ctx.font = 'bold 30px "Segoe UI", system-ui, sans-serif';
    ctx.fillStyle = '#7bd88f';
    ctx.fillText('$' + p.cash.toLocaleString(), W - 20, 18);

    const s = this.stars();
    ctx.font = '22px system-ui';
    ctx.fillStyle = '#ffd166';
    let stars = '';
    for (let i = 0; i < 5; i++) stars += i < s ? '★' : '☆';
    ctx.globalAlpha = s > 0 ? (0.65 + Math.sin(this.t * 7) * 0.35) : 0.28;
    ctx.fillText(stars, W - 20, 54);
    ctx.globalAlpha = 1;

    const d = World.districtAt(p.x, p.y);
    ctx.font = '13px system-ui';
    ctx.fillStyle = 'rgba(210,220,240,.75)';
    ctx.fillText(d.name.toUpperCase(), W - 20, 86);

    // bottom-left: weapon
    const def = WEAPONS[p.weapon];
    ctx.textAlign = 'left';
    ctx.fillStyle = 'rgba(8,10,14,.62)';
    ctx.fillRect(14, this.H - 74, 210, 60);
    ctx.font = 'bold 17px system-ui';
    ctx.fillStyle = '#e8ecf5';
    ctx.fillText(def.name, 26, this.H - 66);
    ctx.font = '14px system-ui';
    if (def.melee) {
      ctx.fillStyle = 'rgba(200,210,230,.65)';
      ctx.fillText('melee', 26, this.H - 42);
    } else {
      const a = p.ammo[p.weapon] || { in: 0, spare: 0 };
      ctx.fillStyle = p.reloading > 0 ? '#ffd166' : (a.in === 0 ? '#ff5f6d' : '#cfd6e4');
      ctx.fillText(p.reloading > 0 ? 'reloading…' : (a.in + ' / ' + a.spare), 26, this.H - 42);
    }

    // messages
    ctx.font = '14px system-ui';
    let my = this.H - 100;
    for (let i = this.msgs.length - 1; i >= 0; i--) {
      const m = this.msgs[i];
      ctx.globalAlpha = Math.min(1, m.t / 1.2);
      ctx.fillStyle = '#dbe3f0';
      ctx.fillText(m.text, 26, my);
      my -= 21;
    }
    ctx.globalAlpha = 1;

    // mission line
    if (this.mission) {
      ctx.textAlign = 'center';
      ctx.font = '15px system-ui';
      ctx.fillStyle = '#66e0ff';
      const left = Math.max(0, this.mission.t | 0);
      ctx.fillText(this.mission.label + '   ·   $' + this.mission.pay
                 + '   ·   ' + left + 's', W / 2, 20);
    }

    this.drawMinimap(ctx);

    // crosshair
    if (!p.dead) {
      const r = this.canvas.getBoundingClientRect();
      const sx = (this.input.aimX - this.cam.x) * this.cam.zoom + this.W / 2;
      const sy = (this.input.aimY - this.cam.y) * this.cam.zoom + this.H / 2;
      ctx.strokeStyle = 'rgba(255,255,255,.75)';
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      ctx.moveTo(sx - 9, sy); ctx.lineTo(sx - 3, sy);
      ctx.moveTo(sx + 3, sy); ctx.lineTo(sx + 9, sy);
      ctx.moveTo(sx, sy - 9); ctx.lineTo(sx, sy - 3);
      ctx.moveTo(sx, sy + 3); ctx.lineTo(sx, sy + 9);
      ctx.stroke();
    }

    if (p.dead) {
      ctx.textAlign = 'center';
      ctx.font = 'bold 60px "Segoe UI", system-ui';
      ctx.fillStyle = 'rgba(255,60,80,.9)';
      ctx.fillText('WASTED', this.W / 2, this.H / 2 - 40);
    }
  },

  drawMinimap(ctx) {
    const S = 176, M = 16;
    const x0 = this.W - S - M, y0 = this.H - S - M;
    const p = this.player;
    const span = 1500;                       // world px shown across the widget

    ctx.save();
    ctx.beginPath(); ctx.arc(x0 + S / 2, y0 + S / 2, S / 2, 0, 6.284);
    ctx.fillStyle = '#0b0d12'; ctx.fill();
    ctx.clip();

    const scale = S / span;
    ctx.imageSmoothingEnabled = false;
    ctx.drawImage(this.minimap,
      (p.x - span / 2) / TILE, (p.y - span / 2) / TILE, span / TILE, span / TILE,
      x0, y0, S, S);
    ctx.imageSmoothingEnabled = true;

    const put = (wx, wy, col, r) => {
      const mx = x0 + S / 2 + (wx - p.x) * scale;
      const my = y0 + S / 2 + (wy - p.y) * scale;
      ctx.fillStyle = col;
      ctx.beginPath(); ctx.arc(mx, my, r, 0, 6.284); ctx.fill();
    };
    for (const c of this.cars) if (c.driver !== 'player') put(c.x, c.y, c.siren ? '#4a9eff' : 'rgba(200,200,210,.5)', c.siren ? 3 : 2);
    for (const c of this.cops) if (!c.dead) put(c.x, c.y, '#4a9eff', 2.5);
    for (const k of this.pickups) if (k.t > 0) put(k.x, k.y, '#7bd88f', 2);
    if (this.missionMarker) put(this.missionMarker.x, this.missionMarker.y, '#ffd166', 4);
    if (this.mission) put(this.mission.x, this.mission.y, '#66e0ff', 4);

    // the player, as an arrow pointing where they face
    const cx = x0 + S / 2, cy = y0 + S / 2;
    ctx.save();
    ctx.translate(cx, cy); ctx.rotate(p.ang);
    ctx.fillStyle = '#fff';
    ctx.beginPath(); ctx.moveTo(7, 0); ctx.lineTo(-5, -5); ctx.lineTo(-5, 5);
    ctx.closePath(); ctx.fill();
    ctx.restore();
    ctx.restore();

    ctx.strokeStyle = 'rgba(255,255,255,.22)'; ctx.lineWidth = 2;
    ctx.beginPath(); ctx.arc(x0 + S / 2, y0 + S / 2, S / 2, 0, 6.284); ctx.stroke();
  },

  drawBigMap(ctx) {
    const S = Math.min(this.W, this.H) * 0.82;
    const x0 = (this.W - S) / 2, y0 = (this.H - S) / 2;
    ctx.fillStyle = 'rgba(4,6,10,.86)';
    ctx.fillRect(0, 0, this.W, this.H);
    ctx.imageSmoothingEnabled = false;
    ctx.drawImage(this.minimap, x0, y0, S, S);
    ctx.imageSmoothingEnabled = true;

    const k = S / WORLD_W;
    const put = (wx, wy, col, r) => {
      ctx.fillStyle = col;
      ctx.beginPath(); ctx.arc(x0 + wx * k, y0 + wy * k, r, 0, 6.284); ctx.fill();
    };
    if (this.missionMarker) put(this.missionMarker.x, this.missionMarker.y, '#ffd166', 6);
    if (this.mission) put(this.mission.x, this.mission.y, '#66e0ff', 6);
    for (const c of this.cops) if (!c.dead) put(c.x, c.y, '#4a9eff', 3);
    put(this.player.x, this.player.y, '#fff', 5);

    // district labels
    ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
    ctx.font = 'bold 15px system-ui';
    const at = [[0.13, 0.5, 'Westside'], [0.87, 0.5, 'Eastpark'],
                [0.5, 0.13, 'Northgate'], [0.5, 0.87, 'Southway'], [0.5, 0.5, 'Downtown']];
    for (const [fx, fy, name] of at) {
      ctx.fillStyle = 'rgba(255,255,255,.62)';
      ctx.fillText(name.toUpperCase(), x0 + S * fx, y0 + S * fy);
    }
    ctx.font = '13px system-ui';
    ctx.fillStyle = 'rgba(200,210,230,.6)';
    ctx.fillText('Tab to close', this.W / 2, y0 + S + 22);
    ctx.textBaseline = 'top';
  },

  drawPause(ctx) {
    ctx.fillStyle = 'rgba(4,6,10,.72)';
    ctx.fillRect(0, 0, this.W, this.H);
    ctx.textAlign = 'center';
    ctx.fillStyle = '#e8ecf5';
    ctx.font = 'bold 40px "Segoe UI", system-ui';
    ctx.fillText('Paused', this.W / 2, this.H / 2 - 130);
    ctx.font = '15px system-ui';
    const lines = [
      'WASD  move          Shift  sprint / handbrake',
      'Mouse  aim          Click  fire',
      'F  enter or leave a vehicle',
      'R  reload           Q / E / wheel  change weapon',
      '1-6  pick a weapon directly',
      'Tab  map            Esc  pause',
      '',
      'Jobs pay. So do the people you take them from.',
      'Five stars is survivable. Barely.',
    ];
    let y = this.H / 2 - 70;
    for (const l of lines) {
      ctx.fillStyle = l.startsWith('Jobs') || l.startsWith('Five')
        ? 'rgba(255,209,102,.8)' : 'rgba(210,220,240,.82)';
      ctx.fillText(l, this.W / 2, y);
      y += 25;
    }
  },

  /* ── persistence ──────────────────────────────────────────────────────── */
  save() {
    const p = this.player;
    try {
      localStorage.setItem('nexus-city', JSON.stringify({
        v: 1, x: p.x, y: p.y, cash: p.cash, hp: p.hp, armour: p.armour,
        owned: p.owned, ammo: p.ammo, weapon: p.weapon,
        kills: p.kills, crimes: p.crimes, missionsDone: this.missionsDone,
      }));
    } catch (e) { /* private browsing, or a full quota - not worth interrupting play */ }
  },

  load() {
    let s;
    try { s = JSON.parse(localStorage.getItem('nexus-city') || 'null'); } catch (e) { return; }
    if (!s || s.v !== 1) return;
    const p = this.player;
    // Never trust a saved position blindly: the city is regenerated from a
    // seed each boot, and a changed seed would put the player inside a wall.
    if (typeof s.x === 'number' && !World.solidAt(s.x, s.y)) { p.x = s.x; p.y = s.y; }
    p.cash = s.cash || p.cash;
    p.hp = Math.max(20, s.hp || p.hp);
    p.armour = s.armour || 0;
    p.owned = Object.assign(p.owned, s.owned || {});
    p.ammo = Object.assign(p.ammo, s.ammo || {});
    if (s.weapon && p.owned[s.weapon]) p.weapon = s.weapon;
    p.kills = s.kills || 0; p.crimes = s.crimes || 0;
    this.missionsDone = s.missionsDone || 0;
  },
};
