← RTS results

RTS

Grok 4.7 · full

Initial view of RTS

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 127,261 bytes · SHA-256 6c8f35a52167
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FRONTIER WAR</title>
<style>
  :root {
    --bg: #07090e;
    --panel: rgba(8, 14, 24, 0.86);
    --line: rgba(80, 210, 255, 0.35);
    --cyan: #3ee0ff;
    --cyan-dim: #1a6d82;
    --amber: #ffc14a;
    --red: #ff4d5e;
    --ok: #5dffb0;
    --text: #d7e7f4;
    --muted: #8aa0b5;
    --danger: #ff6b78;
  }
  * { box-sizing: border-box; }
  html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg); color: var(--text);
    font-family: "Segoe UI", system-ui, sans-serif; user-select: none; }
  #gl, #overlay { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
  #gl { z-index: 0; background: #07090e; }
  #overlay { z-index: 1; pointer-events: none; }
  .ui { position: fixed; z-index: 2; pointer-events: none; }
  .ui * { pointer-events: auto; }
  button, .card, #minimap, #help, #title, #endcard, #pause, #topbar, #cmd, #toasts { pointer-events: auto; }

  #title {
    position: fixed; inset: 0; z-index: 5;
    display: flex; align-items: stretch; justify-content: flex-end;
    background: linear-gradient(90deg, rgba(5,8,14,0.15) 0%, rgba(5,8,14,0.55) 42%, rgba(5,8,14,0.88) 100%);
  }
  #title.hidden, .hidden { display: none !important; }
  .title-panel {
    width: min(520px, 96vw); margin: 28px; padding: 28px 30px;
    background: rgba(6, 12, 22, 0.78);
    border: 1px solid var(--line);
    box-shadow: 0 0 40px rgba(40, 180, 255, 0.08), inset 0 0 60px rgba(0,0,0,0.35);
    backdrop-filter: blur(6px);
    overflow: auto;
    max-height: calc(100vh - 56px);
  }
  .kicker { letter-spacing: 0.42em; font-size: 11px; color: var(--cyan); text-transform: uppercase; }
  h1 { font-size: 46px; margin: 6px 0 0; letter-spacing: 0.08em; font-weight: 700; line-height: 0.95;
    text-shadow: 0 0 24px rgba(62,224,255,0.35); }
  h1 span { color: var(--cyan); }
  .sub { color: var(--muted); margin: 8px 0 16px; font-size: 14px; }
  .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 18px; font-size: 12.5px; line-height: 1.45; color: #c5d6e6; }
  .cols h3 { margin: 0 0 4px; font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--amber); }
  .cols p { margin: 0 0 8px; }
  #startBtn, .primary, .ghost {
    border: 1px solid var(--cyan); background: linear-gradient(180deg, #15556a, #0c3342);
    color: white; font-size: 16px; letter-spacing: 0.14em; text-transform: uppercase;
    padding: 12px 18px; cursor: pointer; width: 100%; margin-top: 16px;
  }
  #startBtn:hover, .primary:hover { filter: brightness(1.15); }
  .ghost { background: transparent; border-color: rgba(255,255,255,0.2); letter-spacing: 0.08em; font-size: 13px; margin-top: 8px; }
  .unit-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
  .chip { font-size: 11px; border: 1px solid rgba(62,224,255,0.3); padding: 3px 7px; color: #b7e9f6; }

  #topbar {
    position: fixed; top: 0; left: 0; right: 0; z-index: 3; height: 46px;
    display: flex; align-items: center; gap: 16px; padding: 0 12px;
    background: linear-gradient(180deg, rgba(6,10,18,0.92), rgba(6,10,18,0.55));
    border-bottom: 1px solid rgba(62,224,255,0.18);
    pointer-events: auto;
  }
  .stat { display: flex; align-items: baseline; gap: 6px; font-variant-numeric: tabular-nums; }
  .stat b { color: var(--amber); font-size: 18px; font-weight: 650; }
  .stat.supply b { color: var(--cyan); }
  .stat.supply.warn b { color: var(--danger); }
  .stat span, .lbl { color: var(--muted); font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; }
  #topbar .spacer { flex: 1; }
  #topbar button {
    pointer-events: auto; background: transparent; color: var(--text);
    border: 1px solid rgba(255,255,255,0.16); padding: 5px 10px; cursor: pointer; font-size: 12px; letter-spacing: 0.08em;
  }
  #topbar button:hover { border-color: var(--cyan); color: white; }
  #clock { font-variant-numeric: tabular-nums; color: #9fb4c6; }

  #minimapWrap {
    position: fixed; left: 12px; bottom: 12px; z-index: 3; width: 210px;
    background: var(--panel); border: 1px solid var(--line); padding: 8px;
    pointer-events: auto;
  }
  #minimapWrap .lbl { display: block; margin-bottom: 4px; }
  #minimap { width: 194px; height: 194px; display: block; cursor: crosshair; background: #05070b; }

  #cmd {
    position: fixed; right: 12px; bottom: 12px; z-index: 3; width: 430px;
    background: var(--panel); border: 1px solid var(--line); padding: 10px;
    pointer-events: auto; min-height: 168px;
  }
  #selHead { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 6px; }
  #selName { font-size: 15px; letter-spacing: 0.06em; }
  #selMeta { color: var(--muted); font-size: 12px; }
  #hpTrack { height: 6px; background: #1b2430; margin: 4px 0 8px; }
  #hpFill { height: 100%; width: 0%; background: linear-gradient(90deg, #1f8f4a, #7dffb0); }
  #why { min-height: 16px; font-size: 12px; color: var(--amber); margin-top: 6px; }
  #grid {
    display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px;
  }
  #grid button {
    background: rgba(16, 28, 42, 0.9); color: var(--text);
    border: 1px solid rgba(90, 160, 190, 0.28); min-height: 42px; cursor: pointer;
    font-size: 11px; text-align: left; padding: 4px 6px; line-height: 1.2;
  }
  #grid button:hover { border-color: var(--cyan); }
  #grid button.disabled { opacity: 0.45; }
  #grid button small { display: block; color: var(--amber); font-size: 10px; }
  #grid button.on { border-color: var(--ok); box-shadow: inset 0 0 8px rgba(93,255,176,0.25); }
  #queue { display: flex; gap: 4px; margin-top: 6px; min-height: 18px; }
  .qitem { position: relative; flex: 1; max-width: 72px; height: 16px; background: #14202c; border: 1px solid rgba(62,224,255,0.25); font-size: 9px; line-height: 16px; padding: 0 4px; overflow: hidden; cursor: pointer; }
  .qitem i { position: absolute; left: 0; top: 0; bottom: 0; background: rgba(62,224,255,0.35); }
  #tips { font-size: 12px; color: #b7c9d8; line-height: 1.45; }
  #tips b { color: var(--cyan); font-weight: 600; }

  #toasts {
    position: fixed; top: 58px; left: 50%; transform: translateX(-50%); z-index: 4;
    display: flex; flex-direction: column; gap: 6px; align-items: center; pointer-events: none;
  }
  .toast {
    background: rgba(8,14,24,0.9); border: 1px solid var(--line); padding: 8px 14px;
    font-size: 13px; letter-spacing: 0.03em; box-shadow: 0 8px 24px rgba(0,0,0,0.35);
  }
  .toast.bad { border-color: var(--red); color: #ffd0d4; }
  .toast.good { border-color: var(--ok); }

  #help, #pause, #endcard {
    position: fixed; z-index: 6; inset: 0; background: rgba(4,6,10,0.72);
    display: flex; align-items: center; justify-content: center;
  }
  .modal {
    width: min(640px, 94vw); max-height: min(84vh, 760px); overflow: auto;
    background: rgba(8,14,24,0.96); border: 1px solid var(--line); padding: 22px 24px;
  }
  .modal h2 { margin: 0 0 8px; letter-spacing: 0.14em; font-size: 20px; }
  .modal h3 { color: var(--amber); font-size: 12px; letter-spacing: 0.14em; text-transform: uppercase; margin: 14px 0 4px; }
  .modal p, .modal li { font-size: 13.5px; color: #d0deea; line-height: 1.45; }
  .modal ul { margin: 4px 0 0 18px; padding: 0; }
  #endcard h2.win { color: var(--ok); }
  #endcard h2.lose { color: var(--danger); }
  .stats { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 16px; font-variant-numeric: tabular-nums; }
  kbd { border: 1px solid rgba(255,255,255,0.2); padding: 0 4px; border-radius: 3px; font-size: 11px; color: var(--cyan); }
</style>
</head>
<body>
<canvas id="gl"></canvas>
<canvas id="overlay"></canvas>

<div id="title">
  <div class="title-panel">
    <div class="kicker">Vesper Reach · Expedition 7</div>
    <h1>FRONTIER<br><span>WAR</span></h1>
    <p class="sub">A 3D real-time strategy skirmish. One primary resource. An unknown map. An enemy that builds, scouts, and escalates.</p>
    <div class="cols">
      <div>
        <h3>Victory</h3>
        <p>Destroy every enemy <b>Command Spire</b>. You lose if all of yours fall. Completed spires only — expansions count once finished. The enemy base is not revealed. Scout.</p>
        <h3>Opening</h3>
        <p>You start with a Spire, four Prospectors, and 420 Aetherium. Workers already harvest the nearby crystals. Serious enemy pressure waits while you build an economy.</p>
      </div>
      <div>
        <h3>Controls</h3>
        <p><b>LMB</b> select · drag box · <b>Shift</b> add<br>
        <b>RMB</b> move / attack / gather / rally<br>
        <b>A</b> attack-move · <b>S</b> stop · <b>H</b> hold<br>
        <b>WASD</b> pan · <b>Q/E</b> rotate · wheel zoom<br>
        <b>Ctrl+1–9</b> groups · <b>.</b> idle workers<br>
        Edge-scroll and minimap clicks move the camera. The camera never reveals fog.</p>
      </div>
    </div>
    <div class="unit-row">
      <span class="chip">Prospector · worker</span>
      <span class="chip">Skirmisher · kinetic melee</span>
      <span class="chip">Railguard · energy / AA</span>
      <span class="chip">Siegeframe · heavy</span>
      <span class="chip">Interceptor · air</span>
    </div>
    <p class="sub" style="margin-top:12px">Kinetic tears up light armor and fails against hulls and flyers. Energy punishes heavies and aircraft. Railguards and Bastions are your answer to the sky.</p>
    <button id="startBtn">Start Expedition</button>
    <button class="ghost" id="titleHelp">Field manual</button>
  </div>
</div>

<div id="hud" class="hidden">
  <div id="topbar">
    <div class="stat"><span>Aetherium</span><b id="resNum">0</b></div>
    <div class="stat supply"><span>Supply</span><b id="supNum">0/0</b></div>
    <div class="stat"><span>Army</span><b id="armyNum">0</b></div>
    <div class="spacer"></div>
    <div id="clock">00:00</div>
    <button id="btnHelp" type="button">Help</button>
    <button id="btnMute" type="button">Sound</button>
    <button id="btnPause" type="button">Pause</button>
  </div>
  <div id="minimapWrap">
    <span class="lbl">Tactical map</span>
    <canvas id="minimap" width="194" height="194"></canvas>
  </div>
  <div id="cmd">
    <div id="selHead"><div id="selName">No selection</div><div id="selMeta"></div></div>
    <div id="hpTrack"><div id="hpFill"></div></div>
    <div id="tips"></div>
    <div id="grid"></div>
    <div id="queue"></div>
    <div id="why"></div>
  </div>
  <div id="toasts"></div>
</div>

<div id="help" class="hidden">
  <div class="modal">
    <h2>FIELD MANUAL</h2>
    <h3>Victory</h3>
    <p>Wipe out every completed enemy Command Spire. If your last completed Spire is destroyed, the expedition is lost. Spire sites still under construction do not count. You may build additional Spires as expansions.</p>
    <h3>Economy</h3>
    <p>Prospectors gather finite Aetherium from crystal fields and return it to a Command Spire or Drop Depot. Nearby fields deplete — a Depot beside a distant field hauls faster than walking home. Spending on workers, army, defenses, tech, and expansions is a live tradeoff.</p>
    <h3>Supply</h3>
    <p>The top bar shows <b>used / capacity</b>. Units occupy supply while alive; training a unit reserves supply and releases it if you cancel. Capacity comes from Spires (+14) and Relay Pylons (+18). There is no small population cap — keep building pylons into the hundreds and beyond. If capacity falls, existing units remain, but new production pauses with a supply warning. Cancelled training refunds 100% of the cost. Cancelling a building under construction refunds 75%.</p>
    <h3>Counters</h3>
    <ul>
      <li><b>Skirmisher</b> — fast kinetic melee. Strong vs light armor, weak vs heavies, cannot hit air.</li>
      <li><b>Railguard</b> — energy rifle, ground and air. Your mobile answer to Siegeframes and Interceptors.</li>
      <li><b>Siegeframe</b> — slow heavy kinetic cannon, ground only. Soaks damage, poor vs energy.</li>
      <li><b>Interceptor</b> — flyer. Ignores terrain. Dies to Railguards and Bastions.</li>
      <li><b>Bastion</b> — energy turret covering ground and sky.</li>
    </ul>
    <h3>Fog</h3>
    <p>Black is unexplored. Dim terrain is explored but out of sight — enemy units vanish there. Enemy buildings you have seen remain as stale silhouettes until you look again. Orders and guns cannot acquire hidden enemies. The minimap uses the same rules. Moving the camera reveals nothing.</p>
    <h3>Commands</h3>
    <ul>
      <li><kbd>LMB</kbd> select, drag box, Shift adds. Double-click selects visible units of that type.</li>
      <li><kbd>RMB</kbd> move, attack, gather, return, repair, or set rally on a production building.</li>
      <li><kbd>A</kbd> then click attack-move. <kbd>S</kbd> stop. <kbd>H</kbd> hold position (fires, never chases).</li>
      <li>Shift+right-click queues orders. <kbd>Ctrl</kbd>+number stores a control group.</li>
      <li><kbd>WASD</kbd> or edge scroll pans. <kbd>Q</kbd>/<kbd>E</kbd> or middle-drag rotates. Wheel zooms.</li>
      <li><kbd>Space</kbd> centers selection. <kbd>.</kbd> or <kbd>I</kbd> selects idle Prospectors. <kbd>Esc</kbd> cancels modes / pauses.</li>
      <li>Build from a selected Prospector. Placement shows valid (green) or blocked (red). Left-click confirms.</li>
    </ul>
    <h3>Research</h3>
    <p>Archives unlock four upgrades: kinetic damage, energy damage, hull strength, and speed. They compete with army spending. Siegeframes require a completed Archive.</p>
    <button class="primary" id="helpClose" type="button">Return</button>
  </div>
</div>

<div id="pause" class="hidden">
  <div class="modal">
    <h2>PAUSED</h2>
    <p>The frontier holds its breath. Camera and manual stay available; the war does not advance.</p>
    <button class="primary" id="resumeBtn" type="button">Resume</button>
    <button class="ghost" id="pauseHelp" type="button">Field manual</button>
    <button class="ghost" id="pauseRestart" type="button">Restart match</button>
    <button class="ghost" id="pauseTitle" type="button">Title screen</button>
  </div>
</div>

<div id="endcard" class="hidden">
  <div class="modal">
    <h2 id="endTitle">VICTORY</h2>
    <p id="endBlurb"></p>
    <div class="stats" id="endStats"></div>
    <button class="primary" id="endRestart" type="button">Fight again</button>
    <button class="ghost" id="endTitleBtn" type="button">Title screen</button>
  </div>
</div>

<script>
const MAP = 200;
const CELL = 2;
const GRID = MAP / CELL;
const FOG_N = 120;
const MAX_ENTS = 2200;

const MOD = {
  kinetic: { light: 1.35, heavy: 0.55, flyer: 0.3 },
  energy: { light: 0.75, heavy: 1.45, flyer: 1.4 }
};

const UDEF = {
  prospector: { name:'Prospector', role:'Worker', hp:70, speed:8.2, r:0.55, sight:10, armor:'light', supply:1, cost:50, time:7, flying:false, carry:12, gather:1.35, dmg:0, range:0, cd:1, targets:'none', dmgType:'kinetic', size:1 },
  skirmisher: { name:'Skirmisher', role:'Melee · kinetic vs light', hp:120, speed:8.4, r:0.62, sight:8.5, armor:'light', supply:1, cost:60, time:8, flying:false, dmg:11, range:1.45, cd:0.7, targets:'ground', dmgType:'kinetic' },
  railguard: { name:'Railguard', role:'Ranged energy · AA', hp:85, speed:6.4, r:0.62, sight:11, armor:'light', supply:2, cost:90, time:11, flying:false, dmg:12, range:8.2, cd:1.05, targets:'both', dmgType:'energy' },
  siegeframe: { name:'Siegeframe', role:'Heavy · kinetic cannon', hp:320, speed:4.3, r:1.05, sight:9, armor:'heavy', supply:3, cost:165, time:16, flying:false, dmg:26, range:7.2, cd:1.65, targets:'ground', dmgType:'kinetic', prereq:'archives' },
  interceptor: { name:'Interceptor', role:'Air · energy', hp:125, speed:12.5, r:0.75, sight:12, armor:'flyer', supply:2, cost:140, time:14, flying:true, fly:6.2, dmg:13, range:6.4, cd:0.85, targets:'both', dmgType:'energy' }
};

const BDEF = {
  spire: { name:'Command Spire', hp:1800, supply:14, sight:13, size:4.6, time:36, cost:380, drop:true, produces:['prospector'], blurb:'HQ, drop-off, trains Prospectors. +14 supply.' },
  pylon: { name:'Relay Pylon', hp:380, supply:18, sight:8, size:1.7, time:10, cost:75, blurb:'+18 supply. Build many — there is no small cap.' },
  depot: { name:'Drop Depot', hp:460, supply:0, sight:8, size:2.5, time:12, cost:90, drop:true, blurb:'Resource drop-off. Place beside distant crystals.' },
  foundry: { name:'Foundry', hp:900, supply:0, sight:9, size:3.6, time:20, cost:170, produces:['skirmisher','railguard','siegeframe'], blurb:'Trains ground army. Queues and rally independently.' },
  aerie: { name:'Sky Aerie', hp:780, supply:0, sight:10, size:3.5, time:22, cost:190, produces:['interceptor'], blurb:'Trains Interceptors.' },
  archives: { name:'Archive', hp:620, supply:0, sight:8, size:2.8, time:18, cost:140, researches:true, blurb:'Research. Also unlocks Siegeframes.' },
  bastion: { name:'Bastion', hp:520, supply:0, sight:11, size:2.1, time:14, cost:110, turret:true, dmg:18, range:9.2, cd:0.95, targets:'both', dmgType:'energy', blurb:'Energy turret. Hits ground and air.' }
};

const RESEARCH = {
  kinetic: { name:'Kinetic Calibers', cost:160, time:24, blurb:'+20% kinetic damage' },
  energy: { name:'Plasma Lattice', cost:160, time:24, blurb:'+20% energy damage' },
  hull: { name:'Composite Hulls', cost:180, time:26, blurb:'+20% unit hit points' },
  speed: { name:'Pulse Drives', cost:140, time:20, blurb:'+15% move speed' }
};

const BUILD_ORDER = ['pylon','depot','foundry','aerie','archives','bastion','spire'];

let gl, prog, buf, aPos, aNrm, aCol, aEmi, uVP, uLight, uFogOn, uFog, uMap, uTime, uCam;
let fogTex;
const overlay = document.getElementById('overlay');
const octx = overlay.getContext('2d');
const mm = document.getElementById('minimap');
const mctx = mm.getContext('2d');

const cam = { x: 36, z: 36, yaw: 0.75, dist: 42 };
let keys = {};
let mode = 'menu'; // menu | play | paused | victory | defeat
let placeType = null;
let attackMoveMode = false;
let drag = null;
let pointer = { x: 0, y: 0, overUI: false };
let lastPointer = { x: 0, y: 0 };
let time = 0;
let matchT = 0;
let nextId = 1;
let muted = false;
let audioCtx = null;
let panelKey = '';
let whyText = '';
let camDrag = null;
let minimapTimer = 0;
let underAttackPing = 0;
let alertCD = 0;
const toasts = [];
const pings = [];
const shots = [];
const particles = [];
const floaters = [];
let navBlocked = null;
let staticBlock = null;
let mouseWorld = { x: 36, z: 36, ok: false };
let hoverEnt = null;
let groups = Array.from({length:10}, () => []);
let groupTap = Array.from({length:10}, () => 0);
let stats = null;
let ai = null;
let fogP, fogE;
let seenEnemy = new Map();
let deposits = [];
let rocks = [];
let units = [];
let buildings = [];
let selected = new Set();
let resources = { player: 420, enemy: 420 };
let upgrades = { player: {}, enemy: {} };
let supplyBlockFlash = 0;
let gameSeed = 7;
let terrainMesh = null;
let ornamentMesh = null;
const templates = {};
let lost = false;
let endDelay = 0;
let helpReturn = 'title';

function mulberry(a) {
  return function() {
    a |= 0; a = a + 0x6D2B79F5 | 0;
    let t = Math.imul(a ^ a >>> 15, 1 | a);
    t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
    return ((t ^ t >>> 14) >>> 0) / 4294967296;
  };
}
let rnd = mulberry(gameSeed);

function id() { return nextId++; }
function clamp(v,a,b){ return v<a?a:v>b?b:v; }
function len(x,z){ return Math.hypot(x,z); }
function dist2(ax,az,bx,bz){ const dx=ax-bx, dz=az-bz; return dx*dx+dz*dz; }
function angLerp(a,b,t){
  let d = ((b-a+Math.PI)%(Math.PI*2)+Math.PI*2)%(Math.PI*2)-Math.PI;
  return a + d * t;
}

function heightAt(x,z){
  let h = Math.sin(x*0.045)*1.15 + Math.cos(z*0.038)*0.95 + Math.sin((x+z)*0.02)*0.7;
  h += Math.sin(x*0.11+2)*0.25;
  const rift = riftInfo(x,z);
  if (rift.depth > 0) h -= rift.depth;
  if (rift.bridge) h += 0.4;
  return h;
}
function riftInfo(x,z){
  const dx = Math.abs(x - 100);
  const inBand = dx < 9 && z > 18 && z < 182;
  const bridge = (Math.abs(z-52)<11) || (Math.abs(z-100)<9) || (Math.abs(z-148)<11);
  if (!inBand) return { depth:0, bridge:false, glow:0 };
  if (bridge && dx < 8) return { depth: 0.3, bridge:true, glow: 0.25 };
  const depth = (9-dx) * 0.85;
  return { depth, bridge:false, glow: dx < 3 ? 1 : 0.4 };
}
function inRiftBlocked(x,z){
  const r = riftInfo(x,z);
  return r.depth > 1.2 && !r.bridge;
}

function rockBlocked(x,z){
  for (let i=0;i<rocks.length;i++){
    const r = rocks[i];
    if (dist2(x,z,r.x,r.z) < (r.r+0.4)*(r.r+0.4)) return true;
  }
  return false;
}

/* ---------- WebGL ---------- */
function compile(type, src){
  const s = gl.createShader(type);
  gl.shaderSource(s, src);
  gl.compileShader(s);
  if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) console.error(gl.getShaderInfoLog(s));
  return s;
}
function initGL(){
  const canvas = document.getElementById('gl');
  gl = canvas.getContext('webgl', { antialias: true, alpha: false, stencil: false });
  if (!gl) { alert('WebGL unavailable'); return; }
  const vs = `
  attribute vec3 aPos;
  attribute vec3 aNrm;
  attribute vec3 aCol;
  attribute float aEmi;
  uniform mat4 uVP;
  uniform float uTime;
  varying vec3 vCol; varying vec3 vNrm; varying float vEmi; varying vec3 vW;
  void main(){
    vec3 p = aPos;
    if (aEmi > 1.5) {
      p.y += sin(uTime*1.8 + aPos.x*0.7 + aPos.z*0.5)*0.18;
    }
    vW = p; vNrm = aNrm; vCol = aCol; vEmi = aEmi;
    gl_Position = uVP * vec4(p, 1.0);
  }`;
  const fs = `
  precision mediump float;
  varying vec3 vCol; varying vec3 vNrm; varying float vEmi; varying vec3 vW;
  uniform vec3 uLight; uniform vec3 uCam;
  uniform sampler2D uFog; uniform float uFogOn; uniform vec2 uMap;
  void main(){
    vec3 n = normalize(vNrm);
    float ndl = max(dot(n, uLight), 0.0);
    float hemi = n.y * 0.5 + 0.5;
    vec3 base = vCol * (0.28 + 0.42*hemi + 0.55*ndl);
    float em = vEmi > 1.5 ? 1.0 : vEmi;
    base += vCol * em * 0.85;
    if (uFogOn > 0.5) {
      vec2 uv = clamp(vW.xz / uMap, 0.0, 1.0);
      float f = texture2D(uFog, uv).r;
      vec3 black = vec3(0.012, 0.016, 0.028);
      vec3 explored = base * 0.32 + vec3(0.015, 0.02, 0.03);
      float known = smoothstep(0.08, 0.28, f);
      float see = smoothstep(0.55, 0.85, f);
      base = mix(black, mix(explored, base, see), known);
      float edge = smoothstep(0.02, 0.12, f) * (1.0 - smoothstep(0.18, 0.4, f));
      base += vec3(0.05, 0.12, 0.16) * edge * 0.7;
    }
    float d = length(vW - uCam);
    base = mix(base, vec3(0.05, 0.07, 0.1), smoothstep(110.0, 220.0, d));
    gl_FragColor = vec4(base, 1.0);
  }`;
  prog = gl.createProgram();
  gl.attachShader(prog, compile(gl.VERTEX_SHADER, vs));
  gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, fs));
  gl.linkProgram(prog);
  gl.useProgram(prog);
  aPos = gl.getAttribLocation(prog, 'aPos');
  aNrm = gl.getAttribLocation(prog, 'aNrm');
  aCol = gl.getAttribLocation(prog, 'aCol');
  aEmi = gl.getAttribLocation(prog, 'aEmi');
  uVP = gl.getUniformLocation(prog, 'uVP');
  uLight = gl.getUniformLocation(prog, 'uLight');
  uFogOn = gl.getUniformLocation(prog, 'uFogOn');
  uFog = gl.getUniformLocation(prog, 'uFog');
  uMap = gl.getUniformLocation(prog, 'uMap');
  uTime = gl.getUniformLocation(prog, 'uTime');
  uCam = gl.getUniformLocation(prog, 'uCam');
  buf = gl.createBuffer();
  fogTex = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, fogTex);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  const blank = new Uint8Array(FOG_N*FOG_N*4);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, FOG_N, FOG_N, 0, gl.RGBA, gl.UNSIGNED_BYTE, blank);
  gl.enable(gl.DEPTH_TEST);
  gl.enable(gl.CULL_FACE);
  gl.cullFace(gl.BACK);
  resize();
}
function resize(){
  const c = gl.canvas, o = overlay;
  const w = innerWidth, h = innerHeight;
  c.width = w; c.height = h; o.width = w; o.height = h;
  gl.viewport(0,0,w,h);
}
window.addEventListener('resize', resize);

/* matrices */
function mat4Perspective(fovy, aspect, near, far){
  const f = 1/Math.tan(fovy/2), nf = 1/(near-far);
  return new Float32Array([
    f/aspect,0,0,0, 0,f,0,0, 0,0,(far+near)*nf,-1, 0,0,(2*far*near)*nf,0
  ]);
}
function mat4Look(eye, center, up){
  let zx = eye[0]-center[0], zy=eye[1]-center[1], zz=eye[2]-center[2];
  let zl = Math.hypot(zx,zy,zz)||1; zx/=zl; zy/=zl; zz/=zl;
  let xx = up[1]*zz - up[2]*zy, xy = up[2]*zx - up[0]*zz, xz = up[0]*zy - up[1]*zx;
  let xl = Math.hypot(xx,xy,xz)||1; xx/=xl; xy/=xl; xz/=xl;
  let yx = zy*xz - zz*xy, yy = zz*xx - zx*xz, yz = zx*xy - zy*xx;
  return new Float32Array([
    xx, yx, zx, 0,
    xy, yy, zy, 0,
    xz, yz, zz, 0,
    -(xx*eye[0]+xy*eye[1]+xz*eye[2]),
    -(yx*eye[0]+yy*eye[1]+yz*eye[2]),
    -(zx*eye[0]+zy*eye[1]+zz*eye[2]),
    1
  ]);
}
function mat4Mul(a,b){
  const o = new Float32Array(16);
  for (let c=0;c<4;c++) for (let r=0;r<4;r++){
    o[c*4+r] = a[r]*b[c*4] + a[4+r]*b[c*4+1] + a[8+r]*b[c*4+2] + a[12+r]*b[c*4+3];
  }
  return o;
}
function mat4Invert(m){
  const inv = new Float32Array(16);
  const a=m;
  inv[0]=a[5]*a[10]*a[15]-a[5]*a[11]*a[14]-a[9]*a[6]*a[15]+a[9]*a[7]*a[14]+a[13]*a[6]*a[11]-a[13]*a[7]*a[10];
  inv[4]=-a[4]*a[10]*a[15]+a[4]*a[11]*a[14]+a[8]*a[6]*a[15]-a[8]*a[7]*a[14]-a[12]*a[6]*a[11]+a[12]*a[7]*a[10];
  inv[8]=a[4]*a[9]*a[15]-a[4]*a[11]*a[13]-a[8]*a[5]*a[15]+a[8]*a[7]*a[13]+a[12]*a[5]*a[11]-a[12]*a[7]*a[9];
  inv[12]=-a[4]*a[9]*a[14]+a[4]*a[10]*a[13]+a[8]*a[5]*a[14]-a[8]*a[6]*a[13]-a[12]*a[5]*a[10]+a[12]*a[6]*a[9];
  inv[1]=-a[1]*a[10]*a[15]+a[1]*a[11]*a[14]+a[9]*a[2]*a[15]-a[9]*a[3]*a[14]-a[13]*a[2]*a[11]+a[13]*a[3]*a[10];
  inv[5]=a[0]*a[10]*a[15]-a[0]*a[11]*a[14]-a[8]*a[2]*a[15]+a[8]*a[3]*a[14]+a[12]*a[2]*a[11]-a[12]*a[3]*a[10];
  inv[9]=-a[0]*a[9]*a[15]+a[0]*a[11]*a[13]+a[8]*a[1]*a[15]-a[8]*a[3]*a[13]-a[12]*a[1]*a[11]+a[12]*a[3]*a[9];
  inv[13]=a[0]*a[9]*a[14]-a[0]*a[10]*a[13]-a[8]*a[1]*a[14]+a[8]*a[2]*a[13]+a[12]*a[1]*a[10]-a[12]*a[2]*a[9];
  inv[2]=a[1]*a[6]*a[15]-a[1]*a[7]*a[14]-a[5]*a[2]*a[15]+a[5]*a[3]*a[14]+a[13]*a[2]*a[7]-a[13]*a[3]*a[6];
  inv[6]=-a[0]*a[6]*a[15]+a[0]*a[7]*a[14]+a[4]*a[2]*a[15]-a[4]*a[3]*a[14]-a[12]*a[2]*a[7]+a[12]*a[3]*a[6];
  inv[10]=a[0]*a[5]*a[15]-a[0]*a[7]*a[13]-a[4]*a[1]*a[15]+a[4]*a[3]*a[13]+a[12]*a[1]*a[7]-a[12]*a[3]*a[5];
  inv[14]=-a[0]*a[5]*a[14]+a[0]*a[6]*a[13]+a[4]*a[1]*a[14]-a[4]*a[2]*a[13]-a[12]*a[1]*a[6]+a[12]*a[2]*a[5];
  inv[3]=-a[1]*a[6]*a[11]+a[1]*a[7]*a[10]+a[5]*a[2]*a[11]-a[5]*a[3]*a[10]-a[9]*a[2]*a[7]+a[9]*a[3]*a[6];
  inv[7]=a[0]*a[6]*a[11]-a[0]*a[7]*a[10]-a[4]*a[2]*a[11]+a[4]*a[3]*a[10]+a[8]*a[2]*a[7]-a[8]*a[3]*a[6];
  inv[11]=-a[0]*a[5]*a[11]+a[0]*a[7]*a[9]+a[4]*a[1]*a[11]-a[4]*a[3]*a[9]-a[8]*a[1]*a[7]+a[8]*a[3]*a[5];
  inv[15]=a[0]*a[5]*a[10]-a[0]*a[6]*a[9]-a[4]*a[1]*a[10]+a[4]*a[2]*a[9]+a[8]*a[1]*a[6]-a[8]*a[2]*a[5];
  let det = a[0]*inv[0]+a[1]*inv[4]+a[2]*inv[8]+a[3]*inv[12];
  if (!det) return mat4Perspective(1,1,0.1,10);
  det = 1/det;
  for (let i=0;i<16;i++) inv[i]*=det;
  return inv;
}
function transformPoint(m, x,y,z,w){
  const X = m[0]*x + m[4]*y + m[8]*z + m[12]*w;
  const Y = m[1]*x + m[5]*y + m[9]*z + m[13]*w;
  const Z = m[2]*x + m[6]*y + m[10]*z + m[14]*w;
  const W = m[3]*x + m[7]*y + m[11]*z + m[15]*w;
  return [X,Y,Z,W];
}

let vp = new Float32Array(16);
let invVP = new Float32Array(16);
let eye = [0,40,40];

function cameraEye(){
  const elev = 0.92 + clamp((cam.dist-30)/80, 0, 1)*0.18;
  const horiz = Math.cos(elev) * cam.dist;
  const y = Math.sin(elev) * cam.dist + 2;
  return [cam.x + Math.cos(cam.yaw)*horiz, y, cam.z + Math.sin(cam.yaw)*horiz];
}
function updateCameraMatrices(){
  eye = cameraEye();
  const aspect = gl.canvas.width / Math.max(1, gl.canvas.height);
  const proj = mat4Perspective(0.95, aspect, 0.4, 420);
  const view = mat4Look(eye, [cam.x, heightAt(cam.x,cam.z), cam.z], [0,1,0]);
  vp = mat4Mul(proj, view);
  invVP = mat4Invert(vp);
}
function worldToScreen(x,y,z){
  const p = transformPoint(vp, x,y,z,1);
  if (p[3] <= 0.05) return null;
  return {
    x: (p[0]/p[3]*0.5+0.5)*gl.canvas.width,
    y: (1-(p[1]/p[3]*0.5+0.5))*gl.canvas.height
  };
}
function screenRay(sx, sy){
  const x = (sx / gl.canvas.width)*2 - 1;
  const y = 1 - (sy / gl.canvas.height)*2;
  const a = transformPoint(invVP, x, y, -1, 1);
  const b = transformPoint(invVP, x, y, 1, 1);
  const ax=a[0]/a[3], ay=a[1]/a[3], az=a[2]/a[3];
  const bx=b[0]/b[3], by=b[1]/b[3], bz=b[2]/b[3];
  const dx=bx-ax, dy=by-ay, dz=bz-az;
  const l = Math.hypot(dx,dy,dz)||1;
  return { ox:ax, oy:ay, oz:az, dx:dx/l, dy:dy/l, dz:dz/l };
}
function rayGround(r){
  let t = 0;
  for (let i=0;i<48;i++){
    const x = r.ox + r.dx*t, y = r.oy + r.dy*t, z = r.oz + r.dz*t;
    if (x<-20||z<-20||x>MAP+20||z>MAP+20) return null;
    const h = heightAt(clamp(x,0,MAP), clamp(z,0,MAP));
    if (y < h) {
      const t0 = Math.max(0, t-4), t1 = t;
      let lo=t0, hi=t1;
      for (let k=0;k<8;k++){
        const mid=(lo+hi)/2;
        const mx=r.ox+r.dx*mid, my=r.oy+r.dy*mid, mz=r.oz+r.dz*mid;
        if (my < heightAt(clamp(mx,0,MAP), clamp(mz,0,MAP))) hi=mid; else lo=mid;
      }
      const hx=r.ox+r.dx*hi, hz=r.oz+r.dz*hi;
      return { x:hx, z:hz, y: heightAt(clamp(hx,0,MAP), clamp(hz,0,MAP)) };
    }
    t += (y > 40 ? 6 : 3);
  }
  return null;
}

/* ---------- mesh builder ---------- */
function MB(){ this.p=[]; this.n=[]; this.c=[]; this.e=[]; }
MB.prototype.add = function(x,y,z,nx,ny,nz,cr,cg,cb,em){
  this.p.push(x,y,z); this.n.push(nx,ny,nz); this.c.push(cr,cg,cb); this.e.push(em);
};
MB.prototype.tri = function(a,b,c,col,em,tint){
  const ux=b[0]-a[0], uy=b[1]-a[1], uz=b[2]-a[2];
  const vx=c[0]-a[0], vy=c[1]-a[1], vz=c[2]-a[2];
  let nx=uy*vz-uz*vy, ny=uz*vx-ux*vz, nz=ux*vy-uy*vx;
  const l=Math.hypot(nx,ny,nz)||1; nx/=l; ny/=l; nz/=l;
  const cr=col[0], cg=col[1], cb=col[2];
  const e = em + (tint?0:0);
  this.add(a[0],a[1],a[2],nx,ny,nz,cr,cg,cb, tint?em:em);
  this.add(b[0],b[1],b[2],nx,ny,nz,cr,cg,cb, em);
  this.add(c[0],c[1],c[2],nx,ny,nz,cr,cg,cb, em);
  if (tint) {
    const i = this.e.length;
    // mark tint in emi high bit via negative? store tint as emi > 2 later. Use emi 3 for team tint.
  }
};
MB.prototype.face = function(pts, col, em){
  for (let i=1;i<pts.length-1;i++) this.tri(pts[0], pts[i], pts[i+1], col, em);
};
MB.prototype.box = function(x,y,z,sx,sy,sz,col,em){
  const x0=x-sx,x1=x+sx,y0=y-sy,y1=y+sy,z0=z-sz,z1=z+sz;
  this.face([[x0,y0,z1],[x1,y0,z1],[x1,y1,z1],[x0,y1,z1]], col, em);
  this.face([[x1,y0,z0],[x0,y0,z0],[x0,y1,z0],[x1,y1,z0]], col, em);
  this.face([[x0,y0,z0],[x0,y0,z1],[x0,y1,z1],[x0,y1,z0]], col, em);
  this.face([[x1,y0,z1],[x1,y0,z0],[x1,y1,z0],[x1,y1,z1]], col, em);
  this.face([[x0,y1,z1],[x1,y1,z1],[x1,y1,z0],[x0,y1,z0]], col, em);
  this.face([[x0,y0,z0],[x1,y0,z0],[x1,y0,z1],[x0,y0,z1]], col, em);
};
MB.prototype.cyl = function(x,y,z,r,h,seg,col,em, taper){
  taper = taper||1;
  const y0=y, y1=y+h;
  for (let i=0;i<seg;i++){
    const a0=i/seg*Math.PI*2, a1=(i+1)/seg*Math.PI*2;
    const c0=Math.cos(a0), s0=Math.sin(a0), c1=Math.cos(a1), s1=Math.sin(a1);
    this.tri([x+c0*r,y0,z+s0*r],[x+c1*r,y0,z+s1*r],[x+c1*r*taper,y1,z+s1*r*taper], col, em);
    this.tri([x+c0*r,y0,z+s0*r],[x+c1*r*taper,y1,z+s1*r*taper],[x+c0*r*taper,y1,z+s0*r*taper], col, em);
    this.tri([x,y0,z],[x+c1*r,y0,z+s1*r],[x+c0*r,y0,z+s0*r], col, em);
    this.tri([x,y1,z],[x+c0*r*taper,y1,z+s0*r*taper],[x+c1*r*taper,y1,z+s1*r*taper], col, em);
  }
};
MB.prototype.wedge = function(x,y,z,sx,sy,sz,col,em){
  this.tri([x-sx,y,z-sz],[x+sx,y,z-sz],[x,y+sy,z+sz*0.2], col, em);
  this.tri([x-sx,y,z-sz],[x,y+sy,z+sz*0.2],[x-sx,y,z+sz], col, em);
  this.tri([x+sx,y,z-sz],[x+sx,y,z+sz],[x,y+sy,z+sz*0.2], col, em);
  this.tri([x-sx,y,z+sz],[x+sx,y,z+sz],[x,y+sy,z+sz*0.2], col, em);
  this.tri([x-sx,y,z-sz],[x-sx,y,z+sz],[x+sx,y,z+sz], col, em);
  this.tri([x-sx,y,z-sz],[x+sx,y,z+sz],[x+sx,y,z-sz], col, em);
};

function buildTemplates(){
  function unitProspector(){
    const m = new MB();
    const body=[0.62,0.72,0.78], trim=[0.9,0.95,1], glow=[0.4,0.95,1];
    m.box(0,0.45,0, 0.55,0.28,0.7, body, 0);
    m.box(-0.7,0.35,0, 0.18,0.16,0.32, trim, 0.2);
    m.box(0.7,0.35,0, 0.18,0.16,0.32, trim, 0.2);
    m.cyl(0,0.7, -0.15, 0.08, 1.15, 6, trim, 0.3);
    m.box(0,1.9,-0.15, 0.12,0.12,0.12, glow, 1);
    m.wedge(0,0.35,0.75, 0.28,0.22,0.35, body, 0);
    return m;
  }
  function unitSkirmisher(){
    const m = new MB();
    const body=[0.55,0.62,0.7], glow=[0.5,0.9,1];
    m.box(0,0.85,0, 0.32,0.55,0.28, body, 0);
    m.box(0,0.35,0, 0.22,0.35,0.2, body, 0);
    m.box(-0.42,0.95,0.1, 0.1,0.32,0.1, body, 0);
    m.box(0.5,0.7,0.55, 0.08,0.08,0.55, glow, 0.85);
    m.box(0,1.45,0, 0.22,0.22,0.22, [0.8,0.88,0.95], 0.15);
    m.wedge(0,0.2,0.15, 0.28,0.12,0.4, body, 0);
    return m;
  }
  function unitRail(){
    const m = new MB();
    const body=[0.5,0.58,0.66], glow=[0.45,0.85,1];
    m.box(0,0.7,0, 0.36,0.4,0.42, body, 0);
    m.box(0,1.25,0, 0.2,0.28,0.2, body, 0);
    m.box(0,0.85,0.85, 0.1,0.1,0.7, [0.75,0.85,0.95], 0.2);
    m.box(0,0.85,1.5, 0.08,0.08,0.18, glow, 1);
    m.box(-0.45,0.55, -0.1, 0.12,0.28,0.22, body, 0);
    m.box(0.45,0.55,-0.1, 0.12,0.28,0.22, body, 0);
    m.box(0,0.4, -0.45, 0.28,0.22,0.2, [0.3,0.4,0.5], 0.4);
    return m;
  }
  function unitSiege(){
    const m = new MB();
    const body=[0.42,0.48,0.55], dark=[0.22,0.26,0.3], glow=[0.6,0.9,1];
    m.box(0,0.55,0, 0.95,0.32,1.15, body, 0);
    m.box(-0.78,0.35,0, 0.22,0.22,0.95, dark, 0);
    m.box(0.78,0.35,0, 0.22,0.22,0.95, dark, 0);
    m.cyl(0,0.85,0, 0.42,0.38, 8, body, 0.1);
    m.box(0,1.05,0.7, 0.12,0.12,0.7, [0.7,0.78,0.85], 0.25);
    m.box(0,1.05,1.35, 0.14,0.14,0.12, glow, 0.9);
    m.box(0,0.7,-1.0, 0.3,0.16,0.18, glow, 0.5);
    return m;
  }
  function unitAir(){
    const m = new MB();
    const body=[0.6,0.7,0.8], glow=[0.4,0.95,1];
    m.wedge(0,0,0.2, 0.35,0.16,0.9, body, 0.15);
    m.box(-0.85,0.02, -0.15, 0.7,0.05,0.32, body, 0);
    m.box(0.85,0.02,-0.15, 0.7,0.05,0.32, body, 0);
    m.box(0,0.05,-0.7, 0.16,0.1,0.35, glow, 1);
    m.box(0,0.18,0.55, 0.12,0.1,0.2, [0.85,0.92,1], 0.3);
    return m;
  }
  templates.prospector = unitProspector();
  templates.skirmisher = unitSkirmisher();
  templates.railguard = unitRail();
  templates.siegeframe = unitSiege();
  templates.interceptor = unitAir();

  function bSpire(){
    const m = new MB();
    const base=[0.35,0.42,0.5], glow=[0.35,0.9,1];
    m.cyl(0,0,0, 3.4, 0.7, 8, base, 0);
    m.cyl(0,0.7,0, 1.5, 2.2, 8, [0.45,0.55,0.65], 0.1);
    m.cyl(0,2.9,0, 0.55, 3.4, 6, [0.7,0.8,0.9], 0.25, 0.45);
    m.box(0,6.4,0, 0.35,0.35,0.35, glow, 1);
    m.cyl(0,1.5,0, 2.3, 0.12, 8, glow, 0.8);
    return m;
  }
  function bPylon(){
    const m = new MB();
    m.box(0,0.3,0, 0.8,0.3,0.8, [0.4,0.48,0.55], 0);
    m.cyl(0,0.6,0, 0.28, 3.2, 6, [0.65,0.75,0.85], 0.2, 0.7);
    m.cyl(0,2.2,0, 0.85, 0.08, 8, [0.4,0.95,1], 1);
    m.box(0,3.9,0, 0.18,0.28,0.18, [0.5,1,1], 1);
    return m;
  }
  function bDepot(){
    const m = new MB();
    m.box(0,0.35,0, 2.1,0.35,1.6, [0.4,0.46,0.52], 0);
    m.box(0,1.0, -0.4, 0.7,0.7,0.7, [0.3,0.55,0.6], 0.35);
    m.cyl(0,0.7,0.9, 0.18, 1.6, 5, [0.7,0.78,0.85], 0.2);
    m.box(0,2.3,0.9, 0.45,0.12,0.12, [0.4,0.95,1], 1);
    return m;
  }
  function bFoundry(){
    const m = new MB();
    m.box(0,0.9,0, 2.8,0.9,2.0, [0.38,0.4,0.46], 0);
    m.box(-1.4,1.9,0, 0.9,0.35,1.5, [0.28,0.32,0.38], 0.05);
    m.box(1.3,1.9,0.3, 0.8,0.28,1.1, [0.28,0.32,0.38], 0.05);
    m.cyl(1.8,1.8,-1.2, 0.35, 1.8, 6, [0.45,0.5,0.55], 0);
    m.box(1.8,3.7,-1.2, 0.2,0.15,0.2, [1,0.55,0.2], 1);
    m.box(0,0.7,2.05, 0.8,0.45,0.15, [0.3,0.7,0.85], 0.7);
    return m;
  }
  function bAerie(){
    const m = new MB();
    m.cyl(0,0.2,0, 3.0, 0.35, 10, [0.34,0.4,0.5], 0);
    m.cyl(0,0.55,0, 1.3, 0.2, 8, [0.3,0.85,1], 0.85);
    m.box(-2.2,1.4,0, 0.18,1.2,0.18, [0.6,0.7,0.8], 0.2);
    m.box(2.2,1.4,0, 0.18,1.2,0.18, [0.6,0.7,0.8], 0.2);
    m.wedge(0,2.5,0, 1.4,0.15,0.4, [0.55,0.8,0.95], 0.4);
    return m;
  }
  function bArch(){
    const m = new MB();
    m.cyl(0,0,0, 2.3, 0.5, 8, [0.36,0.4,0.5], 0);
    m.cyl(0,0.5,0, 1.5, 1.3, 8, [0.5,0.6,0.72], 0.15, 0.8);
    m.cyl(0,1.6,0, 1.15, 0.7, 8, [0.45,0.75,0.9], 0.45, 0.2);
    m.cyl(0,2.2,0, 0.08, 1.3, 5, [0.8,0.9,1], 0.4);
    m.box(0,3.5,0, 0.16,0.16,0.16, [0.5,0.9,1], 1);
    return m;
  }
  function bBastion(){
    const m = new MB();
    m.cyl(0,0,0, 1.7, 0.6, 8, [0.32,0.36,0.42], 0);
    m.cyl(0,0.6,0, 1.05, 0.7, 8, [0.48,0.55,0.62], 0.1);
    m.box(0,1.15,0.9, 0.16,0.14,0.7, [0.5,0.95,1], 0.9);
    m.box(0,1.5,0, 0.3,0.18,0.3, [0.7,0.8,0.9], 0.3);
    return m;
  }
  templates.spire = bSpire();
  templates.pylon = bPylon();
  templates.depot = bDepot();
  templates.foundry = bFoundry();
  templates.aerie = bAerie();
  templates.archives = bArch();
  templates.bastion = bBastion();
  templates.crystal = (function(){
    const m = new MB();
    const c=[0.35,0.95,0.9];
    const h=2.2, r=0.45;
    for (let i=0;i<5;i++){
      const a0=i/5*Math.PI*2, a1=(i+1)/5*Math.PI*2;
      const p0=[Math.cos(a0)*r,0.4,Math.sin(a0)*r];
      const p1=[Math.cos(a1)*r,0.4,Math.sin(a1)*r];
      m.tri(p0,p1,[0,h,0], c, 2.2);
      m.tri(p1,p0,[0, -0.2, 0], c, 2.2);
    }
    return m;
  })();
}

const dyn = { p:[], n:[], c:[], e:[] };
function dynClear(){ dyn.p.length=0; dyn.n.length=0; dyn.c.length=0; dyn.e.length=0; }
function emitRaw(mb, ox,oy,oz, rot, scale, tint, dim){
  const ct = Math.cos(rot), st = Math.sin(rot);
  const P=mb.p, N=mb.n, C=mb.c, E=mb.e;
  dim = dim==null?1:dim;
  const tr = tint ? tint[0] : 1, tg = tint?tint[1]:1, tb=tint?tint[2]:1;
  for (let i=0,j=0;i<P.length;i+=3,j++){
    let x=P[i]*scale, y=P[i+1]*scale, z=P[i+2]*scale;
    const rx = x*ct - z*st, rz = x*st + z*ct;
    dyn.p.push(rx+ox, y+oy, rz+oz);
    let nx=N[i], ny=N[i+1], nz=N[i+2];
    const rnx = nx*ct - nz*st, rnz = nx*st + nz*ct;
    dyn.n.push(rnx, ny, rnz);
    const em = E[j];
    const useTint = em < 1.6;
    if (useTint) dyn.c.push(C[i]*tr*dim, C[i+1]*tg*dim, C[i+2]*tb*dim);
    else dyn.c.push(C[i]*dim, C[i+1]*dim, C[i+2]*dim);
    dyn.e.push(em>1.5?em:em);
  }
}
function emitTri(a,b,c,col,em){
  const ux=b[0]-a[0], uy=b[1]-a[1], uz=b[2]-a[2];
  const vx=c[0]-a[0], vy=c[1]-a[1], vz=c[2]-a[2];
  let nx=uy*vz-uz*vy, ny=uz*vx-ux*vz, nz=ux*vy-uy*vx;
  const l=Math.hypot(nx,ny,nz)||1; nx/=l; ny/=l; nz/=l;
  dyn.p.push(a[0],a[1],a[2], b[0],b[1],b[2], c[0],c[1],c[2]);
  dyn.n.push(nx,ny,nz, nx,ny,nz, nx,ny,nz);
  dyn.c.push(col[0],col[1],col[2], col[0],col[1],col[2], col[0],col[1],col[2]);
  dyn.e.push(em,em,em);
}
function emitCircle(x,y,z,r,col,em){
  const seg=18;
  for (let i=0;i<seg;i++){
    const a0=i/seg*Math.PI*2, a1=(i+1)/seg*Math.PI*2;
    emitTri(
      [x,y,z],
      [x+Math.cos(a0)*r, y, z+Math.sin(a0)*r],
      [x+Math.cos(a1)*r, y, z+Math.sin(a1)*r],
      col, em
    );
  }
}
function emitRing(x,y,z,r,col,em){
  const seg=20, w=0.12;
  for (let i=0;i<seg;i++){
    const a0=i/seg*Math.PI*2, a1=(i+1)/seg*Math.PI*2;
    const c0=Math.cos(a0), s0=Math.sin(a0), c1=Math.cos(a1), s1=Math.sin(a1);
    emitTri(
      [x+c0*(r-w), y, z+s0*(r-w)],
      [x+c0*(r+w), y, z+s0*(r+w)],
      [x+c1*(r+w), y, z+s1*(r+w)],
      col, em
    );
    emitTri(
      [x+c0*(r-w), y, z+s0*(r-w)],
      [x+c1*(r+w), y, z+s1*(r+w)],
      [x+c1*(r-w), y, z+s1*(r-w)],
      col, em
    );
  }
}

function buildTerrain(){
  const N = 80;
  const mb = new MB();
  const st = MAP / N;
  function colAt(x,z,h){
    const rift = riftInfo(x,z);
    if (rift.glow > 0.5 && rift.depth > 1) return [0.15, 0.55, 0.7];
    if (rift.bridge) return [0.42, 0.4, 0.36];
    if (h < -1) return [0.16, 0.38, 0.48];
    if (h > 1.6) return [0.55, 0.5, 0.4];
    const g = ((Math.floor(x/8)+Math.floor(z/8))&1) ? 0.88 : 1;
    return [0.16*g, 0.42*g, 0.38*g];
  }
  for (let iz=0; iz<N; iz++){
    for (let ix=0; ix<N; ix++){
      const x0=ix*st, z0=iz*st, x1=x0+st, z1=z0+st;
      const h00=heightAt(x0,z0), h10=heightAt(x1,z0), h11=heightAt(x1,z1), h01=heightAt(x0,z1);
      const c00=colAt(x0,z0,h00), c10=colAt(x1,z0,h10), c11=colAt(x1,z1,h11), c01=colAt(x0,z1,h01);
      // two tris with averaged emit if rift
      const e = (riftInfo((x0+x1)/2,(z0+z1)/2).glow > 0.6) ? 0.65 : 0;
      mb.tri([x0,h00,z0],[x1,h11,z1],[x1,h10,z0], c00, e);
      mb.tri([x0,h00,z0],[x0,h01,z1],[x1,h11,z1], c01, e);
    }
  }
  terrainMesh = mb;
  const om = new MB();
  for (const r of rocks){
    om.cyl(r.x, heightAt(r.x,r.z)-0.2, r.z, r.r, r.h, 7, [0.28,0.3,0.36], 0, 0.55);
    if (r.spire){
      om.cyl(r.x, heightAt(r.x,r.z)+r.h*0.5, r.z, r.r*0.35, r.h*1.4, 5, [0.45,0.5,0.58], 0.15, 0.2);
    }
  }
  ornamentMesh = om;
}

/* ---------- fog ---------- */
function makeFog(){
  return { data: new Uint8Array(FOG_N*FOG_N), active: [], pix: new Uint8Array(FOG_N*FOG_N*4) };
}
function fogIndex(x,z){
  const fx = clamp(Math.floor(x / MAP * FOG_N), 0, FOG_N-1);
  const fz = clamp(Math.floor(z / MAP * FOG_N), 0, FOG_N-1);
  return fz*FOG_N + fx;
}
function fogVis(fog, x, z){
  if (x<0||z<0||x>=MAP||z>=MAP) return 0;
  return fog.data[fogIndex(x,z)];
}
function stampFog(fog, x, z, radius){
  const cx = x / MAP * FOG_N, cz = z / MAP * FOG_N;
  const rad = radius / MAP * FOG_N;
  const r = Math.ceil(rad);
  const x0 = clamp(Math.floor(cx-r),0,FOG_N-1), x1 = clamp(Math.floor(cx+r),0,FOG_N-1);
  const z0 = clamp(Math.floor(cz-r),0,FOG_N-1), z1 = clamp(Math.floor(cz+r),0,FOG_N-1);
  const rr = rad*rad;
  for (let iz=z0; iz<=z1; iz++){
    for (let ix=x0; ix<=x1; ix++){
      const dx=ix-cx, dz=iz-cz;
      if (dx*dx+dz*dz <= rr){
        const idx = iz*FOG_N+ix;
        if (fog.data[idx] !== 2){
          fog.data[idx] = 2;
          fog.active.push(idx);
        } else if (fog._stampGen) {
          /* already visible */
        }
      }
    }
  }
}
function updateFog(fog, team){
  const active = fog.active;
  for (let i=0;i<active.length;i++) fog.data[active[i]] = 1;
  active.length = 0;
  // mark freshly by setting to 2 only if not already 2 this frame — reset all first so stamp works
  for (let i=0;i<units.length;i++){
    const u = units[i];
    if (u.alive && u.team===team) stampFog(fog, u.x, u.z, u.sight);
  }
  for (let i=0;i<buildings.length;i++){
    const b = buildings[i];
    if (b.alive && b.team===team) stampFog(fog, b.x, b.z, b.done ? b.sight : b.sight*0.65);
  }
}
function uploadFog(){
  const d = fogP.data, pix = fogP.pix;
  for (let i=0;i<d.length;i++){
    const v = d[i];
    pix[i*4] = v===2 ? 255 : v===1 ? 90 : 0;
    pix[i*4+1] = 0; pix[i*4+2] = 0; pix[i*4+3] = 255;
  }
  gl.bindTexture(gl.TEXTURE_2D, fogTex);
  gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, FOG_N, FOG_N, gl.RGBA, gl.UNSIGNED_BYTE, pix);
}
function teamSees(team, x, z){
  const fog = team==='player' ? fogP : fogE;
  return fogVis(fog, x, z) === 2;
}

/* ---------- nav ---------- */
function rebuildStaticBlock(){
  staticBlock = new Uint8Array(GRID*GRID);
  for (let iz=0; iz<GRID; iz++){
    for (let ix=0; ix<GRID; ix++){
      const x = ix*CELL+CELL/2, z = iz*CELL+CELL/2;
      let blocked = 0;
      if (x<3||z<3||x>MAP-3||z>MAP-3) blocked = 1;
      else if (inRiftBlocked(x,z)) blocked = 1;
      else if (rockBlocked(x,z)) blocked = 1;
      else {
        const h = heightAt(x,z);
        if (Math.abs(heightAt(x+2,z)-h)>2.4 || Math.abs(heightAt(x,z+2)-h)>2.4) blocked = 1;
      }
      staticBlock[iz*GRID+ix] = blocked;
    }
  }
}
function rebuildNav(){
  if (!staticBlock) rebuildStaticBlock();
  navBlocked = new Uint8Array(staticBlock);
  for (let i=0;i<buildings.length;i++){
    const b = buildings[i];
    if (!b.alive) continue;
    const rad = b.size + 0.35;
    const x0 = clamp(Math.floor((b.x-rad)/CELL),0,GRID-1);
    const x1 = clamp(Math.floor((b.x+rad)/CELL),0,GRID-1);
    const z0 = clamp(Math.floor((b.z-rad)/CELL),0,GRID-1);
    const z1 = clamp(Math.floor((b.z+rad)/CELL),0,GRID-1);
    for (let iz=z0; iz<=z1; iz++){
      for (let ix=x0; ix<=x1; ix++){
        const cx=ix*CELL+CELL/2, cz=iz*CELL+CELL/2;
        if (Math.abs(cx-b.x) < rad && Math.abs(cz-b.z) < rad) navBlocked[iz*GRID+ix] = 1;
      }
    }
  }
}
function cellWalk(ix,iz){
  if (ix<0||iz<0||ix>=GRID||iz>=GRID) return false;
  return navBlocked[iz*GRID+ix]===0;
}
function worldWalk(x,z){
  return cellWalk(Math.floor(x/CELL), Math.floor(z/CELL));
}
function nearestWalk(x,z){
  const ix0 = Math.floor(x/CELL), iz0 = Math.floor(z/CELL);
  if (cellWalk(ix0,iz0)) return {x,z};
  for (let r=1;r<18;r++){
    for (let dz=-r; dz<=r; dz++){
      for (let dx=-r; dx<=r; dx++){
        if (Math.abs(dx)!==r && Math.abs(dz)!==r) continue;
        if (cellWalk(ix0+dx, iz0+dz)) return { x:(ix0+dx)*CELL+CELL/2, z:(iz0+dz)*CELL+CELL/2 };
      }
    }
  }
  return {x,z};
}
function losClear(x0,z0,x1,z1){
  const dx=x1-x0, dz=z1-z0;
  const steps = Math.ceil(Math.hypot(dx,dz) / (CELL*0.85));
  for (let i=1;i<=steps;i++){
    const t=i/steps;
    if (!worldWalk(x0+dx*t, z0+dz*t)) return false;
  }
  return true;
}

const pathQueue = [];
let pathNodes = 0;
function requestPath(u){
  u.needsPath = true;
  if (pathQueue.indexOf(u) < 0) pathQueue.push(u);
}
function runPaths(budget){
  let n = 0;
  while (pathQueue.length && n < budget){
    const u = pathQueue.shift();
    if (!u.alive || !u.needsPath) continue;
    const dest = u.pathDest;
    if (!dest) { u.needsPath = false; continue; }
    if (u.flying || losClear(u.x, u.z, dest.x, dest.z)){
      u.path = [dest];
      u.pathI = 0;
      u.needsPath = false;
    } else {
      const p = astar(u.x, u.z, dest.x, dest.z);
      u.path = p || [dest];
      u.pathI = 0;
      u.needsPath = false;
    }
    n++;
  }
}
function astar(x0,z0,x1,z1){
  const sx = clamp(Math.floor(x0/CELL),0,GRID-1);
  const sz = clamp(Math.floor(z0/CELL),0,GRID-1);
  const gx = clamp(Math.floor(x1/CELL),0,GRID-1);
  const gz = clamp(Math.floor(z1/CELL),0,GRID-1);
  const start = sz*GRID+sx, goal = gz*GRID+gx;
  if (start===goal) return [{x:x1,z:z1}];
  const open = [start];
  const g = new Float32Array(GRID*GRID);
  g.fill(1e9); g[start]=0;
  const prev = new Int32Array(GRID*GRID);
  prev.fill(-1);
  const inOpen = new Uint8Array(GRID*GRID);
  inOpen[start]=1;
  const heur = (i)=>{
    const ix=i%GRID, iz=(i/GRID)|0;
    return Math.abs(ix-gx)+Math.abs(iz-gz);
  };
  let guard = 0;
  while (open.length && guard++ < 1800){
    let best=0, bestF=1e9;
    for (let i=0;i<open.length;i++){
      const f = g[open[i]] + heur(open[i]);
      if (f<bestF){ bestF=f; best=i; }
    }
    const cur = open[best];
    open.splice(best,1);
    inOpen[cur]=0;
    if (cur===goal){
      const path=[];
      let c=cur;
      while (c!==-1 && c!==start){
        const ix=c%GRID, iz=(c/GRID)|0;
        path.push({x:ix*CELL+CELL/2, z:iz*CELL+CELL/2});
        c=prev[c];
      }
      path.reverse();
      path.push({x:x1,z:z1});
      // simplify
      const simp=[path[0]];
      let anchor=0;
      for (let i=2;i<path.length;i++){
        if (!losClear(path[anchor].x, path[anchor].z, path[i].x, path[i].z)){
          simp.push(path[i-1]);
          anchor=i-1;
        }
      }
      simp.push(path[path.length-1]);
      return simp;
    }
    const cx=cur%GRID, cz=(cur/GRID)|0;
    for (let dz=-1; dz<=1; dz++) for (let dx=-1; dx<=1; dx++){
      if (!dx && !dz) continue;
      const nx=cx+dx, nz=cz+dz;
      if (!cellWalk(nx,nz)) continue;
      if (dx && dz && (!cellWalk(cx+dx,cz) || !cellWalk(cx,cz+dz))) continue;
      const ni = nz*GRID+nx;
      const step = (dx&&dz)?1.414:1;
      const ng = g[cur]+step;
      if (ng < g[ni]){
        g[ni]=ng; prev[ni]=cur;
        if (!inOpen[ni]){ open.push(ni); inOpen[ni]=1; }
      }
    }
  }
  return null;
}

/* ---------- spatial hash ---------- */
const HASH = 6;
let hashMap = new Map();
function rebuildHash(){
  hashMap = new Map();
  for (let i=0;i<units.length;i++){
    const u=units[i];
    if (!u.alive) continue;
    const k = ((u.x/HASH)|0)+','+((u.z/HASH)|0);
    let a = hashMap.get(k);
    if (!a){ a=[]; hashMap.set(k,a); }
    a.push(u);
  }
}
function queryNear(x,z,rad, out){
  out.length=0;
  const r=Math.ceil(rad/HASH);
  const cx=(x/HASH)|0, cz=(z/HASH)|0;
  const rr=rad*rad;
  for (let iz=cz-r; iz<=cz+r; iz++){
    for (let ix=cx-r; ix<=cx+r; ix++){
      const a = hashMap.get(ix+','+iz);
      if (!a) continue;
      for (let i=0;i<a.length;i++){
        const u=a[i];
        if (dist2(u.x,u.z,x,z)<=rr) out.push(u);
      }
    }
  }
  return out;
}
const nearBuf = [];
const nearBuf2 = [];

/* ---------- entities ---------- */
function teamTint(team, flash){
  if (flash>0) return [1,1,1];
  return team==='player' ? [0.45, 0.92, 1] : [1, 0.38, 0.38];
}
function makeUnit(team, kind, x, z){
  const d = UDEF[kind];
  const hp = d.hp * (upgrades[team].hull ? 1.2 : 1);
  return {
    id:id(), kind, team, x, z, y:0, rot: team==='player'?0.4:-2.4,
    hp, maxHp: hp, alive:true, r:d.r, sight:d.sight, speed:d.speed,
    armor:d.armor, supply:d.supply, flying:!!d.flying, fly:d.fly||0,
    dmg:d.dmg, range:d.range, cd:d.cd, cdLeft:0, targets:d.targets, dmgType:d.dmgType,
    orders:[], path:null, pathI:0, needsPath:false, pathDest:null,
    hold:false, carry:0, gatherT:0, targetId:0, flash:0, stuck:0,
    busy:false, attackMove:null, leash:null, bob: Math.random()*6
  };
}
function makeBuilding(team, kind, x, z){
  const d = BDEF[kind];
  return {
    id:id(), kind, team, x, z, rot:0,
    hp:1, maxHp:d.hp, alive:true, size:d.size, sight:d.sight,
    supply:d.supply||0, done:false, progress:0, time:d.time,
    queue:[], rally:null, cdLeft:0, flash:0, buildPower:0,
    drop:!!d.drop, produces:d.produces||null, researches:!!d.researches, turret:!!d.turret
  };
}
function addUnit(u){ units.push(u); return u; }
function addBuilding(b){ buildings.push(b); rebuildNav(); return b; }

function countHQ(team, completedOnly){
  let n=0;
  for (let i=0;i<buildings.length;i++){
    const b=buildings[i];
    if (b.alive && b.team===team && b.kind==='spire' && (!completedOnly || b.done)) n++;
  }
  return n;
}
function supplyState(team){
  let cap=0, used=0;
  for (let i=0;i<buildings.length;i++){
    const b=buildings[i];
    if (!b.alive || b.team!==team) continue;
    if (b.done) cap += b.supply||0;
    for (let q=0;q<b.queue.length;q++) if (b.queue[q].reserved) used += b.queue[q].supply;
  }
  for (let i=0;i<units.length;i++){
    const u=units[i];
    if (u.alive && u.team===team) used += u.supply;
  }
  return { used, cap, free: cap-used };
}
function hasBuilding(team, kind, done){
  for (let i=0;i<buildings.length;i++){
    const b=buildings[i];
    if (b.alive && b.team===team && b.kind===kind && (!done || b.done)) return true;
  }
  return false;
}
function findById(arr, idv){
  for (let i=0;i<arr.length;i++) if (arr[i].id===idv) return arr[i];
  return null;
}

function spend(team, amt){
  if (resources[team] < amt) return false;
  resources[team] -= amt;
  if (team==='player') stats.spent += amt;
  return true;
}
function give(team, amt){
  resources[team] += amt;
  if (team==='player') stats.gathered += amt;
}

/* ---------- combat ---------- */
function canTarget(att, victim){
  if (!victim || !victim.alive) return false;
  if (victim.team === att.team) return false;
  const air = victim.flying || victim.kind==='interceptor';
  const t = att.targets;
  if (t==='none' || !t) return false;
  if (t==='ground' && air) return false;
  if (t==='air' && !air) return false;
  return true;
}
function damageOf(att){
  let d = att.dmg || 0;
  if (!d) return 0;
  if (att.dmgType==='kinetic' && upgrades[att.team].kinetic) d *= 1.2;
  if (att.dmgType==='energy' && upgrades[att.team].energy) d *= 1.2;
  return d;
}
function applyDamage(target, amount, dmgType, attacker){
  if (!target || !target.alive) return;
  const armor = target.armor || 'heavy';
  const mod = (MOD[dmgType] && MOD[dmgType][armor]) || 1;
  const dmg = amount * mod;
  target.hp -= dmg;
  target.flash = 0.12;
  if (target.team==='player' && (target.kind || target.size)) {
    notePlayerHurt(target);
  }
  if (target.team==='enemy') ai.lastHurt = matchT;
  spawnHit(target.x, (target.flying?target.fly:1)+heightAt(target.x,target.z), target.z, dmgType);
  if (target.hp <= 0) killEntity(target, attacker);
}
function killEntity(ent, killer){
  if (!ent.alive) return;
  ent.alive = false;
  ent.hp = 0;
  burst(ent.x, heightAt(ent.x,ent.z)+(ent.flying?ent.fly:1.2), ent.z, ent.team==='player'?[0.3,0.8,1]:[1,0.3,0.3]);
  if (ent.kind && UDEF[ent.kind]) {
    if (ent.team==='player') stats.lost++;
    else stats.kills++;
    // release nothing — supply is occupancy
  } else {
    if (ent.team==='player') stats.buildingsLost++;
    else stats.buildingsRaz++;
    rebuildNav();
    // invalidate paths roughly
    for (let i=0;i<units.length;i++) if (units[i].alive && !units[i].flying) units[i].needsPath = true;
  }
  selected.delete(ent.id);
}
function notePlayerHurt(ent){
  if (alertCD > 0) {
    underAttackPing = 1;
    return;
  }
  alertCD = 7;
  underAttackPing = 1.2;
  toast('Under attack — forces taking fire', 'bad');
  sfx('alert');
  pings.push({x:ent.x, z:ent.z, t:2.5, team:'enemy'});
}
function acquireTarget(att, chase){
  if (att.targets==='none' || !att.dmg) return null;
  const fogTeam = att.team;
  let best=null, bestD=1e9;
  const sight = att.sight || 8;
  queryNear(att.x, att.z, sight, nearBuf);
  for (let i=0;i<nearBuf.length;i++){
    const v = nearBuf[i];
    if (!canTarget(att, v)) continue;
    if (!teamSees(fogTeam, v.x, v.z)) continue;
    const d = dist2(att.x,att.z,v.x,v.z);
    if (d < bestD){ bestD=d; best=v; }
  }
  // also buildings
  const br = sight;
  for (let i=0;i<buildings.length;i++){
    const b = buildings[i];
    if (!b.alive || b.team===att.team) continue;
    if (att.targets==='air') continue;
    if (!teamSees(fogTeam, b.x, b.z)) continue;
    const d = dist2(att.x,att.z,b.x,b.z);
    if (d <= br*br && d < bestD){ bestD=d; best=b; }
  }
  return best;
}
function combatTick(att, dt){
  if (!att.alive || !att.dmg) return;
  att.cdLeft = Math.max(0, att.cdLeft-dt);
  let tgt = att.targetId ? (findById(units, att.targetId) || findById(buildings, att.targetId)) : null;
  if (tgt && (!tgt.alive || !canTarget(att, tgt) || !teamSees(att.team, tgt.x, tgt.z))) {
    tgt = null; att.targetId = 0;
  }
  const explicit = att.orders[0] && att.orders[0].kind==='attack';
  if (!tgt && !att.hold) {
    // auto acquire if idle, attack-move, or not under move-only? 
    const o = att.orders[0];
    const allow = !o || o.kind==='amove' || o.kind==='attack' || o.kind==='hold' || (att.building);
    if (allow || !o) {
      if (!o || o.kind==='amove' || o.kind==='hold' || !o.kind || att.turret) {
        tgt = acquireTarget(att);
        if (tgt) att.targetId = tgt.id;
      }
    }
  }
  if (explicit && att.orders[0].targetId) {
    const et = findById(units, att.orders[0].targetId) || findById(buildings, att.orders[0].targetId);
    if (et && et.alive && canTarget(att, et) && teamSees(att.team, et.x, et.z)) {
      tgt = et; att.targetId = et.id;
    } else if (et && et.alive && !teamSees(att.team, et.x, et.z)) {
      att.orders.shift();
      att.targetId = 0;
      tgt = null;
    } else {
      att.orders.shift();
      att.targetId = 0;
      tgt = null;
    }
  }
  if (!tgt) return;
  const d = Math.hypot(att.x-tgt.x, att.z-tgt.z);
  const range = (att.range||1) + (tgt.size?tgt.size*0.5:tgt.r||0.4);
  if (d > range + 0.15) {
    if (att.hold || att.turret) return;
    if (att.orders[0] && att.orders[0].kind==='move') return;
    // chase unless hold
    if (!explicit && att.orders[0] && att.orders[0].kind==='amove') {
      // chase within leash
    }
    if (!att.turret) {
      att.chase = {x:tgt.x, z:tgt.z};
      return;
    }
    return;
  }
  att.chase = null;
  att.rot = Math.atan2(tgt.z-att.z, tgt.x-att.x);
  if (att.cdLeft <= 0) {
    att.cdLeft = att.cd || 1;
    const dmg = damageOf(att);
    const delay = att.range > 2.2 ? clamp(d*0.03, 0.05, 0.35) : 0.02;
    shots.push({
      x:att.x, y: heightAt(att.x,att.z)+(att.flying?att.fly:1.1), z:att.z,
      tx:tgt.x, ty: heightAt(tgt.x,tgt.z)+(tgt.flying?tgt.fly:1), tz:tgt.z,
      t:0, dur:delay, dmg, dmgType:att.dmgType, targetId:tgt.id, team:att.team,
      flyingShot: !!att.flying
    });
    if (att.team==='player') sfx('shot');
  }
}

function updateShots(dt){
  for (let i=shots.length-1;i>=0;i--){
    const s = shots[i];
    s.t += dt;
    if (s.t >= s.dur) {
      const tgt = findById(units, s.targetId) || findById(buildings, s.targetId);
      if (tgt && tgt.alive) applyDamage(tgt, s.dmg, s.dmgType);
      shots.splice(i,1);
    }
  }
}

/* ---------- movement ---------- */
function speedOf(u){
  let s = u.speed;
  if (upgrades[u.team].speed) s *= 1.15;
  return s;
}
function issueOrder(list, order, append){
  for (let i=0;i<list.length;i++){
    const u = list[i];
    if (!u.alive || u.turret) continue;
    const o = Object.assign({}, order);
    if (append && u.orders.length) u.orders.push(o);
    else { u.orders = [o]; u.hold=false; u.chase=null; u.targetId=0; u.path=null; u.needsPath=false; }
    if (o.kind==='hold') { u.hold=true; u.orders=[]; u.chase=null; }
    if (o.kind==='stop') { u.orders=[]; u.hold=false; u.chase=null; u.targetId=0; u.path=null; u.attackMove=null; }
  }
}
function formationSlots(list, x, z){
  const n = list.length;
  const cols = Math.ceil(Math.sqrt(n));
  const sp = 1.85;
  list.forEach((u,i)=>{
    const c = i % cols, r = Math.floor(i/cols);
    const ox = (c - (cols-1)/2)*sp;
    const oz = (r - (Math.ceil(n/cols)-1)/2)*sp;
    u._slot = nearestWalk(x+ox, z+oz);
  });
}
function orderMove(list, x, z, kind, append){
  const slots = list.filter(u => u.alive && !u.turret && UDEF[u.kind]);
  formationSlots(slots, x, z);
  for (let i=0;i<slots.length;i++){
    const u = slots[i];
    const s = u._slot || {x,z};
    const o = { kind, x:s.x, z:s.z, cx:x, cz:z };
    if (append && u.orders.length) u.orders.push(o);
    else {
      u.orders=[o]; u.hold=false; u.chase=null; u.path=null; u.needsPath=false; u.targetId=0;
    }
  }
  pings.push({x,z,t:0.8, team:'player'});
  sfx('order');
}

function moveUnit(u, dt){
  if (!u.alive) return;
  const sp = speedOf(u);
  let tx=null, tz=null;
  if (u.chase && !u.hold) { tx=u.chase.x; tz=u.chase.z; }
  else {
    const o = u.orders[0];
    if (o && (o.kind==='move' || o.kind==='amove' || o.kind==='gather' || o.kind==='return' || o.kind==='build' || o.kind==='repair')) {
      if (o.kind==='gather') {
        const dep = findById(deposits, o.depId);
        if (dep) { tx=dep.x; tz=dep.z; }
      } else if (o.kind==='return') {
        const drop = nearestDrop(u.team, u.x, u.z);
        if (drop) { tx=drop.x; tz=drop.z; o.dropId=drop.id; }
      } else if (o.kind==='build' || o.kind==='repair') {
        const b = findById(buildings, o.bid);
        if (b && b.alive) { tx=b.x; tz=b.z; }
        else { u.orders.shift(); }
      } else { tx=o.x; tz=o.z; }
    }
  }
  if (tx==null) { u.stuck=0; return; }
  const flying = u.flying;
  let destx=tx, destz=tz;
  if (!flying && !u.chase) {
    if (!u.path || u.needsPath) {
      const pd = u.pathDest;
      if (!pd || dist2(pd.x,pd.z,tx,tz)>0.2) {
        u.pathDest = {x:tx,z:tz};
        requestPath(u);
      }
    }
    if (u.path && u.path.length) {
      const wp = u.path[Math.min(u.pathI, u.path.length-1)];
      destx = wp.x; destz = wp.z;
      if (dist2(u.x,u.z,wp.x,wp.z) < 1.1) {
        u.pathI++;
        if (u.pathI >= u.path.length) u.path=null;
      }
    }
  } else if (!flying && u.chase) {
    destx=tx; destz=tz;
  }
  let dx = destx-u.x, dz = destz-u.z;
  let d = Math.hypot(dx,dz);
  // separation
  if (!flying) {
    queryNear(u.x,u.z, 1.7, nearBuf2);
    let sx=0, sz=0;
    for (let i=0;i<nearBuf2.length;i++){
      const o = nearBuf2[i];
      if (o===u || o.flying) continue;
      const ox=u.x-o.x, oz=u.z-o.z;
      const od = Math.hypot(ox,oz)||0.001;
      const min = u.r + o.r + 0.25;
      if (od < min) {
        const p = (min-od)/min;
        sx += (ox/od)*p*1.8;
        sz += (oz/od)*p*1.8;
      }
    }
    dx += sx; dz += sz;
    d = Math.hypot(dx,dz);
  } else {
    queryNear(u.x,u.z, 2.2, nearBuf2);
    for (let i=0;i<nearBuf2.length;i++){
      const o = nearBuf2[i];
      if (o===u || !o.flying) continue;
      const ox=u.x-o.x, oz=u.z-o.z;
      const od=Math.hypot(ox,oz)||0.001;
      if (od<2) { dx += (ox/od)*0.8; dz += (oz/od)*0.8; }
    }
    d = Math.hypot(dx,dz)||0.001;
  }
  if (d > 0.08) {
    const step = Math.min(d, sp*dt);
    let nx = u.x + dx/d*step;
    let nz = u.z + dz/d*step;
    if (!flying) {
      if (!worldWalk(nx, nz)) {
        // slide
        if (worldWalk(nx, u.z)) nz = u.z;
        else if (worldWalk(u.x, nz)) nx = u.x;
        else {
          u.stuck += dt;
          if (u.stuck > 0.6) {
            u.needsPath = true;
            u.path = null;
            u.pathDest = {x:tx, z:tz};
            requestPath(u);
            const nw = nearestWalk(u.x + (Math.random()-0.5)*4, u.z+(Math.random()-0.5)*4);
            nx = u.x + (nw.x-u.x)*0.2;
            nz = u.z + (nw.z-u.z)*0.2;
            u.stuck = 0;
          }
        }
      } else u.stuck = Math.max(0, u.stuck-dt);
      // keep inside map
      nx = clamp(nx, 1.5, MAP-1.5);
      nz = clamp(nz, 1.5, MAP-1.5);
    }
    if (Math.hypot(nx-u.x, nz-u.z) > 0.001) {
      u.rot = angLerp(u.rot, Math.atan2(dz, dx), 0.2);
      u.x = nx; u.z = nz;
    }
  }
  // arrival for point orders
  const o = u.orders[0];
  if (o && !u.chase && (o.kind==='move' || o.kind==='amove')) {
    if (dist2(u.x,u.z,o.x,o.z) < 1.3) {
      if (o.kind==='move') u.orders.shift();
      else {
        // attack-move reached destination
        u.orders.shift();
      }
      u.path=null;
    }
  }
}

/* ---------- economy ---------- */
function nearestDrop(team, x, z){
  let best=null, bd=1e9;
  for (let i=0;i<buildings.length;i++){
    const b=buildings[i];
    if (!b.alive || !b.done || b.team!==team || !b.drop) continue;
    const d = dist2(x,z,b.x,b.z);
    if (d<bd){ bd=d; best=b; }
  }
  return best;
}
function nearestDeposit(x,z, team, requireKnown){
  let best=null, bd=1e9;
  for (let i=0;i<deposits.length;i++){
    const d=deposits[i];
    if (d.amount<=0) continue;
    if (requireKnown && team && !depositKnown(team, d)) continue;
    const dd = dist2(x,z,d.x,d.z);
    if (dd<bd){ bd=dd; best=d; }
  }
  return best;
}
function depositKnown(team, d){
  const fog = team==='player'?fogP:fogE;
  return fogVis(fog, d.x, d.z) >= 1;
}
function updateEconomy(u, dt){
  if (u.kind!=='prospector') return;
  const o = u.orders[0];
  if (!o) return;
  if (o.kind==='gather') {
    const dep = findById(deposits, o.depId);
    if (!dep || dep.amount<=0) {
      const alt = nearestDeposit(u.x,u.z,u.team,true);
      if (alt && alt.id!==o.depId) { o.depId=alt.id; u.path=null; u.needsPath=true; }
      else u.orders.shift();
      return;
    }
    if (!teamSees(u.team, dep.x, dep.z) && dist2(u.x,u.z,dep.x,dep.z) > 16) {
      // can still walk to known deposit
    }
    if (dist2(u.x,u.z,dep.x,dep.z) < (dep.r+1.3)*(dep.r+1.3)) {
      u.gatherT += dt;
      if (u.gatherT >= UDEF.prospector.gather) {
        u.gatherT = 0;
        const take = Math.min(UDEF.prospector.carry, dep.amount);
        dep.amount -= take;
        u.carry = take;
        if (u.team==='player') dep.playerAmt = dep.amount;
        else dep.enemyAmt = dep.amount;
        u.orders[0] = { kind:'return', resume:dep.id };
        u.path=null; u.needsPath=true;
        if (dep.amount<=0) {
          if (teamSees('player', dep.x, dep.z)) dep.playerAmt = 0;
          if (teamSees('enemy', dep.x, dep.z)) dep.enemyAmt = 0;
        }
      }
    }
  } else if (o.kind==='return') {
    const drop = findById(buildings, o.dropId) || nearestDrop(u.team, u.x, u.z);
    if (!drop) return;
    if (dist2(u.x,u.z,drop.x,drop.z) < (drop.size+1.4)*(drop.size+1.4)) {
      if (u.carry>0) {
        give(u.team, u.carry);
        if (u.team==='player') {
          floaters.push({x:drop.x, y:heightAt(drop.x,drop.z)+3, z:drop.z, t:1.1, txt:'+'+u.carry});
        }
        u.carry = 0;
        sfx('drop');
      }
      const resume = o.resume;
      const dep = resume && findById(deposits, resume);
      if (dep && dep.amount>0) {
        u.orders[0] = { kind:'gather', depId:dep.id };
      } else {
        const alt = nearestDeposit(u.x,u.z,u.team,true);
        if (alt) u.orders[0] = { kind:'gather', depId:alt.id };
        else u.orders.shift();
      }
      u.path=null; u.needsPath=true;
    }
  }
}

function updateConstruction(dt){
  for (let i=0;i<buildings.length;i++){
    const b = buildings[i];
    if (!b.alive || b.done) continue;
    let power = 0;
    for (let j=0;j<units.length;j++){
      const u = units[j];
      if (!u.alive || u.team!==b.team || u.kind!=='prospector') continue;
      const o = u.orders[0];
      if (o && o.kind==='build' && o.bid===b.id && dist2(u.x,u.z,b.x,b.z) < (b.size+2.2)*(b.size+2.2)) {
        power += 1;
        u.rot = Math.atan2(b.z-u.z, b.x-u.x);
      }
    }
    b.buildPower = power;
    if (power>0) {
      const rate = (1 + (power-1)*0.6) / b.time;
      b.progress += dt * rate;
      b.hp = Math.max(1, b.maxHp * b.progress);
      if (b.progress >= 1) {
        b.progress = 1; b.done = true; b.hp = b.maxHp;
        if (b.team==='player') {
          toast(BDEF[b.kind].name + ' complete', 'good');
          sfx('ready');
        }
        rebuildNav();
      }
    }
  }
  // repair
  for (let j=0;j<units.length;j++){
    const u = units[j];
    if (!u.alive || u.kind!=='prospector') continue;
    const o = u.orders[0];
    if (!o || o.kind!=='repair') continue;
    const b = findById(buildings, o.bid);
    if (!b || !b.alive) { u.orders.shift(); continue; }
    if (dist2(u.x,u.z,b.x,b.z) < (b.size+2.2)*(b.size+2.2)) {
      if (b.hp < b.maxHp) {
        b.hp = Math.min(b.maxHp, b.hp + dt * 28);
        u.rot = Math.atan2(b.z-u.z, b.x-u.x);
      } else u.orders.shift();
    }
  }
}

function updateProduction(dt){
  for (let i=0;i<buildings.length;i++){
    const b = buildings[i];
    if (!b.alive || !b.done || !b.queue.length) { if (b) b.blocked = false; continue; }
    const q = b.queue[0];
    if (q.research) {
      b.blocked = false;
      q.t += dt;
      if (q.t >= q.dur) {
        upgrades[b.team][q.key] = true;
        if (q.key==='hull') {
          for (let k=0;k<units.length;k++){
            const u=units[k];
            if (u.alive && u.team===b.team) { u.maxHp*=1.2; u.hp*=1.2; }
          }
        }
        if (b.team==='player') { toast(q.name+' researched', 'good'); sfx('ready'); stats.research++; }
        b.queue.shift();
      }
      continue;
    }
    if (!q.reserved) {
      const sup = supplyState(b.team);
      if (sup.free >= q.supply) q.reserved = true;
      else {
        b.blocked = true;
        if (b.team==='player') supplyBlockFlash = 0.4;
        continue;
      }
    }
    const sup = supplyState(b.team);
    if (q.t===0 && sup.used > sup.cap) {
      b.blocked = true;
      if (b.team==='player') supplyBlockFlash = 0.4;
      continue;
    }
    b.blocked = false;
    q.t += dt;
    if (q.t >= q.dur) {
      const def = UDEF[q.type];
      const ang = Math.random()*6.28;
      const spawnR = b.size + 1.4;
      let sx = b.x + Math.cos(ang)*spawnR, sz = b.z + Math.sin(ang)*spawnR;
      if (!def.flying) {
        const nw = nearestWalk(sx, sz);
        sx=nw.x; sz=nw.z;
      }
      const u = addUnit(makeUnit(b.team, q.type, sx, sz));
      if (b.team==='player') { stats.trained++; sfx('ready'); }
      if (b.rally) {
        u.orders = [{ kind:'move', x:b.rally.x, z:b.rally.z }];
        u.pathDest = {x:b.rally.x, z:b.rally.z};
        requestPath(u);
      }
      q.reserved = false;
      b.queue.shift();
    }
  }
}

function canPlace(team, kind, x, z){
  const d = BDEF[kind];
  if (!d) return false;
  if (x<6||z<6||x>MAP-6||z>MAP-6) return false;
  if (!teamSees(team, x, z)) return false;
  if (inRiftBlocked(x,z) || rockBlocked(x,z)) return false;
  if (!worldWalk(x,z) && kind) {
    // center should be walkable-ish; allow if nearest is close
    if (inRiftBlocked(x,z)) return false;
  }
  const size = d.size;
  for (let i=0;i<buildings.length;i++){
    const b = buildings[i];
    if (!b.alive) continue;
    const need = size + b.size + 0.8;
    if (Math.abs(b.x-x) < need && Math.abs(b.z-z) < need) return false;
  }
  for (let i=0;i<deposits.length;i++){
    const dep = deposits[i];
    if (dist2(dep.x,dep.z,x,z) < (dep.r+size+0.6)*(dep.r+size+0.6)) return false;
  }
  // footprint mostly walkable
  let bad=0, tot=0;
  for (let dz=-1; dz<=1; dz++) for (let dx=-1;dx<=1;dx++){
    tot++;
    const px=x+dx*size*0.6, pz=z+dz*size*0.6;
    if (inRiftBlocked(px,pz) || rockBlocked(px,pz)) bad++;
  }
  if (bad>2) return false;
  return true;
}

function tryPlace(team, kind, x, z, worker){
  if (!canPlace(team, kind, x, z)) return null;
  const def = BDEF[kind];
  if (!spend(team, def.cost)) return null;
  const b = addBuilding(makeBuilding(team, kind, x, z));
  // push units out
  for (let i=0;i<units.length;i++){
    const u=units[i];
    if (!u.alive || u.flying) continue;
    if (Math.abs(u.x-x)<b.size && Math.abs(u.z-z)<b.size) {
      const nw = nearestWalk(x + Math.sign(u.x-x||1)*(b.size+1.2), z + Math.sign(u.z-z||1)*(b.size+1.2));
      u.x=nw.x; u.z=nw.z;
    }
  }
  if (worker && worker.alive) {
    worker.orders = [{ kind:'build', bid:b.id }];
    worker.path=null; worker.needsPath=true;
  }
  if (team==='player') sfx('order');
  return b;
}

function cancelQueueItem(b, idx){
  if (!b.queue[idx]) return;
  const q = b.queue[idx];
  resources[b.team] += q.cost;
  if (b.team==='player') stats.spent -= q.cost;
  q.reserved = false;
  b.queue.splice(idx,1);
  sfx('order');
  toast('Cancelled — full refund', 'good');
}
function cancelBuilding(b){
  if (!b || b.done) return;
  const refund = Math.floor(BDEF[b.kind].cost * 0.75);
  resources[b.team] += refund;
  b.alive = false;
  rebuildNav();
  toast('Construction cancelled — 75% refund ('+refund+')', '');
  sfx('order');
}

/* ---------- AI ---------- */
function initAI(){
  ai = {
    t: 0, scout: 0, nextAttack: 100, wave: 0, lastHurt: -10,
    explore: [
      {x:100,z:100},{x:100,z:52},{x:100,z:148},{x:70,z:70},{x:50,z:90},
      {x:40,z:40},{x:60,z:30},{x:30,z:60},{x:80,z:40},{x:36,z:100},
      {x:55,z:150},{x:24,z:46},{x:44,z:26},{x:130,z:70},{x:90,z:160}
    ],
    ei: 0,
    knownBase: null,
    expand: null,
    wantedArmy: 0
  };
}
function aiThink(dt){
  ai.t += dt;
  if (ai.t < 0.45) return;
  ai.t = 0;
  const team = 'enemy';
  const sup = supplyState(team);
  const res = resources[team];
  const workers = units.filter(u=>u.alive && u.team===team && u.kind==='prospector');
  const army = units.filter(u=>u.alive && u.team===team && u.kind!=='prospector');
  const hqs = buildings.filter(b=>b.alive && b.team===team && b.kind==='spire');
  const doneHQ = hqs.filter(b=>b.done);
  if (!doneHQ.length) return;

  // discover enemy buildings
  for (let i=0;i<buildings.length;i++){
    const b=buildings[i];
    if (b.alive && b.team==='player' && teamSees('enemy', b.x, b.z)) {
      if (!ai.knownBase || b.kind==='spire') ai.knownBase = {x:b.x, z:b.z, t:matchT};
    }
  }

  // idle workers gather
  for (let i=0;i<workers.length;i++){
    const w = workers[i];
    if (!w.orders.length && w.carry<=0) {
      const dep = nearestDeposit(w.x, w.z, 'enemy', true);
      if (dep) w.orders=[{kind:'gather', depId:dep.id}];
    }
  }

  function idleWorker(){
    return workers.find(w => w.alive && (!w.orders.length || w.orders[0].kind==='gather' || w.orders[0].kind==='return')) || workers[0];
  }
  function queueTrain(kind, n){
    const prodKind = kind==='prospector' ? 'spire' : kind==='interceptor' ? 'aerie' : 'foundry';
    const halls = buildings.filter(b=>b.alive && b.done && b.team===team && b.kind===prodKind);
    for (let k=0;k<n;k++){
      let hall = halls.sort((a,b)=>a.queue.length-b.queue.length)[0];
      if (!hall || hall.queue.length>=5) return;
      const def = UDEF[kind];
      if (def.prereq && !hasBuilding(team, def.prereq, true)) return;
      if (resources[team] < def.cost) return;
      const s = supplyState(team);
      if (s.free < def.supply && hall.queue.length>0) return;
      if (s.free < def.supply && !hall.queue.length) {
        // still enqueue without reserve? pause — only if we might get supply soon
        if (s.free < def.supply) return;
      }
      resources[team] -= def.cost;
      const reserved = supplyState(team).free >= def.supply;
      // recompute after spend not supply. Check free before push
      const freeNow = supplyState(team).free;
      hall.queue.push({ type:kind, supply:def.supply, cost:def.cost, reserved: freeNow>=def.supply, t:0, dur:def.time, name:def.name });
    }
  }
  function aiPlace(kind, nearX, nearZ){
    if (resources[team] < BDEF[kind].cost) return false;
    // already constructing this kind nearby?
    for (let r=6; r<36; r+=3){
      for (let a=0;a<10;a++){
        const ang = a/10*6.28 + r;
        const x = nearX + Math.cos(ang)*r;
        const z = nearZ + Math.sin(ang)*r*0.85;
        if (canPlace(team, kind, x, z)) {
          const w = idleWorker();
          if (!w) return false;
          return !!tryPlace(team, kind, x, z, w);
        }
      }
    }
    return false;
  }

  const base = doneHQ[0];
  // workers
  const depots = buildings.filter(b=>b.alive && b.done && b.team===team && (b.drop));
  const wantW = clamp(7 + depots.length*3, 7, 22);
  if (workers.length < wantW && res > 50) queueTrain('prospector', 1);

  // supply
  if (sup.cap < 40 || sup.used >= sup.cap - 6) {
    const pylons = buildings.filter(b=>b.alive && b.team===team && b.kind==='pylon').length;
    if (pylons < 30 && res > 75) aiPlace('pylon', base.x+6, base.z-4);
  }
  if (!hasBuilding(team,'foundry',false) && res > 170) aiPlace('foundry', base.x-6, base.z+5);
  if (matchT > 70 && !hasBuilding(team,'bastion',false) && res > 110) aiPlace('bastion', base.x+8, base.z+6);
  if (matchT > 130 && !hasBuilding(team,'archives',false) && res > 150) aiPlace('archives', base.x-8, base.z-6);
  if (matchT > 150 && !hasBuilding(team,'aerie',false) && res > 200) aiPlace('aerie', base.x+4, base.z-8);
  if (matchT > 180 && hasBuilding(team,'foundry',true) && buildings.filter(b=>b.alive&&b.team===team&&b.kind==='foundry').length < 1+Math.floor(matchT/180) && res > 280) {
    aiPlace('foundry', base.x+10, base.z+2);
  }

  // research
  const arch = buildings.find(b=>b.alive && b.done && b.team===team && b.kind==='archives');
  if (arch && arch.queue.length===0 && res > 220) {
    const order = ['hull','energy','kinetic','speed'];
    for (const k of order) {
      if (!upgrades.enemy[k]) {
        const R = RESEARCH[k];
        if (res >= R.cost) {
          resources.enemy -= R.cost;
          arch.queue.push({ research:true, key:k, name:R.name, cost:R.cost, supply:0, reserved:false, t:0, dur:R.time });
        }
        break;
      }
    }
  }

  // expand
  if (matchT > 110 && workers.length >= 7) {
    const depCount = buildings.filter(b=>b.alive && b.team===team && b.kind==='depot').length;
    const wantDep = matchT > 240 ? 2 : 1;
    if (depCount < wantDep && res > 120) {
      let best=null, bd=1e9;
      for (const d of deposits) {
        if (d.amount < 400) continue;
        if (!depositKnown('enemy', d)) continue;
        // not already a dropoff nearby
        let close=false;
        for (const b of buildings) if (b.alive && b.team==='enemy' && b.drop && dist2(b.x,b.z,d.x,d.z)<18*18) close=true;
        if (close) continue;
        const dd = dist2(d.x,d.z,base.x,base.z);
        if (dd<bd && dd>20*20) { bd=dd; best=d; }
      }
      if (best) aiPlace('depot', best.x+4, best.z+3);
    }
    if (matchT > 220 && countHQ('enemy', false) < 2 && res > 420) {
      const spot = deposits.find(d => d.amount>800 && depositKnown('enemy', d) && dist2(d.x,d.z,base.x,base.z)>30*30);
      if (spot) aiPlace('spire', spot.x+6, spot.z-2);
    }
  }

  // train army
  ai.wantedArmy = clamp(6 + Math.floor(matchT / 7), 6, 420);
  if (army.length < ai.wantedArmy && hasBuilding(team,'foundry',true)) {
    const mix = matchT < 140 ? ['skirmisher','skirmisher','railguard'] :
      matchT < 240 ? ['skirmisher','railguard','railguard','siegeframe'] :
      ['skirmisher','railguard','siegeframe','interceptor','railguard'];
    const choice = mix[Math.floor(Math.random()*mix.length)];
    if (choice==='siegeframe' && !hasBuilding(team,'archives',true)) queueTrain('railguard', 1);
    else if (choice==='interceptor' && !hasBuilding(team,'aerie',true)) queueTrain('skirmisher', 1);
    else queueTrain(choice, 1);
  }

  // scout
  let scout = findById(units, ai.scout);
  if (!scout || !scout.alive) {
    scout = army[0] || workers[workers.length-1];
    if (scout) ai.scout = scout.id;
  }
  if (scout && scout.alive && (!scout.orders.length || matchT % 12 < 0.5)) {
    // don't steal if on attack wave recently
    if (!scout._attacking) {
      const wp = ai.explore[ai.ei % ai.explore.length];
      if (dist2(scout.x,scout.z,wp.x,wp.z) < 12*12 || teamSees('enemy', wp.x, wp.z)) ai.ei++;
      const nwp = ai.explore[ai.ei % ai.explore.length];
      if (!scout.orders.length) scout.orders=[{kind:'amove', x:nwp.x, z:nwp.z}];
    }
  }

  // defend
  const hurtRecently = matchT - ai.lastHurt < 8;
  if (hurtRecently && army.length) {
    let threat = null, bd=1e9;
    for (const b of buildings) {
      if (b.alive && b.team==='enemy' && b.flash>0) { threat=b; break; }
    }
    if (!threat) threat = base;
    for (const u of army) {
      if (!u._attacking) u.orders=[{kind:'amove', x:threat.x+ (Math.random()*6-3), z:threat.z+(Math.random()*6-3)}];
    }
    return;
  }

  // attack waves — not before opening
  if (matchT > ai.nextAttack && army.length >= (ai.wave===0?7:10+ai.wave*3)) {
    let tx, tz;
    if (ai.knownBase) { tx=ai.knownBase.x; tz=ai.knownBase.z; }
    else {
      const wp = ai.explore[ai.ei % ai.explore.length];
      tx=wp.x; tz=wp.z;
    }
    const send = army.filter(u => !u.orders.length || u.orders[0].kind==='amove' || u.orders[0].kind==='move');
    const portion = send.slice(0, Math.max(6, Math.floor(send.length*0.75)));
    for (const u of portion) {
      u._attacking = true;
      u.orders = [{ kind:'amove', x: tx+(Math.random()*8-4), z: tz+(Math.random()*8-4) }];
      u.path=null; u.needsPath=true; u.pathDest={x:u.orders[0].x, z:u.orders[0].z};
      requestPath(u);
    }
    ai.wave++;
    ai.nextAttack = matchT + (ai.wave < 2 ? 55 : 40);
    // clear attacking flag later naturally when orders empty
  }
  for (const u of army) if (u._attacking && !u.orders.length) u._attacking = false;
}

/* ---------- memory of enemy buildings ---------- */
function updateMemory(){
  for (let i=0;i<buildings.length;i++){
    const b = buildings[i];
    if (b.team!=='enemy') continue;
    if (teamSees('player', b.x, b.z)) {
      seenEnemy.set(b.id, {
        id:b.id, kind:b.kind, x:b.x, z:b.z, alive:b.alive, done:b.done, progress:b.progress, hp:b.hp, maxHp:b.maxHp
      });
    }
  }
  for (const [idv, mem] of seenEnemy) {
    if (teamSees('player', mem.x, mem.z)) {
      const b = findById(buildings, idv);
      if (!b || !b.alive) mem.alive = false;
      else {
        mem.hp=b.hp; mem.done=b.done; mem.progress=b.progress; mem.alive=true;
      }
    }
  }
}

/* ---------- effects ---------- */
function burst(x,y,z,col){
  for (let i=0;i<14;i++){
    particles.push({
      x,y,z,
      vx:(Math.random()-0.5)*8, vy:Math.random()*6, vz:(Math.random()-0.5)*8,
      life:0.5+Math.random()*0.4, max:0.8, col, s:0.18+Math.random()*0.3
    });
  }
}
function spawnHit(x,y,z,type){
  particles.push({
    x,y,z, vx:0, vy:0.2, vz:0, life:0.18, max:0.18,
    col: type==='energy'?[0.4,0.9,1]:[1,0.8,0.4], s:0.35
  });
}
function updateParticles(dt){
  for (let i=particles.length-1;i>=0;i--){
    const p=particles[i];
    p.life -= dt;
    p.x += p.vx*dt; p.y += p.vy*dt; p.z += p.vz*dt;
    p.vy -= 6*dt;
    if (p.life<=0) particles.splice(i,1);
  }
  for (let i=floaters.length-1;i>=0;i--){
    floaters[i].t -= dt;
    floaters[i].y += dt*0.8;
    if (floaters[i].t<=0) floaters.splice(i,1);
  }
  for (let i=pings.length-1;i>=0;i--){
    pings[i].t -= dt;
    if (pings[i].t<=0) pings.splice(i,1);
  }
}

/* ---------- world setup ---------- */
function resetMatch(){
  nextId = 1;
  units.length=0; buildings.length=0; deposits.length=0; rocks.length=0;
  shots.length=0; particles.length=0; floaters.length=0; pings.length=0;
  pathQueue.length=0;
  selected.clear();
  seenEnemy = new Map();
  resources.player = 420; resources.enemy = 420;
  upgrades = { player:{}, enemy:{} };
  groups = Array.from({length:10}, () => []);
  matchT = 0; time = 0; lost = false; endDelay = 0;
  placeType = null; attackMoveMode = false; panelKey = '';
  stats = { gathered:0, spent:0, trained:0, lost:0, kills:0, buildingsLost:0, buildingsRaz:0, research:0 };
  fogP = makeFog(); fogE = makeFog();
  rnd = mulberry(11);
  // rocks / landmarks mirrored
  const rockSeeds = [
    [62,78,3.2,4.5,false],[48,120,2.4,3,false],[78,36,2.2,2.6,false],
    [88,88,2.8,7,true],[70,158,3,3.4,false],[30,120,2.6,3,false],
    [118,78,2.2,2.4,false]
  ];
  for (const r of rockSeeds) {
    rocks.push({x:r[0], z:r[1], r:r[2], h:r[3], spire:r[4]});
    rocks.push({x:MAP-r[0], z:MAP-r[1], r:r[2], h:r[3], spire:r[4]});
  }
  const deps = [
    [44,26,2400],[24,48,2000],[36,102,2600],[70,46,1800],[52,154,2200],[82,100,2000],[100,64,1600]
  ];
  for (const d of deps) {
    deposits.push({ id:id(), x:d[0], z:d[1], amount:d[2], max:d[2], r:2.4, playerAmt:null, enemyAmt:null });
    deposits.push({ id:id(), x:MAP-d[0], z:MAP-d[1], amount:d[2], max:d[2], r:2.4, playerAmt:null, enemyAmt:null });
  }
  rebuildStaticBlock();
  navBlocked = new Uint8Array(staticBlock);

  const pHQ = addBuilding(makeBuilding('player','spire', 32, 32));
  pHQ.done=true; pHQ.progress=1; pHQ.hp=pHQ.maxHp;
  const eHQ = addBuilding(makeBuilding('enemy','spire', MAP-32, MAP-32));
  eHQ.done=true; eHQ.progress=1; eHQ.hp=eHQ.maxHp;
  rebuildNav();

  const pw = [[40,28],[36,40],[28,40],[44,36]];
  for (const p of pw) {
    const u = addUnit(makeUnit('player','prospector', p[0], p[1]));
    const dep = nearestDeposit(u.x,u.z,'player', false);
    if (dep) u.orders=[{kind:'gather', depId:dep.id}];
  }
  const ew = [[MAP-40,MAP-28],[MAP-36,MAP-40],[MAP-28,MAP-40],[MAP-44,MAP-36]];
  for (const p of ew) {
    const u = addUnit(makeUnit('enemy','prospector', p[0], p[1]));
    const dep = nearestDeposit(u.x,u.z,'enemy', false);
    if (dep) u.orders=[{kind:'gather', depId:dep.id}];
  }
  initAI();
  updateFog(fogP,'player');
  updateFog(fogE,'enemy');
  // mark starting deposits known amounts
  for (const d of deposits) {
    if (teamSees('player', d.x, d.z)) d.playerAmt = d.amount;
    if (teamSees('enemy', d.x, d.z)) d.enemyAmt = d.amount;
  }
  cam.x = 36; cam.z = 30; cam.yaw = 0.78; cam.dist = 40;
  buildTerrain();
}

/* ---------- UI helpers ---------- */
function toast(msg, kind){
  toasts.push({ msg, kind: kind||'', t: 3.4 });
  if (toasts.length>4) toasts.shift();
}
function sfx(kind){
  if (muted) return;
  if (!audioCtx) return;
  const t = audioCtx.currentTime;
  const o = audioCtx.createOscillator();
  const g = audioCtx.createGain();
  o.connect(g); g.connect(audioCtx.destination);
  g.gain.setValueAtTime(0.0001, t);
  if (kind==='order') {
    o.type='square'; o.frequency.setValueAtTime(520, t); o.frequency.exponentialRampToValueAtTime(220, t+0.08);
    g.gain.setValueAtTime(0.04, t); g.gain.exponentialRampToValueAtTime(0.0001, t+0.1);
    o.start(t); o.stop(t+0.11);
  } else if (kind==='select') {
    o.type='sine'; o.frequency.setValueAtTime(680, t);
    g.gain.setValueAtTime(0.03, t); g.gain.exponentialRampToValueAtTime(0.0001, t+0.06);
    o.start(t); o.stop(t+0.07);
  } else if (kind==='ready') {
    o.type='triangle'; o.frequency.setValueAtTime(440, t);
    o.frequency.setValueAtTime(660, t+0.08);
    g.gain.setValueAtTime(0.04, t); g.gain.exponentialRampToValueAtTime(0.0001, t+0.2);
    o.start(t); o.stop(t+0.22);
  } else if (kind==='alert') {
    o.type='sawtooth'; o.frequency.setValueAtTime(180, t);
    o.frequency.setValueAtTime(140, t+0.12);
    o.frequency.setValueAtTime(180, t+0.24);
    g.gain.setValueAtTime(0.05, t); g.gain.exponentialRampToValueAtTime(0.0001, t+0.4);
    o.start(t); o.stop(t+0.42);
  } else if (kind==='shot') {
    o.type='square'; o.frequency.setValueAtTime(180+Math.random()*80, t);
    g.gain.setValueAtTime(0.015, t); g.gain.exponentialRampToValueAtTime(0.0001, t+0.05);
    o.start(t); o.stop(t+0.06);
  } else if (kind==='drop') {
    o.type='sine'; o.frequency.setValueAtTime(320, t); o.frequency.exponentialRampToValueAtTime(520, t+0.08);
    g.gain.setValueAtTime(0.025, t); g.gain.exponentialRampToValueAtTime(0.0001, t+0.1);
    o.start(t); o.stop(t+0.11);
  } else if (kind==='bad') {
    o.type='sawtooth'; o.frequency.setValueAtTime(120, t);
    g.gain.setValueAtTime(0.04, t); g.gain.exponentialRampToValueAtTime(0.0001, t+0.2);
    o.start(t); o.stop(t+0.22);
  }
}
function ambient(dt){
  // light drone scheduled rarely
}

function selectedUnits(){
  const list=[];
  for (const idv of selected) {
    const u = findById(units, idv);
    if (u && u.alive && u.team==='player') list.push(u);
  }
  return list;
}
function selectedBuildings(){
  const list=[];
  for (const idv of selected) {
    const b = findById(buildings, idv);
    if (b && b.alive && b.team==='player') list.push(b);
  }
  return list;
}
function selectOnly(ids){
  selected.clear();
  for (const i of ids) selected.add(i);
  sfx('select');
  panelKey = '';
}

function cmdReason(cmd){
  const su = selectedUnits();
  const sb = selectedBuildings();
  const res = resources.player;
  const sup = supplyState('player');
  if (cmd.startsWith('train:')) {
    const k = cmd.slice(6);
    const def = UDEF[k];
    const b = sb[0];
    if (!b || !b.done) return 'Building not finished';
    if (def.prereq && !hasBuilding('player', def.prereq, true)) return 'Requires a completed Archive';
    if (b.queue.length>=5) return 'Queue full (5)';
    if (res < def.cost) return 'Need '+def.cost+' Aetherium (have '+Math.floor(res)+')';
    if (sup.free < def.supply) return 'Supply blocked '+sup.used+'/'+sup.cap+' — build Relay Pylons';
    return '';
  }
  if (cmd.startsWith('build:')) {
    const k = cmd.slice(6);
    const def = BDEF[k];
    if (!su.some(u=>u.kind==='prospector')) return 'Select a Prospector';
    if (res < def.cost) return 'Need '+def.cost+' Aetherium (have '+Math.floor(res)+')';
    return '';
  }
  if (cmd.startsWith('res:')) {
    const k = cmd.slice(4);
    const R = RESEARCH[k];
    const b = sb[0];
    if (!b || !b.done) return 'Archive not finished';
    if (upgrades.player[k]) return 'Already researched';
    if (b.queue.length) return 'Archive busy';
    if (res < R.cost) return 'Need '+R.cost+' Aetherium';
    return '';
  }
  if (cmd==='cancelBuild') {
    if (!sb[0] || sb[0].done) return 'Nothing to cancel';
    return '';
  }
  if (cmd==='amove' || cmd==='stop' || cmd==='hold') {
    if (!su.length) return 'Select units';
    return '';
  }
  return '';
}

function rebuildPanel(){
  const su = selectedUnits();
  const sb = selectedBuildings();
  const grid = document.getElementById('grid');
  const tips = document.getElementById('tips');
  const name = document.getElementById('selName');
  const meta = document.getElementById('selMeta');
  grid.innerHTML = '';
  tips.innerHTML = '';
  const buttons = [];
  if (!su.length && !sb.length) {
    name.textContent = 'No selection';
    meta.textContent = '';
    tips.innerHTML = '<b>Select</b> your Prospectors or Spire. Right-click crystals to gather, right-click ground to move. Build pylons before the supply cap binds you. The enemy is beyond the rift — scout both bridges.';
    document.getElementById('hpFill').style.width = '0%';
    return;
  }
  if (sb.length===1 && !su.length) {
    const b = sb[0];
    const def = BDEF[b.kind];
    name.textContent = def.name;
    meta.textContent = b.done ? (def.blurb) : ('Constructing '+Math.floor(b.progress*100)+'%');
    if (b.done && b.produces) {
      for (const k of b.produces) {
        const u = UDEF[k];
        buttons.push({ cmd:'train:'+k, label:u.name, cost:u.cost+' · '+u.supply+' sup' });
      }
    }
    if (b.done && b.researches) {
      for (const k of Object.keys(RESEARCH)) {
        const R = RESEARCH[k];
        buttons.push({ cmd:'res:'+k, label: upgrades.player[k] ? R.name+' ✓' : R.name, cost: upgrades.player[k] ? 'done' : R.cost+' ae' });
      }
    }
    if (!b.done) buttons.push({ cmd:'cancelBuild', label:'Cancel build', cost:'75% refund' });
    if (b.done && b.produces) buttons.push({ cmd:'rallyHint', label:'Set rally', cost:'Right-click ground' });
  } else if (su.length) {
    const kinds = {};
    for (const u of su) kinds[u.kind] = (kinds[u.kind]||0)+1;
    const keys = Object.keys(kinds);
    if (keys.length===1) {
      const u0 = su[0];
      name.textContent = (su.length>1?su.length+' ':'') + UDEF[u0.kind].name + (su.length>1?'s':'');
      meta.textContent = UDEF[u0.kind].role + (u0.kind==='prospector' && u0.carry? ' · carrying '+u0.carry : '');
    } else {
      name.textContent = su.length+' units';
      meta.textContent = keys.map(k=>kinds[k]+' '+UDEF[k].name).join(' · ');
    }
    buttons.push({ cmd:'amove', label:'Attack-move', cost:'A' });
    buttons.push({ cmd:'stop', label:'Stop', cost:'S' });
    buttons.push({ cmd:'hold', label:'Hold', cost:'H' });
    if (su.some(u=>u.kind==='prospector')) buttons.push({ cmd:'idle', label:'Idle workers', cost:'.' });
    if (su.some(u=>u.kind==='prospector')) {
      for (const k of BUILD_ORDER) {
        const d = BDEF[k];
        buttons.push({ cmd:'build:'+k, label:d.name, cost:d.cost+' ae' });
      }
    }
  }
  if (su.length && sb.length) {
    name.textContent = su.length+' units + '+sb.length+' buildings';
  }
  for (const b of buttons) {
    const el = document.createElement('button');
    el.dataset.cmd = b.cmd;
    el.innerHTML = b.label + '<small>'+b.cost+'</small>';
    grid.appendChild(el);
  }
  const single = (su.length+sb.length===1) ? (su[0]||sb[0]) : null;
  const hp = document.getElementById('hpFill');
  if (single) hp.style.width = clamp(single.hp/single.maxHp,0,1)*100+'%';
  else hp.style.width = '0%';
}
function refreshPanelDynamic(){
  const su = selectedUnits();
  const sb = selectedBuildings();
  const meta = document.getElementById('selMeta');
  const hp = document.getElementById('hpFill');
  if (sb.length===1 && !su.length) {
    const b = sb[0];
    if (!b.done) meta.textContent = 'Constructing '+Math.floor(b.progress*100)+'% · '+b.buildPower+' workers';
    hp.style.width = clamp(b.hp/b.maxHp,0,1)*100+'%';
    const qel = document.getElementById('queue');
    qel.innerHTML = '';
    b.queue.forEach((q,idx)=>{
      const d = document.createElement('div');
      d.className='qitem';
      d.innerHTML = '<i style="width:'+Math.floor((q.t/q.dur)*100)+'%"></i>'+(q.name||q.type);
      d.title = 'Click to cancel (100% refund)';
      d.onclick = (ev)=>{ ev.stopPropagation(); cancelQueueItem(b, idx); panelKey=''; };
      qel.appendChild(d);
    });
    if (b.blocked) document.getElementById('why').textContent = 'Production paused — supply blocked. Build Relay Pylons. Reservations hold your place.';
  } else {
    document.getElementById('queue').innerHTML = '';
    if (su.length===1) {
      const u=su[0];
      meta.textContent = UDEF[u.kind].role + ' · HP '+Math.ceil(u.hp)+'/'+Math.ceil(u.maxHp) + (u.carry?' · carry '+u.carry:'');
      hp.style.width = clamp(u.hp/u.maxHp,0,1)*100+'%';
    }
  }
  const buttons = document.querySelectorAll('#grid button');
  let shown = '';
  buttons.forEach(btn=>{
    const why = cmdReason(btn.dataset.cmd);
    btn.classList.toggle('disabled', !!why && btn.dataset.cmd!=='rallyHint');
    btn.dataset.why = why || '';
    if (btn.dataset.cmd===placeTypeCmd()) btn.classList.add('on'); else btn.classList.remove('on');
    if (why && btn.matches(':hover')) shown = why;
  });
  if (!sb.length || !sb[0] || !sb[0].blocked) {
    if (shown) document.getElementById('why').textContent = shown;
    else if (!attackMoveMode && !placeType) document.getElementById('why').textContent = whyText || '';
  }
  if (attackMoveMode) document.getElementById('why').textContent = 'Attack-move: left-click a destination. Units fight anything they sight, then resume.';
  if (placeType) document.getElementById('why').textContent = 'Placing '+BDEF[placeType].name+' — left-click to build, right-click or Esc to cancel. Must be in current vision.';
}
function placeTypeCmd(){ return placeType ? 'build:'+placeType : ''; }

function updateTopbar(){
  document.getElementById('resNum').textContent = Math.floor(resources.player);
  const sup = supplyState('player');
  const el = document.getElementById('supNum');
  el.textContent = sup.used+'/'+sup.cap;
  el.parentElement.classList.toggle('warn', sup.free <= 1 || supplyBlockFlash>0);
  let army=0;
  for (const u of units) if (u.alive && u.team==='player' && u.kind!=='prospector') army++;
  document.getElementById('armyNum').textContent = army;
  const m = Math.floor(matchT/60), s = Math.floor(matchT%60);
  document.getElementById('clock').textContent = String(m).padStart(2,'0')+':'+String(s).padStart(2,'0');
}

function handleCmd(cmd){
  const why = cmdReason(cmd);
  if (why && cmd!=='rallyHint') { toast(why, 'bad'); sfx('bad'); document.getElementById('why').textContent = why; return; }
  if (cmd==='amove') { attackMoveMode = true; placeType=null; return; }
  if (cmd==='stop') { issueOrder(selectedUnits(), {kind:'stop'}, false); sfx('order'); return; }
  if (cmd==='hold') { issueOrder(selectedUnits(), {kind:'hold'}, false); sfx('order'); toast('Hold position', ''); return; }
  if (cmd==='idle') { selectIdle(); return; }
  if (cmd.startsWith('build:')) {
    placeType = cmd.slice(6);
    attackMoveMode = false;
    return;
  }
  if (cmd.startsWith('train:')) {
    const k = cmd.slice(6);
    const b = selectedBuildings()[0];
    const def = UDEF[k];
    if (!spend('player', def.cost)) return;
    const free = supplyState('player').free;
    b.queue.push({ type:k, supply:def.supply, cost:def.cost, reserved: free>=def.supply, t:0, dur:def.time, name:def.name });
    sfx('order');
    panelKey='';
    return;
  }
  if (cmd.startsWith('res:')) {
    const k = cmd.slice(4);
    const b = selectedBuildings()[0];
    const R = RESEARCH[k];
    if (!spend('player', R.cost)) return;
    b.queue.push({ research:true, key:k, name:R.name, cost:R.cost, supply:0, reserved:false, t:0, dur:R.time });
    sfx('order');
    toast('Research started — '+R.blurb, '');
    return;
  }
  if (cmd==='cancelBuild') { cancelBuilding(selectedBuildings()[0]); selected.clear(); panelKey=''; return; }
  if (cmd==='rallyHint') toast('Right-click the ground to set this building\'s rally point', '');
}

function selectIdle(){
  const ids=[];
  for (const u of units) {
    if (u.alive && u.team==='player' && u.kind==='prospector' && !u.orders.length && !u.carry) ids.push(u.id);
  }
  if (!ids.length) { toast('No idle Prospectors', ''); return; }
  selectOnly(ids);
  const u = findById(units, ids[0]);
  cam.x = u.x; cam.z = u.z;
}

function showHelp(from){
  helpReturn = from;
  document.getElementById('help').classList.remove('hidden');
}
function hideHelp(){
  document.getElementById('help').classList.add('hidden');
}

function endMatch(win){
  mode = win ? 'victory' : 'defeat';
  document.getElementById('endcard').classList.remove('hidden');
  const t = document.getElementById('endTitle');
  t.textContent = win ? 'VICTORY' : 'DEFEAT';
  t.className = win ? 'win' : 'lose';
  document.getElementById('endBlurb').textContent = win
    ? 'The Crimson Synod\'s last Command Spire is ash. Vesper Reach is yours — for now.'
    : 'Your last Command Spire has fallen. The expedition is lost.';
  const m = Math.floor(matchT/60), s = Math.floor(matchT%60);
  document.getElementById('endStats').innerHTML =
    '<div>Time</div><div>'+String(m).padStart(2,'0')+':'+String(s).padStart(2,'0')+'</div>'+
    '<div>Aetherium gathered</div><div>'+Math.floor(stats.gathered)+'</div>'+
    '<div>Aetherium spent</div><div>'+Math.floor(stats.spent)+'</div>'+
    '<div>Units trained</div><div>'+stats.trained+'</div>'+
    '<div>Units lost</div><div>'+stats.lost+'</div>'+
    '<div>Enemy units destroyed</div><div>'+stats.kills+'</div>'+
    '<div>Enemy structures razed</div><div>'+stats.buildingsRaz+'</div>'+
    '<div>Research completed</div><div>'+stats.research+'</div>';
  sfx(win?'ready':'alert');
}

function startMatch(){
  if (!audioCtx) {
    const AC = window.AudioContext || window.webkitAudioContext;
    if (AC) audioCtx = new AC();
  }
  if (audioCtx && audioCtx.state==='suspended') audioCtx.resume();
  resetMatch();
  mode = 'play';
  document.getElementById('title').classList.add('hidden');
  document.getElementById('hud').classList.remove('hidden');
  document.getElementById('endcard').classList.add('hidden');
  document.getElementById('pause').classList.add('hidden');
  toast('Prospectors are harvesting. Build supply, then a Foundry. Scout the rift bridges.', '');
  setTimeout(()=>{ if (mode==='play') toast('Relay Pylons raise supply. The cap is not fixed — expand it.', ''); }, 22000);
}
function toTitle(){
  mode = 'menu';
  document.getElementById('title').classList.remove('hidden');
  document.getElementById('hud').classList.add('hidden');
  document.getElementById('endcard').classList.add('hidden');
  document.getElementById('pause').classList.add('hidden');
  document.getElementById('help').classList.add('hidden');
  if (!buildings.length) resetMatch();
}

/* ---------- input ---------- */
function isUI(t){
  if (!t || !t.closest) return false;
  return !!t.closest('#topbar, #cmd, #minimapWrap, #help, #pause, #endcard, #title, #toasts');
}
function pickEntity(x,z, teamOnly, enemies){
  let best=null, bd=1.1*1.1;
  for (let i=0;i<units.length;i++){
    const u=units[i];
    if (!u.alive) continue;
    if (teamOnly && u.team!=='player') continue;
    if (enemies && u.team==='player') continue;
    if (u.team==='enemy' && !teamSees('player', u.x, u.z)) continue;
    const rr = (u.r+0.45);
    const d = dist2(x,z,u.x,u.z);
    if (d < rr*rr && d<bd) { bd=d; best={type:'u', e:u}; }
  }
  if (best && !enemies) return best;
  for (let i=0;i<buildings.length;i++){
    const b=buildings[i];
    if (!b.alive) continue;
    if (b.team==='enemy' && !teamSees('player', b.x, b.z)) continue;
    if (teamOnly && b.team!=='player') continue;
    if (enemies && b.team!=='enemy') continue;
    if (Math.abs(b.x-x)<b.size && Math.abs(b.z-z)<b.size) return {type:'b', e:b};
  }
  if (!enemies) {
    for (const d of deposits) {
      if (d.amount<=0 && !(d.playerAmt>0)) continue;
      if (fogVis(fogP, d.x, d.z) < 1) continue;
      if (dist2(x,z,d.x,d.z) < (d.r+0.8)*(d.r+0.8)) return {type:'d', e:d};
    }
  }
  return best;
}

document.getElementById('grid').addEventListener('click', (ev)=>{
  const btn = ev.target.closest('button');
  if (!btn) return;
  handleCmd(btn.dataset.cmd);
});
document.getElementById('startBtn').onclick = ()=> startMatch();
document.getElementById('titleHelp').onclick = ()=> showHelp('title');
document.getElementById('btnHelp').onclick = ()=> showHelp('play');
document.getElementById('helpClose').onclick = ()=> hideHelp();
document.getElementById('btnPause').onclick = ()=>{
  if (mode==='play') { mode='paused'; document.getElementById('pause').classList.remove('hidden'); }
};
document.getElementById('resumeBtn').onclick = ()=>{
  mode='play'; document.getElementById('pause').classList.add('hidden');
};
document.getElementById('pauseHelp').onclick = ()=> showHelp('pause');
document.getElementById('pauseRestart').onclick = ()=> startMatch();
document.getElementById('pauseTitle').onclick = ()=> toTitle();
document.getElementById('endRestart').onclick = ()=> startMatch();
document.getElementById('endTitleBtn').onclick = ()=> toTitle();
document.getElementById('btnMute').onclick = (e)=>{
  muted = !muted;
  e.target.textContent = muted ? 'Muted' : 'Sound';
  if (!muted && audioCtx && audioCtx.state==='suspended') audioCtx.resume();
};

window.addEventListener('keydown', (ev)=>{
  if (ev.target && ev.target.tagName==='INPUT') return;
  keys[ev.key.toLowerCase()] = true;
  keys[ev.code] = true;
  if (ev.key==='Escape') {
    if (!document.getElementById('help').classList.contains('hidden')) { hideHelp(); return; }
    if (placeType || attackMoveMode) { placeType=null; attackMoveMode=false; return; }
    if (mode==='play') { mode='paused'; document.getElementById('pause').classList.remove('hidden'); }
    else if (mode==='paused') { mode='play'; document.getElementById('pause').classList.add('hidden'); }
  }
  if (mode!=='play') return;
  const k = ev.key.toLowerCase();
  if (k==='a' && !ev.ctrlKey && !ev.metaKey) { attackMoveMode=true; placeType=null; }
  if (k==='s' && !ev.ctrlKey) { issueOrder(selectedUnits(), {kind:'stop'}, false); sfx('order'); }
  if (k==='h') { issueOrder(selectedUnits(), {kind:'hold'}, false); sfx('order'); }
  if (k==='.' || k==='i') selectIdle();
  if (k===' ') {
    ev.preventDefault();
    const su = selectedUnits(); const sb = selectedBuildings();
    const e = su[0] || sb[0];
    if (e) { cam.x=e.x; cam.z=e.z; }
    else {
      const hq = buildings.find(b=>b.alive && b.team==='player' && b.kind==='spire');
      if (hq) { cam.x=hq.x; cam.z=hq.z; }
    }
  }
  if (ev.key>='1' && ev.key<='9') {
    const n = +ev.key;
    if (ev.ctrlKey || ev.metaKey) {
      ev.preventDefault();
      groups[n] = [...selected];
      toast('Group '+n+' assigned ('+groups[n].length+')', '');
    } else {
      const g = groups[n].filter(idv => {
        const u = findById(units,idv); const b=findById(buildings,idv);
        return (u&&u.alive)||(b&&b.alive);
      });
      groups[n]=g;
      if (!g.length) return;
      selectOnly(g);
      const now = performance.now();
      if (now - groupTap[n] < 350) {
        const u = findById(units, g[0]) || findById(buildings, g[0]);
        if (u) { cam.x=u.x; cam.z=u.z; }
      }
      groupTap[n]=now;
    }
  }
  if (k==='f1') { ev.preventDefault(); showHelp('play'); }
});
window.addEventListener('keyup', (ev)=>{
  keys[ev.key.toLowerCase()] = false;
  keys[ev.code] = false;
});

window.addEventListener('mousemove', (ev)=>{
  pointer.x = ev.clientX; pointer.y = ev.clientY;
  pointer.overUI = isUI(ev.target);
  if (camDrag) {
    const dx = ev.clientX - camDrag.x, dy = ev.clientY - camDrag.y;
    cam.yaw = camDrag.yaw - dx*0.005;
    camDrag.x = ev.clientX; camDrag.y = ev.clientY; camDrag.yaw = cam.yaw;
  }
  if (drag) {
    drag.x1 = ev.clientX; drag.y1 = ev.clientY;
  }
});
window.addEventListener('mousedown', (ev)=>{
  if (ev.button===1) {
    camDrag = { x:ev.clientX, y:ev.clientY, yaw:cam.yaw };
    ev.preventDefault();
    return;
  }
  if (mode!=='play') return;
  if (isUI(ev.target)) return;
  if (ev.button===0) {
    drag = { x0:ev.clientX, y0:ev.clientY, x1:ev.clientX, y1:ev.clientY };
  }
});
window.addEventListener('mouseup', (ev)=>{
  if (ev.button===1) camDrag=null;
  if (mode!=='play') return;
  if (ev.button===0 && drag) {
    const dx = drag.x1-drag.x0, dy=drag.y1-drag.y0;
    if (Math.hypot(dx,dy) > 6) {
      boxSelect(drag, ev.shiftKey);
    } else if (!isUI(ev.target)) {
      leftClick(ev.clientX, ev.clientY, ev.shiftKey);
    }
    drag=null;
  }
});
window.addEventListener('contextmenu', (ev)=>{
  if (!isUI(ev.target)) ev.preventDefault();
  if (mode!=='play' || isUI(ev.target)) return;
  rightClick(ev.clientX, ev.clientY, ev.shiftKey);
});
window.addEventListener('wheel', (ev)=>{
  if (isUI(ev.target)) return;
  cam.dist = clamp(cam.dist * (ev.deltaY>0?1.08:0.92), 16, 100);
}, { passive:true });

mm.addEventListener('mousedown', (ev)=>{
  ev.stopPropagation();
  const r = mm.getBoundingClientRect();
  const x = (ev.clientX-r.left)/r.width * MAP;
  const z = (ev.clientY-r.top)/r.height * MAP;
  if (ev.button===2) {
    ev.preventDefault();
    if (mode!=='play') return;
    const su = selectedUnits();
    if (su.length) {
      if (attackMoveMode) orderMove(su, x, z, 'amove', ev.shiftKey);
      else orderMove(su, x, z, 'move', ev.shiftKey);
    }
  } else {
    cam.x = clamp(x, 4, MAP-4);
    cam.z = clamp(z, 4, MAP-4);
  }
});
mm.addEventListener('contextmenu', ev=> ev.preventDefault());

function leftClick(sx,sy,shift){
  updateCameraMatrices();
  const ray = screenRay(sx,sy);
  const hit = rayGround(ray);
  if (!hit) return;
  if (placeType) {
    const workers = selectedUnits().filter(u=>u.kind==='prospector');
    const w = workers.sort((a,b)=>dist2(a.x,a.z,hit.x,hit.z)-dist2(b.x,b.z,hit.x,hit.z))[0];
    if (!w) { toast('Select a Prospector to build', 'bad'); return; }
    if (!canPlace('player', placeType, hit.x, hit.z)) {
      let msg = 'Cannot build there';
      if (!teamSees('player', hit.x, hit.z)) msg = 'Must place in current vision';
      else if (resources.player < BDEF[placeType].cost) msg = 'Not enough Aetherium';
      toast(msg, 'bad'); sfx('bad'); return;
    }
    const b = tryPlace('player', placeType, hit.x, hit.z, w);
    if (b) { toast(BDEF[placeType].name+' started', ''); placeType = shift ? placeType : null; }
    return;
  }
  if (attackMoveMode) {
    const su = selectedUnits();
    if (su.length) orderMove(su, hit.x, hit.z, 'amove', shift);
    attackMoveMode = false;
    return;
  }
  const pick = pickEntity(hit.x, hit.z, false, false);
  if (pick && (pick.type==='u' || pick.type==='b') && pick.e.team==='player') {
    if (shift) {
      if (selected.has(pick.e.id)) selected.delete(pick.e.id);
      else selected.add(pick.e.id);
      panelKey='';
      sfx('select');
    } else selectOnly([pick.e.id]);
    // double click handled via time
    const now = performance.now();
    if (pick.type==='u' && leftClick.last && now-leftClick.last<280 && leftClick.id===pick.e.id) {
      const ids=[];
      for (const u of units) {
        if (!u.alive || u.team!=='player' || u.kind!==pick.e.kind) continue;
        const sc = worldToScreen(u.x, heightAt(u.x,u.z)+1, u.z);
        if (sc && sc.x>=0 && sc.y>=0 && sc.x<gl.canvas.width && sc.y<gl.canvas.height) ids.push(u.id);
      }
      selectOnly(ids);
    }
    leftClick.last = now; leftClick.id = pick.e.id;
  } else if (!shift) {
    selected.clear(); panelKey='';
  }
}
function boxSelect(d, shift){
  const x0=Math.min(d.x0,d.x1), x1=Math.max(d.x0,d.x1);
  const y0=Math.min(d.y0,d.y1), y1=Math.max(d.y0,d.y1);
  if (!shift) selected.clear();
  let any=false;
  for (const u of units) {
    if (!u.alive || u.team!=='player') continue;
    const sc = worldToScreen(u.x, heightAt(u.x,u.z)+(u.flying?u.fly:1), u.z);
    if (!sc) continue;
    if (sc.x>=x0 && sc.x<=x1 && sc.y>=y0 && sc.y<=y1) { selected.add(u.id); any=true; }
  }
  if (!any) {
    for (const b of buildings) {
      if (!b.alive || b.team!=='player') continue;
      const sc = worldToScreen(b.x, heightAt(b.x,b.z)+2, b.z);
      if (sc && sc.x>=x0 && sc.x<=x1 && sc.y>=y0 && sc.y<=y1) selected.add(b.id);
    }
  }
  panelKey='';
  sfx('select');
}
function rightClick(sx,sy,shift){
  if (placeType || attackMoveMode) { placeType=null; attackMoveMode=false; return; }
  updateCameraMatrices();
  const hit = rayGround(screenRay(sx,sy));
  if (!hit) return;
  const su = selectedUnits();
  const sb = selectedBuildings();
  if (!su.length && sb.length) {
    // rally
    for (const b of sb) if (b.produces) b.rally = {x:hit.x, z:hit.z};
    pings.push({x:hit.x,z:hit.z,t:1, team:'player'});
    toast('Rally set', '');
    sfx('order');
    return;
  }
  if (!su.length) return;
  const enemy = pickEntity(hit.x, hit.z, false, true);
  if (enemy) {
    for (const u of su) {
      const o = { kind:'attack', targetId: enemy.e.id, x:enemy.e.x, z:enemy.e.z };
      if (shift && u.orders.length) u.orders.push(o);
      else { u.orders=[o]; u.hold=false; u.targetId=enemy.e.id; }
    }
    sfx('order');
    return;
  }
  const workers = su.filter(u=>u.kind==='prospector');
  const dep = pickEntity(hit.x, hit.z, false, false);
  if (dep && dep.type==='d' && workers.length) {
    for (const u of workers) {
      const o = { kind:'gather', depId: dep.e.id };
      if (shift && u.orders.length) u.orders.push(o);
      else { u.orders=[o]; u.path=null; u.needsPath=true; }
    }
    sfx('order');
    return;
  }
  if (dep && dep.type==='b' && dep.e.team==='player' && workers.length) {
    if (!dep.e.done) {
      for (const u of workers) u.orders=[{kind:'build', bid:dep.e.id}];
      sfx('order'); return;
    }
    if (dep.e.hp < dep.e.maxHp) {
      for (const u of workers) u.orders=[{kind:'repair', bid:dep.e.id}];
      sfx('order'); return;
    }
    if (dep.e.drop) {
      for (const u of workers) if (u.carry>0) u.orders=[{kind:'return', dropId:dep.e.id}];
      sfx('order'); return;
    }
  }
  orderMove(su, hit.x, hit.z, 'move', shift);
}

/* ---------- simulation ---------- */
let tutorial2 = false, tutorial3 = false;
function simulate(dt){
  matchT += dt;
  alertCD = Math.max(0, alertCD-dt);
  supplyBlockFlash = Math.max(0, supplyBlockFlash-dt);
  if (!tutorial2 && matchT>28) { tutorial2=true; toast('Fog hides the Synod. Send a Prospector through a rift bridge.', ''); }
  if (!tutorial3 && matchT>55) { tutorial3=true; toast('Foundry trains Skirmishers and Railguards. Archives unlock Siegeframes and upgrades.', ''); }
  updateFog(fogP,'player');
  updateFog(fogE,'enemy');
  for (const d of deposits) {
    if (teamSees('player', d.x, d.z)) d.playerAmt = d.amount;
    if (teamSees('enemy', d.x, d.z)) d.enemyAmt = d.amount;
  }
  updateMemory();
  rebuildHash();
  runPaths(8);

  for (let i=0;i<units.length;i++){
    const u = units[i];
    if (!u.alive) continue;
    u.flash = Math.max(0, u.flash-dt);
    // attack-move acquire
    const o = u.orders[0];
    if (o && o.kind==='amove' && !u.chase) {
      const tgt = acquireTarget(u);
      if (tgt) { u.targetId = tgt.id; u.chase = {x:tgt.x, z:tgt.z}; }
    }
    if (u.chase) {
      const tgt = u.targetId ? (findById(units, u.targetId)||findById(buildings, u.targetId)) : null;
      if (!tgt || !tgt.alive || !teamSees(u.team, tgt.x, tgt.z)) { u.chase=null; u.targetId=0; }
      else {
        const rr = (u.range||1)+(tgt.size?tgt.size*0.4:0.5);
        if (dist2(u.x,u.z,tgt.x,tgt.z) <= rr*rr) u.chase=null;
        else { u.chase.x=tgt.x; u.chase.z=tgt.z; }
      }
    }
    if ((!o || o.kind==='hold') && !u.hold && u.kind!=='prospector' && !u.orders.length) {
      const tgt = acquireTarget(u);
      if (tgt) u.targetId = tgt.id;
    }
    if (u.hold) {
      const tgt = acquireTarget(u);
      if (tgt && Math.hypot(u.x-tgt.x, u.z-tgt.z) <= (u.range||1)+1) u.targetId = tgt.id;
    }
    combatTick(u, dt);
    moveUnit(u, dt);
    updateEconomy(u, dt);
    // clear chase if attack move and target dead handled
    if (u.orders[0] && u.orders[0].kind==='attack' && !u.targetId) {
      /* combatTick may shift */
    }
  }
  for (let i=0;i<buildings.length;i++){
    const b = buildings[i];
    if (!b.alive) continue;
    b.flash = Math.max(0, b.flash-dt);
    if (b.turret && b.done) {
      b.team = b.team;
      b.dmg = BDEF.bastion.dmg;
      b.range = BDEF.bastion.range;
      b.cd = BDEF.bastion.cd;
      b.targets = 'both';
      b.dmgType = 'energy';
      b.sight = b.sight;
      b.flying = false;
      b.turret = true;
      combatTick(b, dt);
    }
  }
  updateConstruction(dt);
  updateProduction(dt);
  updateShots(dt);
  updateParticles(dt);
  aiThink(dt);

  // win / lose
  if (!lost) {
    if (countHQ('player', true)===0) { lost=true; endDelay=1.3; }
    else if (countHQ('enemy', true)===0) { lost=true; endDelay=1.3; stats._win=true; }
  } else {
    endDelay -= dt;
    if (endDelay<=0) endMatch(!!stats._win);
  }

  // compact dead occasionally
  if (Math.random()<0.02) {
    if (units.length>400) units = units.filter(u=>u.alive);
    if (buildings.length>80) buildings = buildings.filter(b=>b.alive);
  }
}

/* ---------- camera control ---------- */
function updateCam(dt){
  let sp = 28 * (cam.dist/40) * dt;
  if (keys['shift']) sp *= 2;
  let mx=0, mz=0;
  if (keys['w'] || keys['arrowup']) mz -= 1;
  if (keys['s'] || keys['arrowdown']) mz += 1;
  if (keys['a'] && !attackMoveMode || keys['arrowleft']) mx -= 1;
  if (keys['d'] || keys['arrowright']) mx += 1;
  // A is attack-move; don't pan with A. Use arrow or other.
  if (keys['a'] && keys['arrowleft']) {}
  // fix: pan with A only if not just tapped — skip A for pan to keep attack-move. Use QE rotate and WASD but A conflicts.
  // Re-read: user expects WASD pan AND A attack-move. Common compromise: A pans only when no selection command... 
  // I'll pan with A if no attackMove just pressed. Conflict remains. Use classic: WASD pan, and A is attack if selection exists else pan.
  if (keys['a'] && !selectedUnits().length) mx -= 1;
  const yaw = cam.yaw;
  // screen-relative pan: forward is -camera horizontal look? 
  // eye is at cos(yaw), sin(yaw) from target, so forward (toward screen up) is -cos(yaw), -sin(yaw) roughly if looking from eye to target.
  const fx = -Math.cos(yaw), fz = -Math.sin(yaw);
  const rx = -fz, rz = fx;
  if (mx || mz) {
    const l = Math.hypot(mx,mz)||1;
    cam.x += (rx*mx + fx*mz)/l * sp;
    cam.z += (rz*mx + fz*mz)/l * sp;
  }
  if (keys['q']) cam.yaw -= dt*0.8;
  if (keys['e']) cam.yaw += dt*0.8;
  if (!pointer.overUI && mode!=='menu') {
    const m = 8;
    if (pointer.x < m) cam.x += (-rx)*sp;
    if (pointer.x > innerWidth-m) cam.x += rx*sp;
    if (pointer.y < m) { cam.x += fx*sp; cam.z += fz*sp; }
    if (pointer.y > innerHeight-m) { cam.x += -fx*sp; cam.z += -fz*sp; }
  }
  cam.x = clamp(cam.x, 2, MAP-2);
  cam.z = clamp(cam.z, 2, MAP-2);
  if (mode==='menu') {
    cam.yaw += dt*0.08;
    cam.x = 36 + Math.sin(time*0.15)*6;
    cam.z = 32 + Math.cos(time*0.12)*4;
    cam.dist = 44;
  }
}

/* ---------- render ---------- */
let dynF = null;
function renderWorld(){
  updateCameraMatrices();
  gl.useProgram(prog);
  gl.uniformMatrix4fv(uVP, false, vp);
  gl.uniform3f(uLight, 0.45, 0.82, 0.28);
  gl.uniform2f(uMap, MAP, MAP);
  gl.uniform1f(uTime, time);
  gl.uniform3f(uCam, eye[0], eye[1], eye[2]);
  gl.uniform1i(uFog, 0);
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, fogTex);

  gl.clearColor(0.03, 0.045, 0.07, 1);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

  drawMesh(terrainMesh, 1);
  if (ornamentMesh) drawMesh(ornamentMesh, 1);

  dynClear();
  // deposits if explored
  for (const d of deposits) {
    const vis = fogVis(fogP, d.x, d.z);
    if (vis < 1) continue;
    const amt = d.playerAmt==null ? d.amount : d.playerAmt;
    if (amt<=0) continue;
    const y = heightAt(d.x, d.z);
    const crystals = 4;
    for (let i=0;i<crystals;i++){
      const a = i/crystals*6.28 + 0.4;
      const rad = 0.8 + (i%2)*0.45;
      emitRaw(templates.crystal, d.x+Math.cos(a)*rad, y, d.z+Math.sin(a)*rad, a, 0.7+ (i%3)*0.25, [0.5,1,0.9], 1);
    }
  }
  // buildings
  for (const b of buildings) {
    if (!b.alive) continue;
    if (b.team==='enemy' && !teamSees('player', b.x, b.z)) continue;
    drawBuilding(b, 1, false);
  }
  // ghosts
  for (const mem of seenEnemy.values()) {
    if (!mem.alive) continue;
    if (teamSees('player', mem.x, mem.z)) continue;
    if (fogVis(fogP, mem.x, mem.z) < 1) continue;
    const fake = { kind:mem.kind, x:mem.x, z:mem.z, team:'enemy', done:mem.done, progress:mem.progress, hp:mem.hp, maxHp:mem.maxHp, flash:0, rot:0 };
    drawBuilding(fake, 0.55, true);
  }
  // units
  for (const u of units) {
    if (!u.alive) continue;
    if (u.team==='enemy' && !teamSees('player', u.x, u.z)) continue;
    const y = heightAt(u.x,u.z) + (u.flying ? u.fly + Math.sin(time*3+u.bob)*0.25 : 0);
    const bob = u.flying ? 0 : Math.sin(time*8+u.bob)*0.06*(u.orders.length?1:0);
    const tint = teamTint(u.team, u.flash);
    const dim = u.hp/u.maxHp < 0.35 ? 0.75 : 1;
    emitRaw(templates[u.kind], u.x, y+bob, u.z, u.rot, u.kind==='siegeframe'?1.05:1, tint, dim);
    if (u.team==='enemy') {
      // angular chevron
      emitTri([u.x, y+2.2, u.z],[u.x+0.28, y+1.7, u.z],[u.x-0.28, y+1.7, u.z], [1,0.3,0.3], 0.8);
    }
    if (u.carry>0) {
      emitRaw(templates.crystal, u.x, y+1.3, u.z, time, 0.28, [0.5,1,0.9], 1);
    }
    if (selected.has(u.id)) {
      emitRing(u.x, y+0.08, u.z, u.r+0.45, tint, 0.9);
    }
  }
  // placement ghost
  if (placeType && mouseWorld.ok && mode==='play') {
    const ok = canPlace('player', placeType, mouseWorld.x, mouseWorld.z) && resources.player>=BDEF[placeType].cost;
    const fake = { kind:placeType, x:mouseWorld.x, z:mouseWorld.z, team:'player', done:true, progress:1, hp:1, maxHp:1, flash:0 };
    drawBuilding(fake, 1, false);
    emitRing(mouseWorld.x, heightAt(mouseWorld.x,mouseWorld.z)+0.15, mouseWorld.z, BDEF[placeType].size, ok?[0.3,1,0.5]:[1,0.25,0.25], 1);
  }
  // pings
  for (const p of pings) {
    if (fogVis(fogP, p.x, p.z) < 1 && p.team==='player') {
      emitRing(p.x, heightAt(p.x,p.z)+0.2, p.z, 0.6+ (1-p.t)*2, [0.4,1,0.8], 0.8);
    } else if (fogVis(fogP, p.x, p.z)>=1 || p.team==='player') {
      emitRing(p.x, heightAt(p.x,p.z)+0.2, p.z, 0.5+(1-Math.min(p.t,1))*1.5, [0.5,1,0.9], 0.9);
    }
  }
  // shots
  for (const s of shots) {
    const k = clamp(s.t/s.dur,0,1);
    const x = s.x+(s.tx-s.x)*k, y=s.y+(s.ty-s.y)*k, z=s.z+(s.tz-s.z)*k;
    const see = teamSees('player', s.x, s.z) || teamSees('player', s.tx, s.tz) || s.team==='player';
    if (!see) continue;
    const col = s.dmgType==='energy' ? (s.team==='player'?[0.4,0.95,1]:[1,0.45,0.35]) : [1,0.85,0.45];
    emitRaw(templates.crystal, x, y, z, time*4, 0.18, col, 1);
  }
  // particles as small tris
  for (const p of particles) {
    if (fogVis(fogP, p.x, p.z) < 2 && !teamSees('player', p.x, p.z)) {
      // still show if in explored? combat in fog hidden
      if (fogVis(fogP, p.x, p.z)!==2) continue;
    }
    const s = p.s * (p.life/p.max);
    emitTri([p.x, p.y, p.z],[p.x+s, p.y+s, p.z],[p.x, p.y+s*1.4, p.z], p.col, 1);
  }
  flushDyn();
}
function drawBuilding(b, dim, ghost){
  const y = heightAt(b.x, b.z);
  const sc = b.done || ghost ? 1 : 0.35 + 0.65*(b.progress||0);
  const tint = teamTint(b.team, b.flash||0);
  emitRaw(templates[b.kind], b.x, y, b.z, b.rot||0, sc, tint, dim * (ghost?0.7:1));
  if (!b.done && !ghost) {
    emitRing(b.x, y+0.12, b.z, BDEF[b.kind].size, [0.9,0.8,0.3], 0.5);
  }
  if (selected.has(b.id)) emitRing(b.x, y+0.1, b.z, BDEF[b.kind].size+0.3, tint, 0.85);
}
function drawMesh(mb, fogOn){
  const f32 = new Float32Array(mb.p.length/3*10);
  const P=mb.p,N=mb.n,C=mb.c,E=mb.e;
  let o=0;
  for (let i=0,j=0;i<P.length;i+=3,j++){
    f32[o++]=P[i]; f32[o++]=P[i+1]; f32[o++]=P[i+2];
    f32[o++]=N[i]; f32[o++]=N[i+1]; f32[o++]=N[i+2];
    f32[o++]=C[i]; f32[o++]=C[i+1]; f32[o++]=C[i+2];
    f32[o++]=E[j];
  }
  gl.bindBuffer(gl.ARRAY_BUFFER, buf);
  gl.bufferData(gl.ARRAY_BUFFER, f32, gl.DYNAMIC_DRAW);
  const stride = 40;
  gl.enableVertexAttribArray(aPos); gl.vertexAttribPointer(aPos,3,gl.FLOAT,false,stride,0);
  gl.enableVertexAttribArray(aNrm); gl.vertexAttribPointer(aNrm,3,gl.FLOAT,false,stride,12);
  gl.enableVertexAttribArray(aCol); gl.vertexAttribPointer(aCol,3,gl.FLOAT,false,stride,24);
  gl.enableVertexAttribArray(aEmi); gl.vertexAttribPointer(aEmi,1,gl.FLOAT,false,stride,36);
  gl.uniform1f(uFogOn, fogOn);
  gl.drawArrays(gl.TRIANGLES, 0, P.length/3);
}
function flushDyn(){
  if (!dyn.p.length) return;
  const count = dyn.p.length/3;
  if (!dynF || dynF.length < count*10) dynF = new Float32Array(Math.max(count*10, 10000));
  const f32 = dynF;
  let o=0;
  const P=dyn.p,N=dyn.n,C=dyn.c,E=dyn.e;
  for (let i=0,j=0;i<P.length;i+=3,j++){
    f32[o++]=P[i]; f32[o++]=P[i+1]; f32[o++]=P[i+2];
    f32[o++]=N[i]; f32[o++]=N[i+1]; f32[o++]=N[i+2];
    f32[o++]=C[i]; f32[o++]=C[i+1]; f32[o++]=C[i+2];
    f32[o++]=E[j];
  }
  gl.bindBuffer(gl.ARRAY_BUFFER, buf);
  gl.bufferData(gl.ARRAY_BUFFER, f32.subarray(0, count*10), gl.DYNAMIC_DRAW);
  const stride=40;
  gl.enableVertexAttribArray(aPos); gl.vertexAttribPointer(aPos,3,gl.FLOAT,false,stride,0);
  gl.enableVertexAttribArray(aNrm); gl.vertexAttribPointer(aNrm,3,gl.FLOAT,false,stride,12);
  gl.enableVertexAttribArray(aCol); gl.vertexAttribPointer(aCol,3,gl.FLOAT,false,stride,24);
  gl.enableVertexAttribArray(aEmi); gl.vertexAttribPointer(aEmi,1,gl.FLOAT,false,stride,36);
  gl.uniform1f(uFogOn, 0);
  gl.drawArrays(gl.TRIANGLES, 0, count);
}

function renderOverlay(){
  const ctx = octx;
  ctx.clearRect(0,0,overlay.width, overlay.height);
  if (drag && mode==='play') {
    const x=Math.min(drag.x0,drag.x1), y=Math.min(drag.y0,drag.y1);
    const w=Math.abs(drag.x1-drag.x0), h=Math.abs(drag.y1-drag.y0);
    ctx.strokeStyle = 'rgba(80,220,255,0.9)';
    ctx.fillStyle = 'rgba(80,220,255,0.12)';
    ctx.lineWidth = 1;
    ctx.strokeRect(x,y,w,h);
    ctx.fillRect(x,y,w,h);
  }
  if (mode==='menu' || mode==='paused') return;
  // hp bars
  let bars = 0;
  const drawBar = (x,z,y, hp, max, col)=>{
    if (bars>50) return;
    const sc = worldToScreen(x,y,z);
    if (!sc) return;
    if (sc.x<0||sc.y<0||sc.x>overlay.width||sc.y>overlay.height) return;
    bars++;
    const w=36, h=4;
    ctx.fillStyle = 'rgba(0,0,0,0.55)';
    ctx.fillRect(sc.x-w/2, sc.y-h/2, w, h);
    ctx.fillStyle = col;
    ctx.fillRect(sc.x-w/2, sc.y-h/2, w*clamp(hp/max,0,1), h);
  };
  for (const idv of selected) {
    const u = findById(units, idv);
    if (u && u.alive) drawBar(u.x, u.z, heightAt(u.x,u.z)+(u.flying?u.fly:1)+1.6, u.hp, u.maxHp, '#7dffb0');
    const b = findById(buildings, idv);
    if (b && b.alive) drawBar(b.x, b.z, heightAt(b.x,b.z)+4.2, b.hp, b.maxHp, '#7dffb0');
  }
  for (const u of units) {
    if (!u.alive || u.team!=='player' && !teamSees('player', u.x, u.z)) continue;
    if (u.flash>0 && !selected.has(u.id)) drawBar(u.x,u.z, heightAt(u.x,u.z)+(u.flying?u.fly:1)+1.6, u.hp, u.maxHp, u.team==='player'?'#ffd56a':'#ff6b78');
  }
  ctx.font = '12px Segoe UI, sans-serif';
  ctx.textAlign = 'center';
  for (const f of floaters) {
    if (fogVis(fogP, f.x, f.z)<1) continue;
    const sc = worldToScreen(f.x, f.y, f.z);
    if (!sc) continue;
    ctx.fillStyle = 'rgba(255, 210, 90, '+clamp(f.t,0,1)+')';
    ctx.fillText(f.txt, sc.x, sc.y);
  }
  if (attackMoveMode) {
    ctx.fillStyle = 'rgba(255,220,120,0.9)';
    ctx.font = '13px Segoe UI, sans-serif';
    ctx.fillText('ATTACK-MOVE', pointer.x, pointer.y-14);
  }
}

function renderMinimap(){
  const w = mm.width, h = mm.height;
  const ctx = mctx;
  ctx.fillStyle = '#07090e';
  ctx.fillRect(0,0,w,h);
  const img = ctx.getImageData(0,0,w,h);
  const data = img.data;
  for (let y=0;y<h;y++){
    for (let x=0;x<w;x++){
      const wx = x/w*MAP, wz = y/h*MAP;
      const vis = fogVis(fogP, wx, wz);
      const i = (y*w+x)*4;
      if (vis===0) { data[i]=6; data[i+1]=8; data[i+2]=12; data[i+3]=255; continue; }
      const rift = riftInfo(wx,wz);
      let r=18,g=48,b=46;
      if (rift.depth>1 && !rift.bridge) { r=20; g=80; b=100; }
      else if (heightAt(wx,wz)>1.5) { r=70; g=64; b=52; }
      if (vis===1) { r*=0.45; g*=0.45; b*=0.45; }
      data[i]=r; data[i+1]=g; data[i+2]=b; data[i+3]=255;
    }
  }
  ctx.putImageData(img,0,0);
  // resources
  ctx.fillStyle = '#7dfff0';
  for (const d of deposits) {
    if (fogVis(fogP, d.x, d.z)<1) continue;
    const amt = d.playerAmt==null?0:d.playerAmt;
    if (amt<=0) continue;
    ctx.fillRect(d.x/MAP*w-1, d.z/MAP*h-1, 3, 3);
  }
  // buildings
  for (const b of buildings) {
    if (!b.alive || b.team!=='player') continue;
    ctx.fillStyle = '#3ee0ff';
    const s = Math.max(2, b.size/MAP*w);
    ctx.fillRect(b.x/MAP*w-s/2, b.z/MAP*h-s/2, s, s);
  }
  for (const mem of seenEnemy.values()) {
    if (!mem.alive) continue;
    if (fogVis(fogP, mem.x, mem.z)<1) continue;
    ctx.fillStyle = teamSees('player', mem.x, mem.z) ? '#ff4d5e' : 'rgba(255,77,94,0.45)';
    ctx.fillRect(mem.x/MAP*w-2, mem.z/MAP*h-2, 4, 4);
  }
  for (const u of units) {
    if (!u.alive) continue;
    if (u.team==='enemy') {
      if (!teamSees('player', u.x, u.z)) continue;
      ctx.fillStyle = '#ff4d5e';
    } else ctx.fillStyle = u.kind==='prospector' ? '#b8fff4' : '#e8ffff';
    ctx.fillRect(u.x/MAP*w, u.z/MAP*h, 2, 2);
  }
  // camera footprint approx
  ctx.strokeStyle = 'rgba(255,255,255,0.8)';
  ctx.lineWidth = 1;
  const corners = [];
  const rays = [[8,8],[innerWidth-8,8],[innerWidth-8,innerHeight-8],[8,innerHeight-8]];
  // use a simple box around cam target scaled by dist
  const rw = cam.dist*0.55, rh = cam.dist*0.4;
  const c = Math.cos(cam.yaw), s = Math.sin(cam.yaw);
  const pts = [[-rw,-rh],[rw,-rh],[rw,rh],[-rw,rh]];
  ctx.beginPath();
  pts.forEach((p,i)=>{
    const x = cam.x + p[0]*c - p[1]*s;
    const z = cam.z + p[0]*s + p[1]*c;
    const mx = x/MAP*w, my = z/MAP*h;
    if (i===0) ctx.moveTo(mx,my); else ctx.lineTo(mx,my);
  });
  ctx.closePath();
  ctx.stroke();
  if (underAttackPing>0) {
    ctx.strokeStyle = 'rgba(255,60,80,'+clamp(underAttackPing,0,1)+')';
    ctx.strokeRect(1,1,w-2,h-2);
  }
}

/* ---------- loop ---------- */
let last = performance.now();
function frame(now){
  const dt = clamp((now-last)/1000, 0, 0.05);
  last = now;
  time += dt;
  if (mode==='play') simulate(dt);
  else if (mode==='menu') {
    // keep crystals alive via time; fog already stamped
    if (buildings.length) updateFog(fogP,'player');
  }
  updateCam(dt);
  if (gl && mouseWorld) {
    updateCameraMatrices();
    if (!pointer.overUI) {
      const hit = rayGround(screenRay(pointer.x, pointer.y));
      if (hit) { mouseWorld.x=hit.x; mouseWorld.z=hit.z; mouseWorld.ok = hit.x>0&&hit.z>0&&hit.x<MAP&&hit.z<MAP; }
    }
  }
  if (fogP) uploadFog();
  if (gl) renderWorld();
  renderOverlay();
  minimapTimer -= dt;
  if (minimapTimer<=0 && fogP && mode!=='menu') { minimapTimer=0.25; renderMinimap(); }
  if (mode==='menu' && fogP) { minimapTimer=0; }
  if (mode==='play' || mode==='paused') {
    updateTopbar();
    const key = selectionKey();
    if (key!==panelKey) { panelKey=key; rebuildPanel(); }
    refreshPanelDynamic();
  }
  for (let i=toasts.length-1;i>=0;i--){
    toasts[i].t -= dt;
    if (toasts[i].t<=0) toasts.splice(i,1);
  }
  const host = document.getElementById('toasts');
  if (host && mode==='play') {
    host.innerHTML = toasts.map(t=>'<div class="toast '+(t.kind||'')+'">'+t.msg+'</div>').join('');
  }
  requestAnimationFrame(frame);
}
function selectionKey(){
  const ids = [...selected].sort((a,b)=>a-b).join(',');
  return ids+'|'+placeType+'|'+attackMoveMode;
}

function boot(){
  initGL();
  buildTemplates();
  resetMatch();
  uploadFog();
  requestAnimationFrame(frame);
}
boot();
</script>
</body>
</html>