← RTS results

RTS

Mimo v2.6 Pro · 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 124,733 bytes · SHA-256 d66d071bbb03
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>FRONTIER WAR — 3D Real-Time Strategy</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#04070d;color:#cfe6ff;
  font-family:"Segoe UI",Roboto,system-ui,-apple-system,sans-serif;-webkit-user-select:none;user-select:none}
canvas{display:block}
#view{position:absolute;inset:0;width:100vw;height:100vh;cursor:crosshair}
#hud{position:absolute;inset:0;pointer-events:none;display:flex;flex-direction:column;justify-content:space-between;z-index:5}
.spacer{flex:1}

/* ---------- TOP BAR ---------- */
#topbar{display:flex;align-items:center;gap:10px;padding:8px 12px;
  background:linear-gradient(180deg,rgba(6,14,26,.95),rgba(6,14,26,.55) 70%,transparent);
  border-bottom:1px solid rgba(60,210,255,.16)}
.stat{display:flex;flex-direction:column;min-width:86px;padding:3px 12px;border-left:2px solid rgba(53,224,255,.4)}
.stat .lbl{font-size:9px;letter-spacing:.18em;color:#6fa9c8}
.stat .val{font-size:19px;font-weight:600;letter-spacing:.04em;line-height:1.15}
#minerals{color:#7ef2ff}
#supply.warn{color:#ffb347}
#supply.bad{color:#ff5d7a}
.btn{pointer-events:auto;background:rgba(18,38,60,.85);color:#bfe6ff;border:1px solid rgba(60,210,255,.3);
  padding:7px 13px;border-radius:7px;font-size:11px;letter-spacing:.12em;cursor:pointer;transition:.15s}
.btn:hover{background:rgba(30,90,130,.95);border-color:#35e0ff;color:#fff;box-shadow:0 0 14px rgba(53,224,255,.35)}
.btn.big{font-size:17px;padding:15px 42px;letter-spacing:.22em;background:rgba(24,80,116,.9);border-color:#35e0ff}

/* ---------- LOG / TOASTS ---------- */
#log{position:absolute;right:14px;top:62px;width:320px;display:flex;flex-direction:column;gap:6px;z-index:6}
.toast{background:rgba(8,20,34,.9);border-left:3px solid #35e0ff;padding:7px 11px;font-size:12px;
  border-radius:0 6px 6px 0;opacity:.96;box-shadow:0 3px 14px rgba(0,0,0,.4);animation:slide .25s ease}
.toast.warn{border-color:#ffb347}
.toast.bad{border-color:#ff5d7a}
.toast.good{border-color:#4dff9e}
@keyframes slide{from{transform:translateX(24px);opacity:0}to{transform:none;opacity:.96}}

/* ---------- BOTTOM HUD ---------- */
#bottombar{display:flex;align-items:flex-end;gap:10px;padding:0 12px 12px}
.panel{background:linear-gradient(180deg,rgba(11,24,40,.93),rgba(8,18,32,.96));border:1px solid rgba(60,210,255,.22);
  border-radius:11px;pointer-events:auto;box-shadow:0 -4px 26px rgba(0,0,0,.5)}
#selpanel{width:296px;min-height:168px;padding:10px 12px}
#cmdpanel{flex:1;min-height:168px;padding:10px 12px;max-width:640px}
#minipanel{width:222px;padding:8px}
.ptitle{font-size:10px;letter-spacing:.2em;color:#6fa9c8;margin-bottom:7px;display:flex;justify-content:space-between}
#selTitle{font-size:14px;font-weight:600;letter-spacing:.05em}
#selBody{font-size:11.5px;line-height:1.65;color:#a9c9e2}
#selBody b{color:#e8f6ff;font-weight:600}
.bar{height:6px;border-radius:3px;background:rgba(255,255,255,.12);overflow:hidden;margin:3px 0 7px}
.bar i{display:block;height:100%;background:linear-gradient(90deg,#25d6a8,#7ef2ff)}
.bar.hp i{background:linear-gradient(90deg,#2bd66e,#a6ff7a)}
.bar.mp i{background:linear-gradient(90deg,#ffb347,#ff7a3c)}
.iconrow{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px}
.picon{width:38px;height:38px;border-radius:7px;border:1px solid rgba(60,210,255,.25);background:rgba(24,52,80,.7);
  display:flex;align-items:center;justify-content:center;font-size:17px;position:relative}
.picon span{position:absolute;right:2px;bottom:0;font-size:9px;color:#bfe6ff}
#cmdGrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:7px}
.cmd{position:relative;min-height:56px;padding:5px 6px 4px;border:1px solid rgba(60,210,255,.24);border-radius:8px;
  background:rgba(21,46,72,.72);color:#d6ecff;cursor:pointer;font-size:10.5px;text-align:left;transition:.12s;
  display:flex;flex-direction:column;justify-content:space-between}
.cmd:hover{background:rgba(38,96,138,.95);border-color:#35e0ff;transform:translateY(-1px)}
.cmd .nm{font-weight:600;letter-spacing:.03em;line-height:1.15}
.cmd .sub{font-size:9px;color:#8fc4de;letter-spacing:.04em}
.cmd .hk{position:absolute;top:3px;right:5px;font-size:9px;color:#5fd4ff;opacity:.85}
.cmd.disabled{opacity:.42;cursor:not-allowed;filter:grayscale(.55)}
.cmd.disabled:hover{transform:none;border-color:rgba(60,210,255,.24);background:rgba(21,46,72,.72)}
.cmd.active{border-color:#4dff9e;box-shadow:0 0 12px rgba(77,255,158,.35)}
#queue{display:flex;gap:6px;margin-top:9px;min-height:34px;flex-wrap:wrap}
.qitem{width:46px;height:46px;border-radius:7px;border:1px solid rgba(60,210,255,.3);background:rgba(20,44,70,.8);
  position:relative;overflow:hidden;cursor:pointer;font-size:9px;display:flex;align-items:center;justify-content:center;text-align:center;padding:2px}
.qitem i{position:absolute;left:0;bottom:0;width:100%;background:rgba(53,224,255,.32)}
.qitem span{position:relative;z-index:2}
.qhead{font-size:9px;letter-spacing:.18em;color:#6fa9c8;margin-top:8px}
#minimap{width:206px;height:206px;border-radius:7px;border:1px solid rgba(60,210,255,.3);cursor:crosshair;background:#04101c}
.mmlabel{font-size:9px;letter-spacing:.16em;color:#6fa9c8;display:flex;justify-content:space-between;margin-top:5px}

/* ---------- OVERLAYS ---------- */
.overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
  background:radial-gradient(ellipse at 50% 35%,rgba(12,30,52,.9),rgba(3,7,14,.97));z-index:30;pointer-events:auto}
.overlay.hidden{display:none}
.card{width:min(1000px,92vw);max-height:92vh;overflow:auto;padding:30px 38px;border-radius:16px;
  background:linear-gradient(160deg,rgba(14,32,54,.96),rgba(8,18,32,.98));border:1px solid rgba(60,210,255,.28);
  box-shadow:0 24px 90px rgba(0,0,0,.7)}
h1{margin:0;font-size:clamp(38px,7vw,82px);letter-spacing:.14em;font-weight:800;line-height:.95;
  background:linear-gradient(180deg,#eafcff,#37d9ff 55%,#1b7fb8);-webkit-background-clip:text;background-clip:text;color:transparent}
h1 em{font-style:normal;background:linear-gradient(180deg,#ffd9b0,#ff7a3c 60%,#b23c14);-webkit-background-clip:text;background-clip:text;color:transparent}
.tag{margin:10px 0 22px;font-size:13px;letter-spacing:.28em;color:#79b6d6}
.cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:20px;margin:18px 0 26px}
.col h3{margin:0 0 9px;font-size:11px;letter-spacing:.22em;color:#35e0ff}
.col ul{margin:0;padding-left:17px;font-size:12.5px;line-height:1.85;color:#a9c9e2}
.col li{margin-bottom:3px}
.col b{color:#eaf6ff}
.kbd{display:inline-block;padding:1px 7px;border-radius:5px;border:1px solid rgba(60,210,255,.35);
  background:rgba(20,48,76,.8);font-size:11px;color:#bfe6ff;margin:0 2px}
.rules{border:1px solid rgba(255,122,60,.35);background:rgba(60,26,14,.42);border-radius:11px;padding:14px 18px;margin:6px 0 22px}
.rules h3{color:#ff9d5c;margin:0 0 8px;font-size:11px;letter-spacing:.22em}
.rules p{margin:5px 0;font-size:13px;color:#ffd9c2;line-height:1.6}
.endtitle{font-size:clamp(34px,6vw,64px);letter-spacing:.22em;margin:0 0 6px;font-weight:800}
.won{color:#4dff9e;text-shadow:0 0 34px rgba(77,255,158,.5)}
.lost{color:#ff5d7a;text-shadow:0 0 34px rgba(255,93,122,.5)}
.statgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin:22px 0}
.sbox{background:rgba(16,38,62,.7);border:1px solid rgba(60,210,255,.2);border-radius:10px;padding:12px 14px}
.sbox .k{font-size:9px;letter-spacing:.18em;color:#6fa9c8}
.sbox .v{font-size:24px;font-weight:600;color:#eaf6ff}
.hidden{display:none!important}
#help{z-index:40}
#help .card{width:min(920px,92vw)}
table{width:100%;border-collapse:collapse;font-size:12px;margin:8px 0 16px}
th,td{border:1px solid rgba(60,210,255,.18);padding:6px 9px;text-align:left}
th{background:rgba(20,48,76,.75);color:#7fd8ff;font-size:10px;letter-spacing:.14em}
td{color:#b6d4ea}
.tagrow{display:flex;gap:6px;flex-wrap:wrap;margin:6px 0 12px}
.tagpill{font-size:10px;letter-spacing:.1em;padding:4px 10px;border-radius:20px;border:1px solid rgba(60,210,255,.3);background:rgba(20,48,76,.7)}
</style>
</head>
<body>
<canvas id="view"></canvas>

<div id="hud">
  <div id="topbar">
    <div class="stat"><span class="lbl">AETHERIUM</span><span class="val" id="minerals">0</span></div>
    <div class="stat"><span class="lbl">SUPPLY</span><span class="val" id="supply">0 / 0</span></div>
    <div class="stat"><span class="lbl">EXPEDITION FORCES</span><span class="val" id="armysize">0</span></div>
    <div class="stat"><span class="lbl">MISSION TIME</span><span class="val" id="clock">0:00</span></div>
    <div class="spacer"></div>
    <button class="btn" id="btnHelp">HELP</button>
    <button class="btn" id="btnMute">SOUND: ON</button>
    <button class="btn" id="btnPause">PAUSE</button>
  </div>

  <div id="log"></div>

  <div id="bottombar">
    <div class="panel" id="selpanel">
      <div class="ptitle"><span>SELECTION</span><span id="selCount"></span></div>
      <div id="selTitle">No units selected</div>
      <div id="selBody">Drag a box over your forces, or click a unit. Right-click to issue contextual orders.</div>
    </div>

    <div class="panel" id="cmdpanel">
      <div class="ptitle"><span>COMMAND</span><span id="cmdHint">Left-click selects · Right-click orders</span></div>
      <div id="cmdGrid"></div>
      <div class="qhead">PRODUCTION QUEUE</div>
      <div id="queue"></div>
    </div>

    <div class="panel" id="minipanel">
      <canvas id="minimap" width="206" height="206"></canvas>
      <div class="mmlabel"><span>TACTICAL MAP</span><span id="mmLegend">FOG: ACTIVE</span></div>
    </div>
  </div>
</div>

<div class="overlay" id="title">
  <div class="card">
    <h1>FRONTIER <em>WAR</em></h1>
    <div class="tag">VANGUARD EXPEDITION &nbsp;·&nbsp; 3D REAL-TIME STRATEGY &nbsp;·&nbsp; KHAR DOMINION RIVAL</div>
    <div class="rules">
      <h3>VICTORY CONDITIONS</h3>
      <p><b>WIN</b> by destroying every enemy <b>Headquarters</b> on the frontier. <b>LOSE</b> if all of your own Headquarters are destroyed.</p>
      <p>Headquarters can be rebuilt as expansions — a rival with a hidden second HQ is never out of the fight. Scout, expand, and end them.</p>
    </div>
    <div class="cols">
      <div class="col">
        <h3>ECONOMY</h3>
        <ul>
          <li><b>Prospectors</b> harvest <b>Aetherium</b> from crystal deposits and return it to a Headquarters or Refinery.</li>
          <li>Deposits are finite — expand toward fresh fields and build drop-offs nearby.</li>
          <li><b>Supply</b> is reusable capacity: build Supply Relays to field bigger armies.</li>
          <li>Queued units reserve supply; a smashed relay <b>pauses</b> production until rebuilt.</li>
        </ul>
      </div>
      <div class="col">
        <h3>COMMAND</h3>
        <ul>
          <li><span class="kbd">Left-drag</span> select · <span class="kbd">Shift</span> add · <span class="kbd">Ctrl+1-9</span> groups</li>
          <li><span class="kbd">Right-click</span> move / attack / gather · <span class="kbd">Shift</span> queues orders</li>
          <li><span class="kbd">A</span> attack-move · <span class="kbd">S</span> stop · <span class="kbd">H</span> hold position</li>
          <li><span class="kbd">WASD</span>/<span class="kbd">arrows</span> pan · <span class="kbd">Q</span>/<span class="kbd">E</span> rotate · <span class="kbd">wheel</span> zoom</li>
          <li>Minimap: left-click to jump, right-click to order.</li>
        </ul>
      </div>
      <div class="col">
        <h3>WAR MACHINE</h3>
        <ul>
          <li><b>Ravager</b> — fast melee skimmer, shreds light units.</li>
          <li><b>Marksman</b> — long-ranged railgun, hits ground and air.</li>
          <li><b>Juggernaut</b> — heavy siege armour, crushes armour and structures.</li>
          <li><b>Talon</b> — interceptor aircraft, flies over all terrain.</li>
          <li><b>Widow</b> — dedicated anti-air flak platform.</li>
          <li>Fog of war hides the enemy base — scouting wins wars.</li>
        </ul>
      </div>
    </div>
    <button class="btn big" id="btnStart">DEPLOY EXPEDITION</button>
  </div>
</div>

<div class="overlay hidden" id="pause">
  <div class="card" style="text-align:center">
    <h2 class="endtitle" style="letter-spacing:.3em">PAUSED</h2>
    <p style="color:#8fc4de;letter-spacing:.12em">THE FRONTIER HOLDS ITS BREATH</p>
    <div style="display:flex;gap:14px;justify-content:center;margin-top:26px">
      <button class="btn big" id="btnResume">RESUME</button>
      <button class="btn big" id="btnRestart2">RESTART MATCH</button>
      <button class="btn big" id="btnHelp2">HELP</button>
    </div>
  </div>
</div>

<div class="overlay hidden" id="end">
  <div class="card" style="text-align:center">
    <h2 class="endtitle" id="endTitle">VICTORY</h2>
    <p id="endSub" style="letter-spacing:.16em;color:#a9c9e2"></p>
    <div class="statgrid" id="endStats"></div>
    <button class="btn big" id="btnRestart">FIGHT AGAIN</button>
  </div>
</div>

<div class="overlay hidden" id="help">
  <div class="card">
    <h2 style="margin:0 0 4px;letter-spacing:.22em;font-size:26px;color:#8fe9ff">FIELD MANUAL</h2>
    <div class="tagrow">
      <div class="tagpill">VICTORY: DESTROY ALL ENEMY HQs</div>
      <div class="tagpill">DEFEAT: LOSE ALL YOUR HQs</div>
      <div class="tagpill">FOG: UNEXPLORED / SEEN / LIVE</div>
      <div class="tagpill">SUPPLY: RELAYS & HQs</div>
    </div>
    <div class="cols">
      <div class="col">
        <h3>CONTROLS</h3>
        <ul>
          <li><b>Left-click</b> select · <b>left-drag</b> box select · <b>Shift</b> adds to selection.</li>
          <li><b>Double-click</b> a unit to select all of that type on screen.</li>
          <li><b>Right-click</b>: move, attack, gather, return — context sensitive. <b>Shift</b> queues orders.</li>
          <li><b>A</b> attack-move · <b>S</b> stop · <b>H</b> hold position · <b>G</b> gather-mode.</li>
          <li><b>Ctrl + 0-9</b> assign control group · <b>0-9</b> recall (twice to centre camera).</li>
          <li><b>WASD / arrows</b> pan · <b>Q / E</b> rotate · <b>mouse wheel</b> zoom · <b>edge scroll</b>.</li>
          <li><b>Esc</b> pause · <b>F1</b> this manual.</li>
        </ul>
      </div>
      <div class="col">
        <h3>ECONOMY & SUPPLY</h3>
        <ul>
          <li>Workers mine crystals and carry them home. Place <b>Refineries</b> beside distant fields to shorten trips.</li>
          <li>Every deposit is finite. Watch the crystal size shrink as it depletes.</li>
          <li><b>Supply</b> is capacity, never spent: HQ +20, Supply Relay +16. Queue as many as you can support.</li>
          <li>Queueing reserves supply; cancelling refunds the full cost instantly.</li>
          <li>If supply drops below demand, production <b>pauses</b> (shown in the queue) until relays are rebuilt.</li>
          <li>Build near friendly structures — the frontier is claimed by proximity.</li>
        </ul>
      </div>
      <div class="col">
        <h3>COUNTERS & COMBAT</h3>
        <ul>
          <li><b>Light</b> armour (Ravager, Marksman, workers) fears energy blades and massed fire.</li>
          <li><b>Heavy</b> armour (Juggernaut) shrugs off kinetic fire but melts to explosive shells.</li>
          <li><b>Air</b> units take extra damage from energy weapons; Widow flak and Marksmen shoot up.</li>
          <li>Juggernauts cannot shoot air. Ravagers cannot reach air. Bring combined arms.</li>
          <li><b>Attack-move</b> fights while travelling; <b>hold</b> holds ground and fires without chasing.</li>
        </ul>
      </div>
    </div>
    <table>
      <tr><th>UNIT</th><th>ROLE</th><th>DAMAGE</th><th>ARMOUR</th><th>HITS</th><th>COST</th><th>SUPPLY</th></tr>
      <tr><td>Prospector</td><td>Worker / builder</td><td>Kinetic</td><td>Light</td><td>Ground</td><td>50</td><td>1</td></tr>
      <tr><td>Ravager</td><td>Fast melee skirmisher</td><td>Energy</td><td>Light</td><td>Ground</td><td>90</td><td>2</td></tr>
      <tr><td>Marksman</td><td>Long-range railgun</td><td>Kinetic</td><td>Light</td><td>Ground + Air</td><td>120</td><td>2</td></tr>
      <tr><td>Widow</td><td>Anti-air flak</td><td>Energy</td><td>Light</td><td>Air</td><td>140</td><td>2</td></tr>
      <tr><td>Juggernaut</td><td>Heavy siege armour</td><td>Explosive</td><td>Heavy</td><td>Ground</td><td>260</td><td>5</td></tr>
      <tr><td>Talon</td><td>Interceptor aircraft</td><td>Energy</td><td>Air</td><td>Ground + Air</td><td>220</td><td>4</td></tr>
    </table>
    <div style="text-align:center"><button class="btn big" id="btnCloseHelp">RETURN TO BATTLE</button></div>
  </div>
</div>

<script>
"use strict";

/* ================================================================
   FRONTIER WAR — self-contained 3D RTS engine (canvas 2.5D renderer)
   ================================================================ */
const TAU = Math.PI * 2;
const clamp = (v,a,b)=> v<a?a:(v>b?b:v);
const lerp = (a,b,t)=> a+(b-a)*t;
const dist2 = (ax,az,bx,bz)=>{const dx=ax-bx,dz=az-bz;return dx*dx+dz*dz;};
const dist = (ax,az,bx,bz)=> Math.sqrt(dist2(ax,az,bx,bz));

/* deterministic-ish rng for world generation */
let _seed = 20240617;
function rnd(){ _seed = (_seed*1664525 + 1013904223) & 0x7fffffff; return _seed/0x7fffffff; }
function rr(a,b){ return a + (b-a)*rnd(); }

/* ---------------- world constants ---------------- */
const WORLD = 240;
const FOGN = 128;
const CELL = WORLD / FOGN;
const HCELL = 8, HDIM = Math.ceil(WORLD/HCELL);
const HUMAN = 0, ENEMY = 1;
const FOV = 0.86, PITCH = 0.88;          // radians
const MIN_ZOOM = 48, MAX_ZOOM = 250;

/* ---------------- canvases ---------------- */
const view = document.getElementById('view');
const ctx = view.getContext('2d', {alpha:false});
const mm = document.getElementById('minimap');
const mmx = mm.getContext('2d');
const fogCanvas = document.createElement('canvas'); fogCanvas.width = FOGN; fogCanvas.height = FOGN;
const fogCtx = fogCanvas.getContext('2d');
let CW=0, CH=0, DPR=1, FOCAL=1, HALF_W=0, HALF_H=0;

function resize(){
  DPR = Math.min(window.devicePixelRatio || 1, 1.6);
  CW = view.clientWidth; CH = view.clientHeight;
  view.width = Math.max(2,Math.floor(CW*DPR)); view.height = Math.max(2,Math.floor(CH*DPR));
  ctx.setTransform(DPR,0,0,DPR,0,0);
  HALF_W = CW/2; HALF_H = CH/2;
  FOCAL = (CH/2)/Math.tan(FOV/2);
}
window.addEventListener('resize', resize);

/* ---------------- camera ---------------- */
const cam = { x: 60, z: 178, yaw: 0, dist: 128,
  posX:0, posY:0, posZ:0, fx:0,fy:0,fz:0, rx:0,ry:0,rz:0, ux:0,uy:0,uz:0 };

function updateCamera(){
  cam.dist = clamp(cam.dist, MIN_ZOOM, MAX_ZOOM);
  cam.x = clamp(cam.x, 14, WORLD-14);
  cam.z = clamp(cam.z, 14, WORLD-14);
  const sy = Math.sin(cam.yaw), cy = Math.cos(cam.yaw), sp = Math.sin(PITCH), cp = Math.cos(PITCH);
  cam.posX = cam.x - sy*cam.dist*cp;
  cam.posY = cam.dist*sp;
  cam.posZ = cam.z - cy*cam.dist*cp;
  cam.fx = sy*cp; cam.fy = -sp; cam.fz = cy*cp;      // forward
  cam.rx = cy;    cam.ry = 0;   cam.rz = -sy;        // right
  cam.ux = sp*sy; cam.uy = cp;  cam.uz = sp*cy;      // up
}

/* project world point -> screen; returns depth or -1 when behind camera */
const _p = {x:0,y:0,z:0};
function project(x,y,z,out){
  const dx=x-cam.posX, dy=y-cam.posY, dz=z-cam.posZ;
  const X = dx*cam.rx + dy*cam.ry + dz*cam.rz;
  const Y = dx*cam.ux + dy*cam.uy + dz*cam.uz;
  const Z = dx*cam.fx + dy*cam.fy + dz*cam.fz;
  if (Z < 1) return false;
  out.x = HALF_W + FOCAL*X/Z;
  out.y = HALF_H - FOCAL*Y/Z;
  out.z = Z;
  return true;
}

function screenToGround(sx, sy, out){
  const ndx = (sx - HALF_W)/FOCAL, ndy = -(sy - HALF_H)/FOCAL;
  const dx = cam.fx + cam.rx*ndx + cam.ux*ndy;
  const dy = cam.fy + cam.ry*ndx + cam.uy*ndy;
  const dz = cam.fz + cam.rz*ndx + cam.uz*ndy;
  const t = (0.0 - cam.posY)/dy;
  out.x = clamp(cam.posX + dx*t, -60, WORLD+60);
  out.z = clamp(cam.posZ + dz*t, -60, WORLD+60);
  return out;
}

/* ---------------- entity stores ---------------- */
let nextId = 1;
const units = [], buildings = [], nodes = [], rocks = [], projs = [], fxList = [], beacons = [];
const ents = { all: [] };

/* ---------------- game state ---------------- */
const S = {
  running:false, paused:false, over:false, t:0,
  min:[0,0], supUsed:[0,0], supRes:[0,0], supCap:[0,0],
  upg:[{weapons:0,armor:0,thrusters:0,harvest:0},{weapons:0,armor:0,thrusters:0,harvest:0}],
  kills:[0,0], lost:[0,0], gathered:[0,0],
  ai:{ nextBuild:0, nextArmy:0, nextScout:0, attackAt:0, waveSize:4, threat:0 },
  stats:{ fps:60 }
};

/* ---------------- fog of war ---------------- */
const fog = [
  { vis:new Uint8Array(FOGN*FOGN), exp:new Uint8Array(FOGN*FOGN) },
  { vis:new Uint8Array(FOGN*FOGN), exp:new Uint8Array(FOGN*FOGN) }
];
function fogIdx(x,z){
  const cx = x/CELL|0, cz = z/CELL|0;
  return (cx<0?0:cx>=FOGN?FOGN-1:cx) + FOGN*(cz<0?0:cz>=FOGN?FOGN-1:cz);
}
function visTo(p,x,z){ return fog[p].vis[fogIdx(x,z)]>0; }
function seenTo(p,x,z){ return fog[p].exp[fogIdx(x,z)]>0; }

function stampVision(f,x,z,rad){
  const x0 = Math.max(0, Math.floor((x-rad)/CELL)), x1 = Math.min(FOGN-1, Math.floor((x+rad)/CELL));
  const z0 = Math.max(0, Math.floor((z-rad)/CELL)), z1 = Math.min(FOGN-1, Math.floor((z+rad)/CELL));
  const c2 = (rad/CELL)*(rad/CELL);
  const v = f.vis;
  for (let cz=z0; cz<=z1; cz++){
    const dz = cz + 0.5 - z/CELL;
    for (let cx=x0; cx<=x1; cx++){
      const dx = cx + 0.5 - x/CELL;
      if (dx*dx + dz*dz <= c2) v[cx + FOGN*cz] = 1;
    }
  }
}
function stampExplored(f,x,z,rad){
  const x0 = Math.max(0, Math.floor((x-rad)/CELL)), x1 = Math.min(FOGN-1, Math.floor((x+rad)/CELL));
  const z0 = Math.max(0, Math.floor((z-rad)/CELL)), z1 = Math.min(FOGN-1, Math.floor((z+rad)/CELL));
  const c2 = (rad/CELL)*(rad/CELL);
  const ex = f.exp;
  for (let cz=z0; cz<=z1; cz++){
    const dz = cz + 0.5 - z/CELL;
    for (let cx=x0; cx<=x1; cx++){
      const dx = cx + 0.5 - x/CELL;
      if (dx*dx + dz*dz <= c2) ex[cx + FOGN*cz] = 1;
    }
  }
}
let visionTimer = 0;
function computeVision(){
  for (let p=0;p<2;p++){
    const f = fog[p]; f.vis.fill(0);
    for (let i=0;i<units.length;i++){ const u=units[i]; if (u.player===p) stampVision(f,u.x,u.z,u.sight); }
    for (let i=0;i<buildings.length;i++){ const b=buildings[i]; if (b.player===p) stampVision(f,b.x,b.z,b.sight); }
    const v=f.vis, ex=f.exp;
    for (let i=0;i<v.length;i++) if (v[i]) ex[i]=1;
  }
}

/* ---------------- spatial hash (units) ---------------- */
const hash = new Array(HDIM*HDIM);
for (let i=0;i<hash.length;i++) hash[i] = [];
function hashRebuild(){
  for (let i=0;i<hash.length;i++) if (hash[i].length) hash[i].length = 0;
  for (let i=0;i<units.length;i++){
    const u = units[i];
    const cx = clamp(u.x/HCELL|0,0,HDIM-1), cz = clamp(u.z/HCELL|0,0,HDIM-1);
    hash[cx + HDIM*cz].push(u);
  }
}
function hashQuery(x,z,r,cb){
  const x0 = clamp((x-r)/HCELL|0,0,HDIM-1), x1 = clamp((x+r)/HCELL|0,0,HDIM-1);
  const z0 = clamp((z-r)/HCELL|0,0,HDIM-1), z1 = clamp((z+r)/HCELL|0,0,HDIM-1);
  for (let cz=z0; cz<=z1; cz++){
    const row = cz*HDIM;
    for (let cx=x0; cx<=x1; cx++){
      const bucket = hash[row+cx];
      for (let i=0;i<bucket.length;i++) if (cb(bucket[i])===false) return;
    }
  }
}

/* static hash for buildings & rocks (obstacles) */
const oHash = new Array(HDIM*HDIM);
for (let i=0;i<oHash.length;i++) oHash[i] = [];
let oHashDirty = true;
function oHashRebuild(){
  for (let i=0;i<oHash.length;i++) if (oHash[i].length) oHash[i].length = 0;
  const push=(o,cx,cz)=>{ oHash[cx + HDIM*cz].push(o); };
  for (let i=0;i<buildings.length;i++){
    const b = buildings[i];
    const r = b.def.rad;
    const x0=clamp((b.x-r)/HCELL|0,0,HDIM-1), x1=clamp((b.x+r)/HCELL|0,0,HDIM-1);
    const z0=clamp((b.z-r)/HCELL|0,0,HDIM-1), z1=clamp((b.z+r)/HCELL|0,0,HDIM-1);
    for (let cz=z0;cz<=z1;cz++) for (let cx=x0;cx<=x1;cx++) push(b,cx,cz);
  }
  for (let i=0;i<rocks.length;i++){
    const o = rocks[i];
    const cx = clamp(o.x/HCELL|0,0,HDIM-1), cz = clamp(o.z/HCELL|0,0,HDIM-1);
    push(o,cx,cz);
  }
  oHashDirty = false;
}
function oHashQuery(x,z,r,cb){
  const x0 = clamp((x-r)/HCELL|0,0,HDIM-1), x1 = clamp((x+r)/HCELL|0,0,HDIM-1);
  const z0 = clamp((z-r)/HCELL|0,0,HDIM-1), z1 = clamp((z+r)/HCELL|0,0,HDIM-1);
  for (let cz=z0; cz<=z1; cz++){
    const row = cz*HDIM;
    for (let cx=x0; cx<=x1;cx++){
      const bucket = oHash[row+cx];
      for (let i=0;i<bucket.length;i++) if (cb(bucket[i])===false) return;
    }
  }
}

/* ---------------- world generation ---------------- */
function addRock(x,z,r,h,sides,tint){
  const vcount = sides|0;
  const base = [], top = [];
  const a0 = rnd()*TAU;
  for (let i=0;i<vcount;i++){
    const a = a0 + i/vcount*TAU;
    const rad = r*(0.72+rnd()*0.5);
    base.push([x+Math.cos(a)*rad, 0, z+Math.sin(a)*rad]);
    top.push([x+Math.cos(a)*rad*0.55, h*(0.72+rnd()*0.42), z+Math.sin(a)*rad*0.55]);
  }
  const shade = tint||0;
  const faces = [];
  const c1 = `rgb(${38+shade},${46+shade},${64+shade})`;
  const c2 = `rgb(${28+shade},${34+shade},${50+shade})`;
  const cTop = `rgb(${62+shade},${76+shade},${98+shade})`;
  for (let i=0;i<vcount;i++){
    const j = (i+1)%vcount;
    faces.push({p:[base[i],base[j],top[j],top[i]], c: i%2 ? c2 : c1});
  }
  faces.push({p:top, c:cTop});
  rocks.push({x, z, r:r*1.02, h, faces, kind:'rock'});
}

function rockChain(x1,z1,x2,z2,n,minR,maxR,gapAt){
  for (let i=0;i<n;i++){
    if (gapAt && gapAt.includes(i)) continue;
    const t = n===1?0.5:i/(n-1);
    const x = lerp(x1,x2,t) + rr(-3,3), z = lerp(z1,z2,t) + rr(-3,3);
    addRock(x,z, rr(minR,maxR), rr(5,13), 5+Math.floor(rnd()*3), Math.floor(rr(-6,10)));
  }
}

function addNode(x,z,amount){
  const crystals = [];
  const n = 4 + Math.floor(rnd()*3);
  for (let i=0;i<n;i++){
    const a = rnd()*TAU, d = rr(0,4.6);
    crystals.push({dx:Math.cos(a)*d, dz:Math.sin(a)*d, h:rr(5,12), w:rr(1.4,2.8), rot:rnd()*TAU});
  }
  nodes.push({ id:nextId++, x, z, amount, max:amount, r:7.5, crystals, seed:rnd()*TAU });
}

function genWorld(){
  rocks.length = 0; nodes.length = 0;
  /* resource deposits */
  addNode(34,196,1600); addNode(74,166,1600);
  addNode(206,44,1600);  addNode(166,74,1600);
  addNode(120,120,2600);
  addNode(56,58,2100);   addNode(184,182,2100);
  addNode(36,120,1900);  addNode(204,120,1900);
  addNode(120,38,1900);  addNode(120,202,1900);
  addNode(88,96,1500);   addNode(152,144,1500);

  /* ridges forming lanes and chokepoints */
  rockChain(30,140,74,104,7,5,11,[3]);
  rockChain(166,136,210,100,7,5,11,[3]);
  rockChain(96,36,144,26,6,5,11,[2,3]);
  rockChain(96,214,144,204,6,5,11,[2,3]);
  rockChain(148,72,176,44,5,4,9,[2]);
  rockChain(64,196,92,168,5,4,9,[2]);
  /* central ring around the main field (gaps at N/S/E/W) */
  const ring = [];
  for (let i=0;i<18;i++){ const a=i/18*TAU; if (i%4===0) continue; ring.push(i); }
  for (const i of ring){
    const a = i/18*TAU + rr(-0.06,0.06);
    addRock(120+Math.cos(a)*rr(30,35), 120+Math.sin(a)*rr(30,35), rr(5,10), rr(6,14), 5+Math.floor(rnd()*3), Math.floor(rr(-6,10)));
  }
  /* scattered clusters */
  for (let i=0;i<40;i++){
    const x = rr(18,WORLD-18), z = rr(18,WORLD-18);
    if (dist(x,z,56,184)<34 || dist(x,z,184,56)<34) continue;
    let ok = true;
    for (const nd of nodes) if (dist2(x,z,nd.x,nd.z) < 26*26) { ok=false; break; }
    if (!ok) continue;
    const n = 1+Math.floor(rnd()*3);
    for (let k=0;k<n;k++) addRock(x+rr(-9,9), z+rr(-9,9), rr(3.5,8), rr(4,11), 5+Math.floor(rnd()*3), Math.floor(rr(-6,10)));
  }
  oHashDirty = true;
}

/* ================================================================
   GAME DATA — factions, units, buildings, upgrades, damage model
   ================================================================ */
const DMG = {
  kinetic:  { light:1.00, heavy:0.55, air:0.85, structure:0.62 },
  explosive:{ light:0.80, heavy:1.30, air:0.50, structure:1.15 },
  energy:   { light:1.10, heavy:0.70, air:1.25, structure:0.75 }
};
const DMGNAME = { kinetic:'Kinetic', explosive:'Explosive', energy:'Energy' };

const UNITS = {
  worker:    { name:'Prospector', role:'Worker · builder', cost:50,  time:8,  supply:1, hp:65,  speed:11, sight:13,
               dmg:4,  dmgType:'kinetic', range:2.6, cd:1.0, targets:'ground', armor:'light', rad:1.35, h:3.6, hotkey:'W' },
  ravager:   { name:'Ravager', role:'Fast melee skimmer', cost:90,  time:10, supply:2, hp:135, speed:15.5, sight:15,
               dmg:15, dmgType:'energy', range:3.2, cd:0.75, targets:'ground', armor:'light', rad:1.55, h:3.6, hotkey:'V' },
  marksman:  { name:'Marksman', role:'Railgun · hits air', cost:120, time:12, supply:2, hp:100, speed:11, sight:19,
               dmg:17, dmgType:'kinetic', range:14, cd:1.55, targets:'both', armor:'light', rad:1.55, h:5, hotkey:'M' },
  widow:     { name:'Widow', role:'Anti-air flak', cost:140, time:13, supply:2, hp:115, speed:11.5, sight:21,
               dmg:19, dmgType:'energy', range:16, cd:1.0, targets:'air', armor:'light', rad:1.6, h:4.6, hotkey:'D' },
  juggernaut:{ name:'Juggernaut', role:'Heavy siege armour', cost:260, time:22, supply:5, hp:440, speed:7.2, sight:17,
               dmg:42, dmgType:'explosive', range:11.5, cd:2.5, targets:'ground', armor:'heavy', rad:2.7, h:5.4, hotkey:'J' },
  talon:     { name:'Talon', role:'Interceptor aircraft', cost:220, time:18, supply:4, hp:185, speed:22, sight:21,
               dmg:18, dmgType:'energy', range:10, cd:1.15, targets:'both', armor:'air', rad:2.1, h:4, hotkey:'L', fly:true }
};

const BUILDS = {
  hq:       { name:'Headquarters', role:'Drop-off · trains Prospectors · +20 supply', cost:500, time:40, hp:2200,
              w:16, d:16, sight:26, supply:20, drop:true, trains:['worker'], hotkey:'Y' },
  supply:   { name:'Supply Relay', role:'+16 supply capacity', cost:100, time:12, hp:650,
              w:9, d:9, sight:13, supply:16, hotkey:'Z' },
  foundry:  { name:'War Foundry', role:'Trains ground forces', cost:180, time:20, hp:1300,
              w:13, d:13, sight:15, trains:['ravager','marksman','widow','juggernaut'], hotkey:'F' },
  starport: { name:'Aerodrome', role:'Trains aircraft', cost:240, time:24, hp:1150,
              w:15, d:15, sight:15, trains:['talon'], hotkey:'P' },
  lab:      { name:'Tech Nexus', role:'Research upgrades', cost:220, time:20, hp:950,
              w:11, d:11, sight:15, research:true, hotkey:'N' },
  turret:   { name:'Sentinel Turret', role:'Static defence · ground + air', cost:120, time:12, hp:850,
              w:6, d:6, sight:21, dmg:23, dmgType:'kinetic', range:21, cd:1.15, targets:'both', hotkey:'T' },
  refinery: { name:'Refinery', role:'Resource drop-off', cost:140, time:14, hp:950,
              w:11, d:11, sight:13, drop:true, hotkey:'R' }
};

const UPGRADES = {
  weapons:   { name:'Pulse Amplifiers', cost:150, time:28, max:2, desc:'+12% unit damage per level', hotkey:'C' },
  armor:     { name:'Reactive Plating', cost:150, time:28, max:2, desc:'+12% unit hull per level', hotkey:'I' },
  thrusters: { name:'Vector Thrusters', cost:200, time:34, max:1, desc:'+12% movement speed', hotkey:'U' },
  harvest:   { name:'Flux Extraction', cost:200, time:34, max:1, desc:'+30% harvest rate & carry', hotkey:'O' }
};

const THEME = [
  { main:[32,124,150], dark:[16,66,84], trim:[46,168,201], glow:[126,242,255], accent:'#35e0ff' },
  { main:[168,76,44],  dark:[92,38,22], trim:[201,110,60], glow:[255,178,122], accent:'#ff7a3c' }
];

/* ------------- procedural models (local space: +z forward, +y up) ------------- */
function shadeSet(c){
  const out = [];
  for (let i=0;i<8;i++){
    const k = 0.42 + i*(0.62/7);
    out.push(`rgb(${Math.min(255,c[0]*k|0)},${Math.min(255,c[1]*k|0)},${Math.min(255,c[2]*k|0)})`);
  }
  return out;
}
function box(x,y,z,w,h,d,c){ return {x,y,z,w,h,d,sh:shadeSet(c)}; }

const BOX_FACES = [
  { i:[4,5,6,7], n:[0,1,0] },
  { i:[3,2,6,7], n:[0,0,1] },
  { i:[0,1,5,4], n:[0,0,-1] },
  { i:[1,2,6,5], n:[1,0,0] },
  { i:[0,3,7,4], n:[-1,0,0] }
];

const MODELS = {
  worker(t){ return [
    box(0,1.1,0, 2.7,1.15,3.5, t.main),
    box(0,2.25,-0.35, 1.55,1.15,1.9, t.trim),
    box(1.75,0.85,-0.1, 0.75,0.95,2.5, t.dark),
    box(-1.75,0.85,-0.1, 0.75,0.95,2.5, t.dark),
    box(0,1.45,1.95, 1.25,0.75,0.55, t.glow)
  ]; },
  ravager(t){ return [
    box(0,1.15,0, 2.9,1.25,4.2, t.main),
    box(0,2.35,-0.6, 1.5,1.05,1.7, t.trim),
    box(1.9,0.75,1.2, 0.45,2.1,2.6, t.glow),
    box(-1.9,0.75,1.2, 0.45,2.1,2.6, t.glow),
    box(0,1.5,2.35, 1.15,0.65,0.5, t.glow),
    box(0,0.55,-2.0, 2.1,0.75,0.9, t.dark)
  ]; },
  marksman(t){ return [
    box(0,1.25,0, 2.5,1.5,3.3, t.main),
    box(0,2.7,-0.5, 1.55,1.2,1.7, t.trim),
    box(0,2.95,1.85, 0.55,0.55,4.9, t.dark),
    box(0,3.0,4.4, 0.85,0.85,0.85, t.glow),
    box(1.45,0.15,0, 0.85,1.35,2.5, t.dark),
    box(-1.45,0.15,0, 0.85,1.35,2.5, t.dark),
    box(0,1.75,-1.9, 1.3,0.8,0.7, t.glow)
  ]; },
  widow(t){ return [
    box(0,1.0,0, 3.1,1.15,3.7, t.main),
    box(0,2.15,-0.3, 1.85,1.15,2.1, t.trim),
    box(1.15,2.55,0.9, 0.45,0.45,3.2, t.dark),
    box(-1.15,2.55,0.9, 0.45,0.45,3.2, t.dark),
    box(0,3.3,-1.15, 1.75,0.35,1.15, t.glow),
    box(0,1.35,1.95, 1.35,0.65,0.5, t.glow)
  ]; },
  juggernaut(t){ return [
    box(0,1.15,0, 4.6,1.9,6.2, t.main),
    box(0,3.05,-0.35, 3.3,1.75,3.4, t.trim),
    box(1.15,3.55,2.15, 0.85,0.85,4.7, t.dark),
    box(-1.15,3.55,2.15, 0.85,0.85,4.7, t.dark),
    box(1.15,3.55,4.6, 1.15,1.15,0.9, t.glow),
    box(-1.15,3.55,4.6, 1.15,1.15,0.9, t.glow),
    box(2.65,0.35,0, 1.05,1.85,6.6, t.dark),
    box(-2.65,0.35,0, 1.05,1.85,6.6, t.dark),
    box(0,4.8,-1.5, 1.6,0.85,1.3, t.glow)
  ]; },
  talon(t){ return [
    box(0,0,0, 1.7,1.35,6.2, t.main),
    box(0,0.85,1.9, 1.15,0.85,1.9, t.trim),
    box(0,-0.12,-0.2, 7.4,0.42,2.3, t.dark),
    box(0,0.05,-2.85, 3.4,0.4,1.35, t.dark),
    box(0,0.35,-2.2, 0.42,1.75,1.15, t.trim),
    box(0,-0.35,2.25, 1.0,0.55,1.15, t.glow)
  ]; },

  /* ---- buildings ---- */
  hq(t){ return [
    box(0,0,0, 16,1.3,16, t.dark),
    box(0,1.3,0, 10.5,7,10.5, t.main),
    box(0,8.3,0, 7.5,2.2,7.5, t.trim),
    box(0,10.5,0, 4.2,2.1,4.2, t.glow),
    box(6.2,1.3,6.2, 2.2,10,2.2, t.trim),
    box(6.2,11.3,6.2, 1.15,3.2,1.15, t.glow),
    box(-6.2,1.3,-6.2, 2.2,7,2.2, t.trim),
    box(0,1.3,7.4, 6.2,3.4,1.6, t.trim)
  ]; },
  supply(t){ return [
    box(0,0,0, 9,1.1,9, t.dark),
    box(0,1.1,0, 4.4,11,4.4, t.main),
    box(0,6.2,0, 6.6,1.5,6.6, t.trim),
    box(0,9.2,0, 6.6,1.2,6.6, t.trim),
    box(0,12.1,0, 2.4,2.6,2.4, t.glow)
  ]; },
  foundry(t){ return [
    box(0,0,0, 13,1.2,13, t.dark),
    box(0,1.2,-1, 10.5,6.2,9, t.main),
    box(0,7.4,-1, 8,1.5,7, t.trim),
    box(-4.2,1.2,-5.2, 2.4,9.5,2.4, t.trim),
    box(-4.2,10.7,-5.2, 1.6,1.8,1.6, t.glow),
    box(0,1.2,6.2, 6.6,3.1,2.2, t.trim),
    box(0,3.2,6.6, 3.2,1.2,1.4, t.glow)
  ]; },
  starport(t){ return [
    box(0,0,0, 15,1.1,15, t.dark),
    box(0,1.1,0, 11,0.75,11, t.trim),
    box(0,1.85,0, 6,0.45,6, t.glow),
    box(-5.4,1.1,-5.4, 3.2,9.5,3.2, t.main),
    box(-5.4,10.6,-5.4, 2,2.1,2, t.glow),
    box(5.4,1.1,-5.4, 2.2,6.2,2.2, t.main),
    box(5.4,7.3,-5.4, 1.5,1.6,1.5, t.glow),
    box(0,1.1,6.4, 7.2,2.2,1.6, t.trim)
  ]; },
  lab(t){ return [
    box(0,0,0, 11,1.2,11, t.dark),
    box(0,1.2,0, 7.2,5.6,7.2, t.main),
    box(0,6.8,0, 9,1.15,9, t.trim),
    box(0,7.95,0, 5.4,2.4,5.4, t.trim),
    box(0,10.35,0, 2.1,3.6,2.1, t.glow),
    box(3.9,1.2,3.9, 1.5,6.8,1.5, t.trim),
    box(-3.9,1.2,3.9, 1.5,6.8,1.5, t.trim)
  ]; },
  turret(t){ return [
    box(0,0,0, 6,1.3,6, t.dark),
    box(0,1.3,0, 2.9,3.6,2.9, t.main),
    box(0,4.9,0, 4.1,1.5,4.1, t.trim),
    box(0,5.5,1.5, 1.15,1.15,4.4, t.dark),
    box(0,5.5,-1.2, 1.9,1.5,1.9, t.glow)
  ]; },
  refinery(t){ return [
    box(0,0,0, 11,1.15,11, t.dark),
    box(-2.9,1.15,-1.4, 4.2,5.4,5.2, t.main),
    box(2.9,1.15,-1.4, 4.2,4.2,5.2, t.main),
    box(0,1.15,3.9, 9,3.4,2.6, t.trim),
    box(-2.9,6.55,-1.4, 2.6,1.5,3.2, t.glow),
    box(0,4.55,3.9, 3.2,1.2,1.6, t.glow)
  ]; }
};
const MODEL_H = { worker:3.6, ravager:3.6, marksman:5.2, widow:4.6, juggernaut:5.6, talon:3,
  hq:14, supply:14.8, foundry:12, starport:12, lab:14, turret:7.2, refinery:8 };

const modelCache = {};
function getModel(key, player){
  const id = key + player;
  if (!modelCache[id]) modelCache[id] = MODELS[key](THEME[player]);
  return modelCache[id];
}

/* ================================================================
   ENTITIES
   ================================================================ */
function unitHpMul(p){ return 1 + 0.12*S.upg[p].armor; }
function unitDmgMul(p){ return 1 + 0.12*S.upg[p].weapons; }
function unitSpeedMul(p){ return 1 + 0.12*S.upg[p].thrusters; }
function carryCap(p){ return Math.round(10 * (S.upg[p].harvest ? 1.3 : 1)); }
function harvestMul(p){ return S.upg[p].harvest ? 1.3 : 1; }

function spawnUnit(key, player, x, z, yaw){
  const d = UNITS[key];
  const u = {
    id:nextId++, kind:'unit', key, def:d, player,
    x:x, z:z, y:(d.fly?15:0), yaw:(yaw!==undefined?yaw:rnd()*TAU),
    vx:0, vz:0, hp:0, maxHp:0, speed:d.speed, sight:d.sight, rad:d.rad,
    cd:0, flash:0, hitT:0, anim:rnd()*TAU,
    orders:[], order:null, target:null, tx:0, tz:0,
    carry:0, node:null, mineT:0, buildRef:null,
    hold:false, dead:false, attackMove:false
  };
  u.maxHp = Math.round(d.hp*unitHpMul(player));
  u.hp = u.maxHp;
  units.push(u);
  recomputeSupply();
  return u;
}

function spawnBuilding(key, player, x, z, complete){
  const d = BUILDS[key];
  const b = {
    id:nextId++, kind:'building', key, def:d, player,
    x:x, z:z, y:0, yaw:0, rad:Math.max(d.w,d.d)*0.52,
    hp: complete ? d.hp : Math.round(d.hp*0.1), maxHp:d.hp,
    progress: complete ? 1 : 0, built: !!complete,
    queue:[], rally:null, research:null, researchT:0,
    cd:0, flash:0, hitT:0, dead:false, sight:d.sight,
    anim:rnd()*TAU, builders:0, spawnT:0
  };
  buildings.push(b);
  oHashDirty = true;
  recomputeSupply();
  return b;
}

function recomputeSupply(){
  for (let p=0;p<2;p++){
    let cap=0, used=0, res=0;
    for (let i=0;i<buildings.length;i++){
      const b = buildings[i];
      if (b.player!==p) continue;
      if (b.built && b.def.supply) cap += b.def.supply;
      for (let q=0;q<b.queue.length;q++) res += UNITS[b.queue[q].key].supply;
    }
    for (let i=0;i<units.length;i++) if (units[i].player===p) used += units[i].def.supply;
    S.supCap[p]=cap; S.supUsed[p]=used; S.supRes[p]=res;
  }
}

function supplyFree(p){ return S.supCap[p] - S.supUsed[p] - S.supRes[p]; }
function supplyBlocked(p){ return S.supUsed[p] + S.supRes[p] > S.supCap[p]; }

/* knowledge of enemy buildings (last-known silhouettes) */
const knowledge = [new Map(), new Map()];
function updateKnowledge(){
  for (let p=0;p<2;p++){
    const kn = knowledge[p];
    for (let i=0;i<buildings.length;i++){
      const b = buildings[i];
      if (b.player===p || b.dead) continue;
      if (visTo(p,b.x,b.z)) kn.set(b.id, {x:b.x, z:b.z, key:b.key, player:b.player, ref:b});
    }
    for (const [id,g] of kn){
      if (g.ref && !g.ref.dead) continue;
      if (visTo(p, g.x, g.z)) kn.delete(id);
    }
  }
}

/* ================================================================
   RENDERER
   ================================================================ */
const drawList = [];
const V8 = [];
for (let i=0;i<8;i++) V8.push({x:0,y:0,z:0});
const _c = {x:0,y:0,z:0};
const LIGHT = (function(){ const v=[0.36,0.87,-0.34]; const m=Math.hypot(v[0],v[1],v[2]); return [v[0]/m,v[1]/m,v[2]/m]; })();

function camDepth(x,y,z){ return (x-cam.posX)*cam.fx + (y-cam.posY)*cam.fy + (z-cam.posZ)*cam.fz; }

/* --- starfield backdrop --- */
const starCanvas = document.createElement('canvas');
starCanvas.width = 420; starCanvas.height = 180;
(function(){
  const sc = starCanvas.getContext('2d');
  sc.fillStyle = '#000'; sc.fillRect(0,0,420,180);
  for (let i=0;i<220;i++){
    const x = rnd()*420, y = rnd()*180, r = rnd()*1.5+0.3;
    sc.fillStyle = `rgba(${180+rnd()*75|0},${210+rnd()*45|0},255,${0.25+rnd()*0.7})`;
    sc.beginPath(); sc.arc(x,y,r,0,TAU); sc.fill();
  }
})();

function clipNear(poly, zn){
  const out = [];
  for (let i=0;i<poly.length;i++){
    const a = poly[i], b = poly[(i+1)%poly.length];
    const ain = a.z >= zn, bin = b.z >= zn;
    if (ain) out.push(a);
    if (ain !== bin){
      const t = (zn - a.z)/(b.z - a.z);
      out.push({x:a.x+(b.x-a.x)*t, y:a.y+(b.y-a.y)*t, z:zn});
    }
  }
  return out;
}

function drawSky(){
  const g = ctx.createLinearGradient(0,0,0,CH);
  g.addColorStop(0,'#060a17');
  g.addColorStop(0.30,'#0d1c34');
  g.addColorStop(0.62,'#28405e');
  g.addColorStop(1,'#4b6480');
  ctx.fillStyle = g; ctx.fillRect(0,0,CW,CH);
  ctx.save();
  ctx.globalAlpha = 0.75;
  ctx.drawImage(starCanvas, 0, 0, CW, Math.max(1,CH*0.42));
  ctx.restore();
}

function camSpace(x,y,z,out){
  const dx=x-cam.posX, dy=y-cam.posY, dz=z-cam.posZ;
  out.x = dx*cam.rx + dy*cam.ry + dz*cam.rz;
  out.y = dx*cam.ux + dy*cam.uy + dz*cam.uz;
  out.z = dx*cam.fx + dy*cam.fy + dz*cam.fz;
  return out;
}

function drawGroundPlane(){
  const size = 1500;
  const cs = [];
  const corners = [[cam.posX-size, cam.posZ-size],[cam.posX+size, cam.posZ-size],
                   [cam.posX+size, cam.posZ+size],[cam.posX-size, cam.posZ+size]];
  for (const c of corners) cs.push(camSpace(c[0],0,c[1],{x:0,y:0,z:0}));
  const poly = clipNear(cs, 1.5);
  if (poly.length < 3) return;
  const g = ctx.createLinearGradient(0,0,0,CH);
  g.addColorStop(0,'#2b4360');
  g.addColorStop(0.35,'#243b56');
  g.addColorStop(1,'#16273d');
  ctx.fillStyle = g;
  ctx.beginPath();
  for (let i=0;i<poly.length;i++){
    const p = poly[i];
    const sx = HALF_W + FOCAL*p.x/p.z, sy = HALF_H - FOCAL*p.y/p.z;
    if (i) ctx.lineTo(sx,sy); else ctx.moveTo(sx,sy);
  }
  ctx.closePath(); ctx.fill();
}

function drawGrid(){
  const a = 1 - clamp((cam.dist-140)/110, 0, 1);
  if (a <= 0.02) return;
  const tl = {x:0,z:0}, br = {x:0,z:0};
  screenToGround(0, CH, tl); screenToGround(CW, 0, br);
  const x0 = Math.min(tl.x, br.x) - 60, x1 = Math.max(tl.x, br.x) + 60;
  const z0 = Math.min(tl.z, br.z) - 60, z1 = Math.max(tl.z, br.z) + 60;
  const step = 10;
  ctx.lineWidth = 1;
  for (let x=Math.floor(x0/step)*step; x<=x1; x+=step){
    const major = (x % 40 === 0);
    ctx.strokeStyle = major ? `rgba(90,215,255,${0.16*a})` : `rgba(90,215,255,${0.075*a})`;
    ctx.beginPath();
    let started = false;
    for (let z=z0; z<=z1; z+=step){
      if (project(x,0,z,_c)){ started ? ctx.lineTo(_c.x,_c.y) : ctx.moveTo(_c.x,_c.y); started=true; }
      else started = false;
    }
    ctx.stroke();
  }
  for (let z=Math.floor(z0/step)*step; z<=z1; z+=step){
    const major = (z % 40 === 0);
    ctx.strokeStyle = major ? `rgba(90,215,255,${0.16*a})` : `rgba(90,215,255,${0.075*a})`;
    ctx.beginPath();
    let started = false;
    for (let x=x0; x<=x1; x+=step){
      if (project(x,0,z,_c)){ started ? ctx.lineTo(_c.x,_c.y) : ctx.moveTo(_c.x,_c.y); started=true; }
      else started = false;
    }
    ctx.stroke();
  }
}

function drawGroundDetail(){
  /* resource deposit glows */
  for (let i=0;i<nodes.length;i++){
    const n = nodes[i];
    if (!seenTo(0,n.x,n.z)) continue;
    if (n.amount <= 0) continue;
    const t = 0.35 + 0.65*(n.amount/n.max);
    if (!project(n.x, 0.1, n.z, _c)) continue;
    const edge = {x:0,y:0,z:0};
    project(n.x + n.r*t, 0.1, n.z, edge);
    const r = Math.abs(edge.x - _c.x) + 6;
    if (_c.x < -r || _c.x > CW+r || _c.y < -r || _c.y > CH+r) continue;
    const g = ctx.createRadialGradient(_c.x,_c.y,0,_c.x,_c.y,r);
    g.addColorStop(0,`rgba(64,226,255,${0.30*t})`);
    g.addColorStop(1,'rgba(64,226,255,0)');
    ctx.fillStyle = g; ctx.beginPath(); ctx.arc(_c.x,_c.y,r,0,TAU); ctx.fill();
  }
}

/* --- model drawing --- */
function drawBoxes(boxes, x, y, z, yaw, alpha, ghost, maxBoxes){
  const cy = Math.cos(yaw), sy = Math.sin(yaw);
  if (alpha !== undefined) ctx.globalAlpha = alpha;
  const nb = maxBoxes ? Math.min(boxes.length, maxBoxes) : boxes.length;
  for (let bi=0; bi<nb; bi++){
    const b = boxes[bi];
    const hx = b.w/2, hz = b.d/2;
    /* corners: index order matches BOX_FACES */
    const lx = [-hx,hx,hx,-hx,-hx,hx,hx,-hx];
    const ly = [b.y,b.y,b.y,b.y,b.y+b.h,b.y+b.h,b.y+b.h,b.y+b.h];
    const lz = [-hz,-hz,hz,hz,-hz,-hz,hz,hz];
    let ok = true;
    for (let i=0;i<8;i++){
      const px = x + lx[i]*cy + lz[i]*sy;
      const pz = z - lx[i]*sy + lz[i]*cy;
      const py = y + ly[i];
      const dx = px-cam.posX, dy = py-cam.posY, dz = pz-cam.posZ;
      const vx = dx*cam.rx + dy*cam.ry + dz*cam.rz;
      const vy = dx*cam.ux + dy*cam.uy + dz*cam.uz;
      const vz = dx*cam.fx + dy*cam.fy + dz*cam.fz;
      if (vz < 1.5){ ok = false; break; }
      V8[i].x = HALF_W + FOCAL*vx/vz;
      V8[i].y = HALF_H - FOCAL*vy/vz;
      V8[i].z = vz;
    }
    if (!ok) continue;
    for (let f=0; f<5; f++){
      const face = BOX_FACES[f], n = face.n;
      const nx = n[0]*cy + n[2]*sy, ny = n[1], nz = -n[0]*sy + n[2]*cy;
      /* backface cull using face center in world space */
      const cxi = face.i;
      const cxw = x + ((lx[cxi[0]]+lx[cxi[2]])/2)*cy + ((lz[cxi[0]]+lz[cxi[2]])/2)*sy;
      const czw = z - ((lx[cxi[0]]+lx[cxi[2]])/2)*sy + ((lz[cxi[0]]+lz[cxi[2]])/2)*cy;
      const cyw = y + ((ly[cxi[0]]+ly[cxi[2]])/2);
      const dot = nx*(cxw-cam.posX) + ny*(cyw-cam.posY) + nz*(czw-cam.posZ);
      if (dot >= 0) continue;
      const shade = 0.5 + 0.5*Math.max(0, nx*LIGHT[0] + ny*LIGHT[1] + nz*LIGHT[2]);
      const idx = clamp(Math.round(shade*7),0,7);
      ctx.fillStyle = ghost ? 'rgba(122,158,196,0.16)' : b.sh[idx];
      ctx.beginPath();
      const i0 = cxi[0], i1 = cxi[1], i2 = cxi[2], i3 = cxi[3];
      ctx.moveTo(V8[i0].x,V8[i0].y);
      ctx.lineTo(V8[i1].x,V8[i1].y);
      ctx.lineTo(V8[i2].x,V8[i2].y);
      ctx.lineTo(V8[i3].x,V8[i3].y);
      ctx.closePath();
      ctx.fill();
    }
  }
  if (alpha !== undefined) ctx.globalAlpha = 1;
}

function groundRing(x, z, r, color, width, y){
  ctx.beginPath();
  let started = false, any = false;
  for (let i=0;i<=14;i++){
    const a = i/14*TAU;
    if (project(x + Math.cos(a)*r, y||0.08, z + Math.sin(a)*r, _c)){
      started ? ctx.lineTo(_c.x,_c.y) : ctx.moveTo(_c.x,_c.y);
      started = true; any = true;
    } else started = false;
  }
  if (!any) return;
  ctx.closePath();
  ctx.strokeStyle = color; ctx.lineWidth = width||2; ctx.stroke();
}

function groundDisc(x, z, r, color, y){
  ctx.beginPath();
  let started = false, any = false;
  for (let i=0;i<=12;i++){
    const a = i/12*TAU;
    if (project(x + Math.cos(a)*r, y||0.05, z + Math.sin(a)*r, _c)){
      started ? ctx.lineTo(_c.x,_c.y) : ctx.moveTo(_c.x,_c.y);
      started = true; any = true;
    } else started = false;
  }
  if (!any) return;
  ctx.closePath();
  ctx.fillStyle = color; ctx.fill();
}

/* --- main object pass --- */
function entityVisibleToHuman(e){
  if (e.player === HUMAN) return true;
  if (e.kind === 'unit') return visTo(0, e.x, e.z);
  return visTo(0, e.x, e.z);
}

function drawObjects(){
  drawList.length = 0;
  for (let i=0;i<rocks.length;i++){
    const r = rocks[i];
    if (!seenTo(0,r.x,r.z)) continue;
    const d = camDepth(r.x, r.h*0.5, r.z);
    if (d < 2) continue;
    drawList.push({d, k:0, e:r});
  }
  for (let i=0;i<nodes.length;i++){
    const n = nodes[i];
    if (n.amount<=0 || !seenTo(0,n.x,n.z)) continue;
    const d = camDepth(n.x, 5, n.z);
    if (d < 2) continue;
    drawList.push({d, k:1, e:n});
  }
  for (let i=0;i<buildings.length;i++){
    const b = buildings[i];
    const vis = b.player===HUMAN || visTo(0,b.x,b.z);
    if (!vis) continue;
    const d = camDepth(b.x, 4, b.z);
    if (d < 2) continue;
    drawList.push({d, k:2, e:b, ghost:false});
  }
  /* last-known enemy building silhouettes */
  for (const [id,g] of knowledge[0]){
    if (g.ref && !g.ref.dead && visTo(0,g.x,g.z)) continue;
    const d = camDepth(g.x, 4, g.z);
    if (d < 2) continue;
    drawList.push({d, k:2, e:g, ghost:true});
  }
  for (let i=0;i<units.length;i++){
    const u = units[i];
    if (u.player!==HUMAN && !visTo(0,u.x,u.z)) continue;
    const d = camDepth(u.x, u.y+1.5, u.z);
    if (d < 2) continue;
    drawList.push({d, k:3, e:u});
  }
  for (let i=0;i<projs.length;i++){
    const p = projs[i];
    const d = camDepth(p.x,p.y,p.z);
    if (d < 2) continue;
    drawList.push({d, k:4, e:p});
  }
  drawList.sort((a,b)=>b.d-a.d);

  for (let i=0;i<drawList.length;i++){
    const it = drawList[i];
    switch(it.k){
      case 0: drawRock(it.e); break;
      case 1: drawNode(it.e); break;
      case 2: drawBuilding(it.e, it.ghost); break;
      case 3: drawUnit(it.e); break;
      case 4: drawProjectile(it.e); break;
    }
  }
}

function drawRock(r){
  for (let f=0; f<r.faces.length; f++){
    const face = r.faces[f], p = face.p;
    let dz = 0;
    for (let i=0;i<p.length;i++) dz += camDepth(p[i][0],p[i][1],p[i][2]);
    dz /= p.length;
    if (dz < 2) continue;
    let ok = true;
    ctx.beginPath();
    for (let i=0;i<p.length;i++){
      if (!project(p[i][0],p[i][1],p[i][2],_c)){ ok=false; break; }
      i ? ctx.lineTo(_c.x,_c.y) : ctx.moveTo(_c.x,_c.y);
    }
    if (!ok) continue;
    ctx.closePath();
    ctx.fillStyle = face.c;
    ctx.fill();
    ctx.strokeStyle = 'rgba(10,16,28,0.35)'; ctx.lineWidth = 1; ctx.stroke();
  }
}

const _oneBox = [null];
function drawNode(n){
  const t = 0.32 + 0.68*Math.max(0, n.amount/n.max);
  for (let i=0;i<n.crystals.length;i++){
    const c = n.crystals[i];
    if (!c.bx) c.bx = box(0,0,0,1,1,1,[120,236,255]);
    c.bx.w = c.bx.d = c.w*(0.55+0.45*t);
    c.bx.h = c.h*t;
    _oneBox[0] = c.bx;
    drawBoxes(_oneBox, n.x + c.dx, 0, n.z + c.dz, c.rot, 1);
  }
}

function drawBuilding(b, ghost){
  const model = getModel(b.key, b.player === HUMAN || ghost ? (ghost ? b.player : b.player) : b.player);
  const prog = ghost ? 1 : (b.built ? 1 : b.progress);
  if (!ghost && !b.built){
    drawDisc(b.x, b.z, b.rad*1.15, 'rgba(53,224,255,0.10)');
    drawBoxes(model, b.x, 0, b.z, 0, 0.22 + 0.5*prog, false);
    if (project(b.x, MODEL_H[b.key]*prog + 2, b.z, _c)){
      const w = 74, h = 7;
      ctx.fillStyle = 'rgba(6,16,28,0.85)';
      ctx.fillRect(_c.x-w/2, _c.y-h/2, w, h);
      ctx.fillStyle = '#35e0ff';
      ctx.fillRect(_c.x-w/2+1, _c.y-h/2+1, (w-2)*prog, h-2);
      ctx.fillStyle = '#cfeaff'; ctx.font = '10px sans-serif'; ctx.textAlign='center';
      ctx.fillText('BUILDING ' + Math.round(prog*100) + '%', _c.x, _c.y - 9);
    }
    return;
  }
  drawBoxes(model, b.x, 0, b.z, 0, ghost ? 1 : 1, ghost);
  if (ghost) return;

  /* hit flash */
  if (b.flash > 0 && project(b.x, MODEL_H[b.key]*0.5, b.z, _c)){
    ctx.globalAlpha = Math.min(0.5, b.flash*1.6);
    ctx.fillStyle = '#ffd0a0';
    ctx.beginPath(); ctx.arc(_c.x,_c.y, MODEL_H[b.key]*0.9, 0, TAU); ctx.fill();
    ctx.globalAlpha = 1;
  }
  if (b.player === HUMAN && (b.selected || b.hp < b.maxHp)) drawHealthBar(b, MODEL_H[b.key]+1.6);
  if (b.player === HUMAN && b.selected) groundRing(b.x, b.z, b.rad*1.28, 'rgba(90,240,255,0.85)', 2);
  if (b.rally && b.player === HUMAN){
    groundRing(b.rally.x, b.rally.z, 2.4, 'rgba(120,255,170,0.8)', 2);
    if (project(b.rally.x, 0.2, b.rally.z, _c) && project(b.x, MODEL_H[b.key]*0.6, b.z, _p2)){
      ctx.strokeStyle = 'rgba(120,255,170,0.45)'; ctx.lineWidth = 1.5;
      ctx.beginPath(); ctx.moveTo(_p2.x,_p2.y); ctx.lineTo(_c.x,_c.y); ctx.stroke();
    }
  }
}
const _p2 = {x:0,y:0,z:0};
function drawDisc(x,z,r,color){ groundDisc(x,z,r,color); }

function drawUnit(u){
  const lod = FOCAL*(MODEL_H[u.key]||3)/Math.max(6,camDepth(u.x,u.y,u.z));
  /* shadow */
  if (u.def.fly) groundDisc(u.x, u.z, u.rad*1.25, 'rgba(6,12,22,0.30)');
  else groundDisc(u.x, u.z, u.rad*1.15, 'rgba(6,12,22,0.34)');
  const model = getModel(u.key, u.player);
  drawBoxes(model, u.x, u.y, u.z, u.yaw, 1, false, lod < 5.2 ? 1 : (lod < 9 ? 3 : 0));
  if (u.flash > 0 && project(u.x, u.y+1.4, u.z, _c)){
    ctx.globalAlpha = Math.min(0.55, u.flash*1.8);
    ctx.fillStyle = u.player===HUMAN ? '#bfefff' : '#ffc9a0';
    ctx.beginPath(); ctx.arc(_c.x,_c.y, Math.max(4,lod*0.8), 0, TAU); ctx.fill();
    ctx.globalAlpha = 1;
  }
  if (u.player === HUMAN && u.selected) groundRing(u.x, u.z, u.rad*1.55, 'rgba(90,240,255,0.9)', 2);
  if (u.player !== HUMAN && u.targeted) groundRing(u.x, u.z, u.rad*1.55, 'rgba(255,110,110,0.8)', 2);
  if (u.player === HUMAN && (u.selected || u.hp < u.maxHp)) drawHealthBar(u, (MODEL_H[u.key]||3) + 1.4);
  if (u.carry > 0 && u.player === HUMAN && project(u.x, u.y+MODEL_H[u.key]+0.6, u.z, _c)){
    ctx.fillStyle = '#7ef2ff';
    ctx.fillRect(_c.x-3, _c.y-3, 6, 6);
  }
}

function drawHealthBar(e, yOff){
  if (!project(e.x, (e.y||0) + yOff, e.z, _c)) return;
  const w = clamp(FOCAL*2.6/Math.max(20,_c.z), 16, 46), h = 4;
  const frac = clamp(e.hp/e.maxHp, 0, 1);
  ctx.fillStyle = 'rgba(4,10,18,0.82)';
  ctx.fillRect(_c.x-w/2-1, _c.y-1, w+2, h+2);
  ctx.fillStyle = e.player===HUMAN ? (frac>0.35?'#43e08a':'#ffb347') : (frac>0.35?'#ff6b52':'#ff9d5c');
  ctx.fillRect(_c.x-w/2, _c.y, w*frac, h);
}

function drawProjectile(p){
  const a = project(p.px,p.py,p.pz,_c), b = project(p.x,p.y,p.z,_p2);
  if (!b) return;
  if (a){
    ctx.strokeStyle = p.color; ctx.lineWidth = p.size; ctx.globalAlpha = 0.75;
    ctx.beginPath(); ctx.moveTo(_c.x,_c.y); ctx.lineTo(_p2.x,_p2.y); ctx.stroke();
    ctx.globalAlpha = 1;
  }
  ctx.fillStyle = p.color;
  ctx.beginPath(); ctx.arc(_p2.x,_p2.y, p.size*1.15, 0, TAU); ctx.fill();
}

function drawEffects(){
  for (let i=0;i<fxList.length;i++){
    const f = fxList[i];
    const k = f.t/f.dur;
    if (f.k === 'boom'){
      if (project(f.x, f.y+0.4, f.z, _c)){
        const r = f.r*(0.35+k*1.1);
        const edge = {x:0,y:0,z:0};
        project(f.x + r, f.y+0.4, f.z, edge);
        const rr2 = Math.abs(edge.x-_c.x) + 3;
        const g = ctx.createRadialGradient(_c.x,_c.y,0,_c.x,_c.y,rr2);
        g.addColorStop(0, `rgba(${f.c[0]},${f.c[1]},${f.c[2]},${0.75*(1-k)})`);
        g.addColorStop(1, `rgba(${f.c[0]},${f.c[1]},${f.c[2]},0)`);
        ctx.fillStyle = g; ctx.beginPath(); ctx.arc(_c.x,_c.y,rr2,0,TAU); ctx.fill();
      }
      groundRing(f.x, f.z, f.r*(0.4+k*1.6), `rgba(${f.c[0]},${f.c[1]},${f.c[2]},${0.6*(1-k)})`, 2.4, 0.12);
    } else if (f.k === 'spark'){
      if (project(f.x, f.y, f.z, _c)){
        ctx.globalAlpha = 1-k;
        ctx.fillStyle = f.color;
        ctx.fillRect(_c.x-f.s/2, _c.y-f.s/2, f.s, f.s);
        ctx.globalAlpha = 1;
      }
    } else if (f.k === 'text'){
      if (project(f.x, f.y, f.z, _c)){
        ctx.globalAlpha = Math.min(1, (1-k)*1.6);
        ctx.font = 'bold 13px sans-serif'; ctx.textAlign = 'center';
        ctx.fillStyle = f.color;
        ctx.fillText(f.str, _c.x, _c.y - k*30);
        ctx.globalAlpha = 1;
      }
    } else if (f.k === 'ring'){
      groundRing(f.x, f.z, f.r*(0.3+k), `rgba(${f.c[0]},${f.c[1]},${f.c[2]},${0.8*(1-k)})`, 2, 0.1);
    }
  }
}

/* --- fog of war overlay (batched into two fills) --- */
/* append a ground-level polygon (world coords) to the current path, near-clipped */
function groundPolyPath(q){
  const cs = [];
  for (let i=0;i<q.length;i++) cs.push(camSpace(q[i][0], 0.15, q[i][1], {x:0,y:0,z:0}));
  const poly = clipNear(cs, 1.5);
  if (poly.length < 3) return;
  for (let i=0;i<poly.length;i++){
    const p = poly[i];
    const sx = HALF_W + FOCAL*p.x/p.z, sy = HALF_H - FOCAL*p.y/p.z;
    if (i) ctx.lineTo(sx,sy); else ctx.moveTo(sx,sy);
  }
  ctx.closePath();
}

function drawFog(){
  /* ground beyond the map border is always unexplored: fill the ring around the map */
  {
    const size = 1500;
    const outer = [[cam.posX-size, cam.posZ-size],[cam.posX+size, cam.posZ-size],
                   [cam.posX+size, cam.posZ+size],[cam.posX-size, cam.posZ+size]];
    const inner = [[0,0],[WORLD,0],[WORLD,WORLD],[0,WORLD]];
    ctx.fillStyle = 'rgba(3,7,14,0.945)';
    ctx.beginPath();
    groundPolyPath(outer);
    groundPolyPath(inner);
    ctx.fill('evenodd');
  }
  const step = (FOCAL*CELL/cam.dist) < 7 ? 2 : 1;
  const paths = [[],[]];
  for (let cz=0; cz<FOGN; cz+=step){
    for (let cx=0; cx<FOGN; cx+=step){
      let a = 0;
      for (let bz=0; bz<step; bz++){
        for (let bx=0; bx<step; bx++){
          const i = (cx+bx) + FOGN*(cz+bz);
          const v = fog[0].vis[i], e = fog[0].exp[i];
          const av = v ? 0 : (e ? 1 : 2);
          if (av > a) a = av;
        }
      }
      if (a === 0) continue;
      const x0 = cx*CELL, z0 = cz*CELL, x1 = (cx+step)*CELL + 0.02, z1 = (cz+step)*CELL + 0.02;
      const q = [[x0,z0],[x1,z0],[x1,z1],[x0,z1]];
      let pts = [];
      let ok = true;
      for (let i=0;i<4;i++){
        if (!project(q[i][0], 0.15, q[i][1], _c)){ ok = false; break; }
        pts.push(_c.x, _c.y);
      }
      if (!ok){
        /* quad crosses the near plane: clip it rather than dropping the fog */
        const cs = [];
        for (let i=0;i<4;i++) cs.push(camSpace(q[i][0], 0.15, q[i][1], {x:0,y:0,z:0}));
        const poly = clipNear(cs, 1.5);
        if (poly.length < 3) continue;
        pts = [];
        for (let i=0;i<poly.length;i++){
          const p = poly[i];
          pts.push(HALF_W + FOCAL*p.x/p.z, HALF_H - FOCAL*p.y/p.z);
        }
      }
      /* consistent winding for nonzero fill */
      let area = 0;
      for (let i=0,n=pts.length/2;i<n;i++){
        const j = (i+1)%n;
        area += pts[i*2]*pts[j*2+1] - pts[j*2]*pts[i*2+1];
      }
      if (area < 0){
        const rev = [];
        for (let i=pts.length/2-1;i>=0;i--) rev.push(pts[i*2], pts[i*2+1]);
        pts = rev;
      }
      paths[a-1].push(pts);
    }
  }
  const cols = ['rgba(4,9,18,0.46)','rgba(3,7,14,0.945)'];
  for (let b=0;b<2;b++){
    const list = paths[b];
    if (!list.length) continue;
    ctx.fillStyle = cols[b];
    ctx.beginPath();
    for (let i=0;i<list.length;i++){
      const p = list[i];
      ctx.moveTo(p[0],p[1]);
      for (let j=1,n=p.length/2;j<n;j++) ctx.lineTo(p[j*2],p[j*2+1]);
      ctx.closePath();
    }
    ctx.fill();
  }
}

/* --- in-world overlay: drag select, ghosts, order markers --- */
function drawWorldOverlay(){
  /* placement ghost */
  if (placing && mouse.world){
    const bDef = BUILDS[placing];
    const ok = canPlace(placing, mouse.world.x, mouse.world.z);
    const x = mouse.world.x, z = mouse.world.z;
    drawBoxes(getModel(placing, HUMAN), x, 0, z, 0, 0.42, false);
    groundRing(x, z, bDef.w*0.62, ok ? 'rgba(80,255,150,0.9)' : 'rgba(255,90,90,0.95)', 2.4);
    if (project(x, MODEL_H[placing]+1, z, _c)){
      ctx.font = 'bold 13px sans-serif'; ctx.textAlign='center';
      ctx.fillStyle = ok ? '#8dffb8' : '#ff8a8a';
      ctx.fillText(ok ? 'Click to build · right-click to cancel' : invalidReason, _c.x, _c.y);
    }
    if (mouse.down && dragSel.active){
      ctx.strokeStyle = 'rgba(120,240,255,0.75)'; ctx.lineWidth = 1.6;
      ctx.setLineDash([7,6]);
      ctx.strokeRect(Math.min(dragSel.x0,dragSel.x1), Math.min(dragSel.y0,dragSel.y1),
        Math.abs(dragSel.x1-dragSel.x0), Math.abs(dragSel.y1-dragSel.y0));
      ctx.setLineDash([]);
    }
  } else if (dragSel.active){
    const x0 = Math.min(dragSel.x0,dragSel.x1), y0 = Math.min(dragSel.y0,dragSel.y1);
    const w = Math.abs(dragSel.x1-dragSel.x0), h = Math.abs(dragSel.y1-dragSel.y0);
    ctx.fillStyle = 'rgba(60,210,255,0.10)';
    ctx.fillRect(x0,y0,w,h);
    ctx.strokeStyle = 'rgba(120,240,255,0.85)'; ctx.lineWidth = 1.4;
    ctx.strokeRect(x0,y0,w,h);
  }
  /* order markers */
  for (let i=0;i<beacons.length;i++){
    const bc = beacons[i];
    const k = bc.t/bc.dur;
    groundRing(bc.x, bc.z, 2 + k*7, `rgba(${bc.c[0]},${bc.c[1]},${bc.c[2]},${1-k})`, 2.2);
  }
}

/* --- minimap --- */
const fogImg = fogCtx.createImageData(FOGN, FOGN);
function drawMinimap(){
  const MM = 206;
  mmx.clearRect(0,0,MM,MM);
  mmx.fillStyle = '#0d2033';
  mmx.fillRect(0,0,MM,MM);
  /* terrain hints */
  const sc = MM/WORLD;
  for (let i=0;i<rocks.length;i++){
    const r = rocks[i];
    if (!seenTo(0,r.x,r.z)) continue;
    mmx.fillStyle = '#22344a';
    mmx.fillRect(r.x*sc-1.5, r.z*sc-1.5, 3, 3);
  }
  for (let i=0;i<nodes.length;i++){
    const n = nodes[i];
    if (n.amount<=0 || !seenTo(0,n.x,n.z)) continue;
    mmx.fillStyle = '#3fe4ff';
    mmx.fillRect(n.x*sc-2.5, n.z*sc-2.5, 5, 5);
  }
  /* fog */
  const d = fogImg.data;
  for (let i=0;i<FOGN*FOGN;i++){
    const v = fog[0].vis[i], e = fog[0].exp[i];
    const o = i*4;
    d[o] = 3; d[o+1] = 7; d[o+2] = 15;
    d[o+3] = v ? 0 : (e ? 128 : 252);
  }
  fogCtx.putImageData(fogImg, 0, 0);
  mmx.imageSmoothingEnabled = false;
  mmx.drawImage(fogCanvas, 0, 0, MM, MM);

  /* buildings & units */
  for (let i=0;i<buildings.length;i++){
    const b = buildings[i];
    const vis = b.player===HUMAN || visTo(0,b.x,b.z);
    if (!vis) continue;
    mmx.fillStyle = b.player===HUMAN ? '#59f0ff' : '#ff7c4a';
    const s = b.key==='hq' ? 7 : 5;
    mmx.fillRect(b.x*sc-s/2, b.z*sc-s/2, s, s);
  }
  for (const [id,g] of knowledge[0]){
    if (g.ref && !g.ref.dead && visTo(0,g.x,g.z)) continue;
    mmx.fillStyle = 'rgba(255,124,74,0.42)';
    mmx.fillRect(g.x*sc-2.5, g.z*sc-2.5, 5, 5);
  }
  for (let i=0;i<units.length;i++){
    const u = units[i];
    if (u.player!==HUMAN && !visTo(0,u.x,u.z)) continue;
    mmx.fillStyle = u.player===HUMAN ? '#7ef2ff' : '#ff6b52';
    mmx.fillRect(u.x*sc-1.4, u.z*sc-1.4, 2.8, 2.8);
  }
  /* camera footprint */
  const corners = [[0,0],[CW,0],[CW,CH],[0,CH]];
  mmx.save();
  mmx.beginPath(); mmx.rect(0,0,MM,MM); mmx.clip();
  mmx.beginPath();
  for (let i=0;i<4;i++){
    const g = {x:0,z:0};
    screenToGround(corners[i][0], corners[i][1], g);
    const px = g.x*sc, py = g.z*sc;
    i ? mmx.lineTo(px,py) : mmx.moveTo(px,py);
  }
  mmx.closePath();
  mmx.strokeStyle = 'rgba(190,240,255,0.9)'; mmx.lineWidth = 1.6; mmx.stroke();
  mmx.restore();
}

/* ================================================================
   SIMULATION
   ================================================================ */
function nextOrder(u){ u.orders.shift(); u.order = u.orders[0] || null; }
function clearOrders(u){ u.orders.length = 0; u.order = null; u.target = null; u.attackMove = false; }

function nearestDropoff(p, x, z){
  let best = null, bestD = 1e18;
  for (let i=0;i<buildings.length;i++){
    const b = buildings[i];
    if (b.player!==p || !b.built || !b.def.drop) continue;
    const d = dist2(x,z,b.x,b.z);
    if (d < bestD){ bestD = d; best = b; }
  }
  return best;
}
function nearestNode(x, z, ignore){
  let best = null, bestD = 1e18;
  for (let i=0;i<nodes.length;i++){
    const n = nodes[i];
    if (n.amount <= 0 || n === ignore) continue;
    const d = dist2(x,z,n.x,n.z);
    if (d < bestD){ bestD = d; best = n; }
  }
  return best;
}

/* -------- steering -------- */
function steerTo(u, dt, tx, tz, arriveR){
  const dx = tx-u.x, dz = tz-u.z;
  const d = Math.sqrt(dx*dx+dz*dz);
  if (d <= arriveR) return true;
  let dirx = dx/d, dirz = dz/d;
  const speed = u.def.speed * unitSpeedMul(u.player);

  if (!u.def.fly){
    /* obstacle avoidance */
    let ax = 0, az = 0;
    const look = 5 + u.rad*2;
    oHashQuery(u.x, u.z, 16, (o)=>{
      const ox = u.x-o.x, oz = u.z-o.z;
      const od = Math.sqrt(ox*ox+oz*oz) || 0.001;
      const clear = o.r + u.rad;
      if (od > clear + look) return;
      const ahead = (-ox)*dirx + (-oz)*dirz;
      if (ahead < -1) return;                       // behind us
      const prox = clamp((clear + look - od)/(clear + look), 0, 1);
      const nx = ox/od, nz = oz/od;
      ax += nx*prox*1.35; az += nz*prox*1.35;
      /* tangential slide */
      const tx2 = -nz, tz2 = nx;
      const side = (dirx*tx2 + dirz*tz2) >= 0 ? 1 : -1;
      ax += tx2*side*prox*0.95; az += tz2*side*prox*0.95;
    });
    dirx += ax; dirz += az;
    const m = Math.sqrt(dirx*dirx+dirz*dirz) || 1;
    dirx /= m; dirz /= m;

    /* unit separation */
    let sx = 0, sz = 0;
    hashQuery(u.x, u.z, u.rad*2.6, (o)=>{
      if (o === u || o.def.fly) return;
      const dx2 = u.x-o.x, dz2 = u.z-o.z;
      const dd = Math.sqrt(dx2*dx2+dz2*dz2) || 0.001;
      const min = u.rad + o.rad;
      if (dd < min*1.25){
        const k = (min*1.25 - dd)/(min*1.25);
        sx += dx2/dd*k; sz += dz2/dd*k;
      }
    });
    dirx += sx*0.85; dirz += sz*0.85;
    const m2 = Math.sqrt(dirx*dirx+dirz*dirz) || 1;
    dirx /= m2; dirz /= m2;
  }

  const desiredVx = dirx*speed, desiredVz = dirz*speed;
  const accel = u.def.fly ? 6.5 : 5.5;
  u.vx += (desiredVx-u.vx)*Math.min(1, dt*accel);
  u.vz += (desiredVz-u.vz)*Math.min(1, dt*accel);
  u.x += u.vx*dt; u.z += u.vz*dt;

  if (!u.def.fly){
    oHashQuery(u.x, u.z, u.rad+3, (o)=>{
      const dx3 = u.x-o.x, dz3 = u.z-o.z;
      const dd = Math.sqrt(dx3*dx3+dz3*dz3) || 0.001;
      const min = o.r + u.rad;
      if (dd < min){ u.x += dx3/dd*(min-dd); u.z += dz3/dd*(min-dd); }
    });
    hashQuery(u.x, u.z, u.rad*1.15, (o)=>{
      if (o === u || o.def.fly) return;
      const dx4 = u.x-o.x, dz4 = u.z-o.z;
      const dd = Math.sqrt(dx4*dx4+dz4*dz4) || 0.001;
      const min = (u.rad + o.rad)*0.62;
      if (dd < min){ u.x += dx4/dd*(min-dd)*0.5; u.z += dz4/dd*(min-dd)*0.5; }
    });
    u.x = clamp(u.x, 3, WORLD-3); u.z = clamp(u.z, 3, WORLD-3);
  } else {
    u.x = clamp(u.x, 2, WORLD-2); u.z = clamp(u.z, 2, WORLD-2);
  }
  const sp = Math.sqrt(u.vx*u.vx+u.vz*u.vz);
  if (sp > 0.4) u.yaw = Math.atan2(u.vx, u.vz);
  return d <= arriveR + sp*dt*1.2;
}

function stopMoving(u, dt){
  u.vx *= Math.max(0, 1-dt*7); u.vz *= Math.max(0, 1-dt*7);
}

/* -------- targeting & damage -------- */
function canHit(shooter, t){
  const tt = shooter.def.targets;
  const isAir = t.kind==='unit' && !!t.def.fly;
  return tt==='both' || (tt==='air' && isAir) || (tt==='ground' && !isAir);
}
function acquireTarget(u, radius){
  const sight = radius || u.sight;
  let best = null, bestD = 1e18;
  const consider = (o)=>{
    if (o.dead || o.player===u.player) return;
    if (!canHit(u,o)) return;
    if (!visTo(u.player,o.x,o.z)) return;
    const d = dist2(u.x,u.z,o.x,o.z);
    if (d < bestD){ bestD = d; best = o; }
  };
  hashQuery(u.x, u.z, sight, consider);
  for (let i=0;i<buildings.length;i++){
    const b = buildings[i];
    if (b.dead || b.player===u.player) continue;
    if (!canHit(u,b)) continue;
    if (!visTo(u.player,b.x,b.z)) continue;
    const d = dist2(u.x,u.z,b.x,b.z);
    if (d < bestD && d < sight*sight){ bestD = d; best = b; }
  }
  return best;
}
function acquireForBuilding(b){
  const sight = b.def.range + 4;
  let best = null, bestD = 1e18;
  hashQuery(b.x, b.z, sight, (o)=>{
    if (o.dead || o.player===b.player) return;
    if (!canHit(b,o)) return;
    if (!visTo(b.player,o.x,o.z)) return;
    const d = dist2(b.x,b.z,o.x,o.z);
    if (d < bestD){ bestD = d; best = o; }
  });
  return best;
}

function applyDamage(t, amount, dmgType, srcPlayer, src){
  if (!t || t.dead) return;
  const armor = t.def.armor || 'structure';
  const mult = (DMG[dmgType] && DMG[dmgType][armor]) || 1;
  t.hp -= amount*mult;
  t.flash = 1; t.hitT = 6;
  if (src && t.kind === 'unit' && !t.order && !t.hold && UNITS[t.key].dmg){
    t.orders.unshift({type:'attack', target:src, leash:{x:t.x, z:t.z}});
    t.order = t.orders[0];
  }
  if (t.player === HUMAN && t.kind === 'building') alertBase(t);
  if (t.player === ENEMY && srcPlayer === HUMAN){ S.ai.threatPos = {x:t.x, z:t.z}; S.ai.threatT = S.t; }
  if (t.hp <= 0) destroyEntity(t, srcPlayer);
}

function destroyEntity(e, byPlayer){
  if (e.dead) return;
  e.dead = true;
  e.hp = 0;
  const isUnit = e.kind === 'unit';
  if (!isUnit){
    if (visTo(0,e.x,e.z)) knowledge[0].delete(e.id);
    if (visTo(1,e.x,e.z)) knowledge[1].delete(e.id);
    oHashDirty = true;
  }
  S.lost[e.player]++;
  if (byPlayer !== undefined && byPlayer !== e.player) S.kills[byPlayer]++;
  boom(e.x, isUnit ? e.y+1.5 : 5, e.z, isUnit ? 6 : 13, isUnit ? [255,170,90] : [255,120,60]);
  sparkBurst(e.x, isUnit ? e.y+1.4 : 5, e.z, isUnit ? 7 : 15, isUnit ? '#ffc38a' : '#ff9a5c');
  Snd.play(isUnit ? 'boom' : 'bigboom');
  if (isUnit && e.player === HUMAN && e.key === 'worker' && rnd() < 0.35) toast('A Prospector has been lost.', 'warn');
  recomputeSupply();
}

function sweepDead(){
  let w = 0;
  for (let i=0;i<units.length;i++){ const u = units[i]; if (!u.dead) units[w++] = u; }
  units.length = w;
  w = 0;
  for (let i=0;i<buildings.length;i++){ const b = buildings[i]; if (!b.dead) buildings[w++] = b; }
  buildings.length = w;
  w = 0;
  for (let i=0;i<projs.length;i++){ const p = projs[i]; if (!p.dead) projs[w++] = p; }
  projs.length = w;
}

/* -------- effects & projectiles -------- */
function boom(x,y,z,r,c){ fxList.push({k:'boom', x,y,z, t:0, dur:0.55, r, c}); }
function ring(x,z,r,c){ fxList.push({k:'ring', x, y:0, z, t:0, dur:0.6, r, c}); }
function sparkBurst(x,y,z,n,color){
  for (let i=0;i<n;i++){
    const a = rnd()*TAU, sp = 4+rnd()*11;
    fxList.push({k:'spark', x,y,z, vx:Math.cos(a)*sp, vy:2+rnd()*9, vz:Math.sin(a)*sp, t:0, dur:0.45+rnd()*0.5, s:2+rnd()*3, color});
  }
}
function floatText(x,y,z,str,color){ fxList.push({k:'text', x,y,z, t:0, dur:1.1, str, color}); }
function spawnProjectile(x,y,z,target,dmg,dmgType,player,color,size,speed){
  projs.push({x,y,z, px:x, py:y, pz:z, target, dmg, dmgType, player, color, size, speed, dead:false});
}
function updateProjectiles(dt){
  for (let i=0;i<projs.length;i++){
    const p = projs[i];
    const t = p.target;
    let tx = p.x, ty = p.y, tz = p.z;
    if (t && !t.dead){ tx = t.x; ty = (t.y||0) + (t.kind==='unit' ? (MODEL_H[t.key]||3)*0.5 : 4); tz = t.z; }
    const dx = tx-p.x, dy = ty-p.y, dz = tz-p.z;
    const d = Math.sqrt(dx*dx+dy*dy+dz*dz);
    const step = p.speed*dt;
    p.px = p.x; p.py = p.y; p.pz = p.z;
    if (d <= step + 0.6){
      p.dead = true;
      if (t && !t.dead) applyDamage(t, p.dmg, p.dmgType, p.player, null);
      sparkBurst(tx, ty, tz, 3, p.color);
      continue;
    }
    p.x += dx/d*step; p.y += dy/d*step; p.z += dz/d*step;
    if (p.y < 0.4) p.y = 0.4;
  }
}
function updateEffects(dt){
  for (let i=fxList.length-1;i>=0;i--){
    const f = fxList[i];
    f.t += dt;
    if (f.k === 'spark'){
      f.x += f.vx*dt; f.y += f.vy*dt; f.z += f.vz*dt;
      f.vy -= 22*dt;
      if (f.y < 0.2){ f.y = 0.2; f.vy *= -0.35; f.vx *= 0.6; f.vz *= 0.6; }
    }
    if (f.t >= f.dur) fxList.splice(i,1);
  }
  for (let i=beacons.length-1;i>=0;i--){
    beacons[i].t += dt;
    if (beacons[i].t >= beacons[i].dur) beacons.splice(i,1);
  }
}

/* -------- unit behaviour -------- */
function fireUnit(u, t){
  u.cd = u.def.cd;
  u.flash = 0.4;
  const dmg = u.def.dmg * unitDmgMul(u.player);
  const ty = (t.kind==='unit' ? (t.y||0) + (MODEL_H[t.key]||3)*0.5 : 4);
  if (u.def.range < 5){
    applyDamage(t, dmg, u.def.dmgType, u.player, u);
    sparkBurst(t.x, ty, t.z, 2, '#fff2c0');
    Snd.play('hit');
  } else {
    const uy = (u.y||0) + (MODEL_H[u.key]||3)*0.55;
    spawnProjectile(u.x, uy, u.z, t, dmg, u.def.dmgType, u.player,
      u.def.dmgType==='energy' ? '#8ef4ff' : (u.def.dmgType==='explosive' ? '#ffb45c' : '#fff0b0'),
      u.def.dmgType==='explosive' ? 3.4 : 2.3, 62);
    Snd.play('shot');
  }
}

function updateUnit(u, dt){
  u.flash = Math.max(0, u.flash - dt*2.6);
  u.hitT = Math.max(0, u.hitT - dt);
  if (u.cd > 0) u.cd -= dt;
  const o = u.order;

  if (!o){
    if (u.def.dmg && !u.hold){
      const t = acquireTarget(u, u.sight*0.9);
      if (t){ u.orders.push({type:'attack', target:t, leash:{x:u.x, z:u.z}}); u.order = u.orders[0]; }
    }
    stopMoving(u, dt);
    return;
  }

  switch(o.type){
    case 'move':
      if (steerTo(u, dt, o.x, o.z, 1.8 + u.rad*0.6)) nextOrder(u);
      break;

    case 'attackMove': {
      const t = acquireTarget(u, u.sight);
      if (t){
        u.orders.unshift({type:'attack', target:t, resume:o});
        u.order = u.orders[0];
        break;
      }
      if (steerTo(u, dt, o.x, o.z, 2 + u.rad*0.6)) nextOrder(u);
      break;
    }

    case 'attack': {
      const t = o.target;
      if (!t || t.dead){
        if (o.resume){ u.orders[0] = o.resume; u.order = o.resume; }
        else nextOrder(u);
        break;
      }
      if (o.leash && dist2(u.x,u.z,o.leash.x,o.leash.z) > 26*26){
        if (o.resume){ u.orders[0] = o.resume; u.order = o.resume; }
        else nextOrder(u);
        break;
      }
      const d = dist(u.x,u.z,t.x,t.z) - (t.rad||1);
      const range = u.def.range + (t.kind==='building' ? t.rad*0.35 : 0);
      if (d > range*0.92){
        if (u.hold){
          if (o.resume){ u.orders[0] = o.resume; u.order = o.resume; }
          else nextOrder(u);
          break;
        }
        steerTo(u, dt, t.x, t.z, Math.max(1.2, range*0.82));
      } else {
        stopMoving(u, dt);
        u.yaw = Math.atan2(t.x-u.x, t.z-u.z);
        if (u.cd <= 0) fireUnit(u, t);
      }
      break;
    }

    case 'hold':
      stopMoving(u, dt);
      if (u.def.dmg && u.cd <= 0){
        const t = acquireTarget(u, u.def.range + 1.5);
        if (t){ u.yaw = Math.atan2(t.x-u.x, t.z-u.z); fireUnit(u, t); }
      }
      break;

    case 'gather': {
      if (u.carry >= carryCap(u.player)){
        u.orders.unshift({type:'return'});
        u.order = u.orders[0];
        break;
      }
      const n = u.node;
      if (!n || n.amount <= 0){
        const nn = nearestNode(u.x, u.z);
        if (!nn){ nextOrder(u); break; }
        u.node = nn;
      }
      const nd = u.node;
      const d = dist(u.x,u.z,nd.x,nd.z);
      if (d > nd.r*0.82){
        steerTo(u, dt, nd.x, nd.z, nd.r*0.7);
      } else {
        stopMoving(u, dt);
        u.yaw = Math.atan2(nd.x-u.x, nd.z-u.z);
        u.mineT += dt*harvestMul(u.player);
        if (u.mineT >= 1.05){
          u.mineT = 0;
          const take = Math.min(nd.amount, carryCap(u.player) - u.carry);
          nd.amount -= take;
          u.carry += take;
          sparkBurst(nd.x + (rnd()-0.5)*4, 3.5, nd.z + (rnd()-0.5)*4, 2, '#7ef2ff');
          Snd.play('mine');
        }
      }
      break;
    }

    case 'return': {
      const drop = nearestDropoff(u.player, u.x, u.z);
      if (!drop){ stopMoving(u, dt); break; }
      const d = dist(u.x,u.z,drop.x,drop.z);
      if (d > drop.rad + 2.2){
        steerTo(u, dt, drop.x, drop.z, drop.rad + 1.4);
      } else {
        stopMoving(u, dt);
        if (u.carry > 0){
          S.min[u.player] += u.carry;
          S.gathered[u.player] += u.carry;
          if (u.player === HUMAN){
            floatText(drop.x, MODEL_H[drop.key]+1, drop.z, '+' + u.carry, '#7ef2ff');
            if (rnd() < 0.25) Snd.play('cash');
          }
          u.carry = 0;
        }
        nextOrder(u);
      }
      break;
    }

    case 'build': {
      const b = o.ref;
      if (!b || b.dead || b.built){ nextOrder(u); break; }
      const d = dist(u.x,u.z,b.x,b.z);
      if (d > b.rad + 2.4){
        steerTo(u, dt, b.x, b.z, b.rad + 1.2);
      } else {
        stopMoving(u, dt);
        u.yaw = Math.atan2(b.x-u.x, b.z-u.z);
        const speed = Math.min(3, 1 + b.builders*0.55);
        b.progress += dt / b.def.time * speed;
        b.hp = Math.max(b.hp, b.maxHp*(0.1 + 0.9*b.progress));
        if (rnd() < dt*7) sparkBurst(b.x + (rnd()-0.5)*b.rad, 2+rnd()*4, b.z + (rnd()-0.5)*b.rad, 1, '#ffd08a');
        if (b.progress >= 1){
          b.progress = 1; b.built = true; b.hp = b.maxHp;
          oHashDirty = true;
          recomputeSupply();
          if (b.player === HUMAN){ toast(b.def.name + ' complete.', 'good'); Snd.play('complete'); }
          else Snd.play('complete');
          nextOrder(u);
          if (!u.order && u.key === 'worker'){
            const nn2 = nearestNode(u.x, u.z);
            if (nn2){ u.node = nn2; u.orders.push({type:'gather', node:nn2}); u.order = u.orders[0]; }
          }
        }
      }
      break;
    }

    default:
      stopMoving(u, dt);
      break;
  }
}

/* -------- buildings -------- */
function completeUnit(b, key){
  const ang = b.yaw + Math.PI + rr(-0.7,0.7);
  const ex = clamp(b.x + Math.sin(ang)*(b.rad+3.2), 3, WORLD-3);
  const ez = clamp(b.z + Math.cos(ang)*(b.rad+3.2), 3, WORLD-3);
  const u = spawnUnit(key, b.player, ex, ez);
  if (b.player === HUMAN){ toast(UNITS[key].name + ' ready.', 'good'); Snd.play('ready'); }
  else Snd.play('ready');
  if (b.rally){
    if (b.rally.node && b.rally.node.amount > 0 && key === 'worker'){
      u.node = b.rally.node; u.orders.push({type:'gather', node:b.rally.node});
    } else {
      u.orders.push({type:'move', x:b.rally.x, z:b.rally.z});
    }
    u.order = u.orders[0];
  }
  return u;
}

function updateBuilding(b, dt){
  b.flash = Math.max(0, b.flash - dt*2.4);
  if (!b.built) return;

  /* static defence */
  if (b.def.dmg){
    if (b.cd > 0) b.cd -= dt;
    let t = b.target;
    if (t && (t.dead || dist2(b.x,b.z,t.x,t.z) > (b.def.range+4)*(b.def.range+4) || !visTo(b.player,t.x,t.z))) t = null;
    if (!t){ t = acquireForBuilding(b); b.target = t; }
    if (t && b.cd <= 0){
      b.cd = b.def.cd;
      const dmg = b.def.dmg * unitDmgMul(b.player);
      spawnProjectile(b.x, MODEL_H[b.key]*0.78, b.z, t, dmg, b.def.dmgType, b.player, '#ffe08a', 2.6, 78);
      Snd.play('shot');
    }
  }

  /* research (not gated by supply) */
  if (b.research){
    {
      b.research.t += dt;
      if (b.research.t >= b.research.time){
        const key = b.research.key;
        S.upg[b.player][key]++;
        if (key === 'armor'){
          for (const u of units) if (u.player === b.player){ u.maxHp = Math.round(u.def.hp*unitHpMul(b.player)); u.hp = Math.min(u.maxHp, u.hp*1.12); }
        }
        if (b.player === HUMAN){ toast(UPGRADES[key].name + ' research complete.', 'good'); Snd.play('complete'); }
        b.research = null;
      }
    }
  }

  /* production */
  if (b.queue.length){
    if (supplyBlocked(b.player)) return;
    const q = b.queue[0];
    q.t += dt;
    if (q.t >= q.time){
      b.queue.shift();
      recomputeSupply();
      completeUnit(b, q.key);
    }
  }
}

/* -------- per-tick world update -------- */
function updateWorld(dt){
  S.t += dt;
  visionTimer -= dt;
  if (visionTimer <= 0){ visionTimer = 0.2; computeVision(); updateKnowledge(); }
  hashRebuild();
  if (oHashDirty) oHashRebuild();

  for (let i=0;i<units.length;i++) updateUnit(units[i], dt);
  for (let i=0;i<buildings.length;i++) updateBuilding(buildings[i], dt);
  updateProjectiles(dt);
  updateEffects(dt);
  sweepDead();
}

/* -------- base under attack alert -------- */
let baseAlertT = 0;
function alertBase(b){
  if (S.t < baseAlertT) return;
  baseAlertT = S.t + 10;
  toast('Our base is under attack!', 'bad');
  Snd.play('alert');
}

/* ================================================================
   AUDIO (procedural, WebAudio)
   ================================================================ */
const Snd = {
  ctx:null, master:null, muted:false, last:{}, noise:null,
  init(){
    if (this.ctx) return;
    const AC = window.AudioContext || window.webkitAudioContext;
    if (!AC) return;
    this.ctx = new AC();
    this.master = this.ctx.createGain();
    this.master.gain.value = 0.42;
    this.master.connect(this.ctx.destination);
    const len = this.ctx.sampleRate*1.2;
    const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
    const d = buf.getChannelData(0);
    for (let i=0;i<len;i++) d[i] = Math.random()*2-1;
    this.noise = buf;
  },
  env(dur, vol, type, freq, freq2, delay){
    const c = this.ctx; if (!c) return;
    const t0 = c.currentTime + (delay||0);
    const o = c.createOscillator(), g = c.createGain();
    o.type = type||'sine';
    o.frequency.setValueAtTime(freq, t0);
    if (freq2) o.frequency.exponentialRampToValueAtTime(Math.max(20,freq2), t0+dur);
    g.gain.setValueAtTime(0.0001, t0);
    g.gain.exponentialRampToValueAtTime(vol, t0+0.008);
    g.gain.exponentialRampToValueAtTime(0.0001, t0+dur);
    o.connect(g); g.connect(this.master);
    o.start(t0); o.stop(t0+dur+0.05);
  },
  burst(dur, vol, freq, q, delay, freq2){
    const c = this.ctx; if (!c) return;
    const t0 = c.currentTime + (delay||0);
    const src = c.createBufferSource(); src.buffer = this.noise;
    const f = c.createBiquadFilter(); f.type = 'bandpass';
    f.frequency.setValueAtTime(freq, t0);
    if (freq2) f.frequency.exponentialRampToValueAtTime(Math.max(60,freq2), t0+dur);
    f.Q.value = q||1.2;
    const g = c.createGain();
    g.gain.setValueAtTime(vol, t0);
    g.gain.exponentialRampToValueAtTime(0.0001, t0+dur);
    src.connect(f); f.connect(g); g.connect(this.master);
    src.start(t0); src.stop(t0+dur+0.05);
  },
  play(name){
    if (this.muted) return;
    this.init();
    if (!this.ctx) return;
    if (this.ctx.state === 'suspended') this.ctx.resume();
    const now = performance.now();
    if (this.last[name] && now - this.last[name] < 45) return;
    this.last[name] = now;
    switch(name){
      case 'click': this.env(0.06,0.16,'square',520,380); break;
      case 'select': this.env(0.07,0.14,'triangle',680,880); break;
      case 'move': this.env(0.09,0.15,'triangle',330,260); this.env(0.07,0.1,'sine',660,520,0.02); break;
      case 'attack': this.env(0.12,0.18,'sawtooth',260,120); this.burst(0.1,0.12,900,1); break;
      case 'shot': this.burst(0.07,0.12,1800,2.4); break;
      case 'hit': this.burst(0.06,0.10,520,1.1); break;
      case 'mine': this.env(0.05,0.07,'triangle',880,1180); break;
      case 'cash': this.env(0.08,0.11,'triangle',740,980); this.env(0.09,0.10,'triangle',980,1320,0.07); break;
      case 'boom': this.burst(0.32,0.22,420,0.8,null,120); this.env(0.30,0.16,'sine',150,52); break;
      case 'bigboom': this.burst(0.62,0.30,320,0.7,null,90); this.env(0.55,0.22,'sine',120,42); this.burst(0.4,0.12,1400,1,0.05,300); break;
      case 'complete': this.env(0.11,0.15,'triangle',520,760); this.env(0.16,0.15,'triangle',780,1040,0.11); break;
      case 'ready': this.env(0.09,0.13,'square',600,820); break;
      case 'build': this.burst(0.16,0.10,620,1.4); this.env(0.12,0.09,'square',220,170); break;
      case 'error': this.env(0.16,0.16,'square',180,110); break;
      case 'alert': this.env(0.16,0.20,'square',420,300); this.env(0.16,0.20,'square',420,300,0.2); break;
      case 'victory': [392,494,587,784].forEach((f,i)=>this.env(0.5,0.16,'triangle',f,f,i*0.16)); break;
      case 'defeat': [330,262,208,165].forEach((f,i)=>this.env(0.6,0.16,'sawtooth',f,f*0.98,i*0.22)); break;
    }
  }
};

/* ================================================================
   INPUT / SELECTION / COMMANDS
   ================================================================ */
const mouse = { x:-999, y:-999, world:{x:0,z:0}, down:false, over:false, mid:false };
const dragSel = { active:false, x0:0, y0:0, x1:0, y1:0, moved:false };
const keys = {};
const sel = [];
let placing = null, invalidReason = '', amMode = false, gatherMode = false;
const groups = [[],[],[],[],[],[],[],[],[],[]];
let groupAnchor = [0,0,0,0,0,0,0,0,0,0];
let lastClick = { t:0, id:0 };

function toast(msg, kind){
  const log = document.getElementById('log');
  const el = document.createElement('div');
  el.className = 'toast' + (kind ? ' ' + kind : '');
  el.textContent = msg;
  log.appendChild(el);
  while (log.children.length > 6) log.removeChild(log.firstChild);
  setTimeout(()=>{ if (el.parentNode) el.parentNode.removeChild(el); }, 5200);
}

function clearSelection(){
  for (const e of sel) e.selected = false;
  sel.length = 0;
  selDirty = true;
}
function setSelection(list){
  clearSelection();
  for (const e of list){
    if (e.dead) continue;
    if (e.selected) continue;
    e.selected = true; sel.push(e);
  }
  selDirty = true;
}
function toggleSelect(e){
  if (e.selected){ e.selected = false; const i = sel.indexOf(e); if (i>=0) sel.splice(i,1); }
  else { e.selected = true; sel.push(e); }
  selDirty = true;
}

function pickEntity(sx, sy){
  let best = null, bestD = 1e9;
  const tryPick = (e, isBuilding)=>{
    if (e.dead) return;
    if (e.player !== HUMAN && !visTo(0,e.x,e.z)) return;
    if (!project(e.x, (e.y||0) + (MODEL_H[e.key]||3)*0.5, e.z, _c)) return;
    const r = clamp(FOCAL*(isBuilding ? e.rad*1.15 : e.rad*1.7)/Math.max(12,_c.z), 9, 90);
    const d = Math.hypot(sx-_c.x, sy-_c.y);
    if (d < r && _c.z < bestD){ bestD = _c.z; best = e; }
  };
  for (let i=0;i<units.length;i++) tryPick(units[i], false);
  for (let i=0;i<buildings.length;i++) tryPick(buildings[i], true);
  return best;
}
function pickNode(sx, sy){
  let best = null, bestD = 1e9;
  for (let i=0;i<nodes.length;i++){
    const n = nodes[i];
    if (n.amount <= 0) continue;
    if (!seenTo(0,n.x,n.z)) continue;
    if (!project(n.x, 3, n.z, _c)) continue;
    const r = clamp(FOCAL*n.r/Math.max(12,_c.z), 12, 120);
    const d = Math.hypot(sx-_c.x, sy-_c.y);
    if (d < r && _c.z < bestD){ bestD = _c.z; best = n; }
  }
  return best;
}

function selectRect(x0,y0,x1,y1, additive){
  const ax = Math.min(x0,x1), bx = Math.max(x0,x1), ay = Math.min(y0,y1), by = Math.max(y0,y1);
  const found = [];
  for (let i=0;i<units.length;i++){
    const u = units[i];
    if (u.player !== HUMAN || u.dead) continue;
    if (!project(u.x, u.y+1, u.z, _c)) continue;
    if (_c.x>=ax && _c.x<=bx && _c.y>=ay && _c.y<=by) found.push(u);
  }
  let list = found;
  if (!found.length){
    for (let i=0;i<buildings.length;i++){
      const b = buildings[i];
      if (b.player !== HUMAN || b.dead) continue;
      if (!project(b.x, 3, b.z, _c)) continue;
      if (_c.x>=ax && _c.x<=bx && _c.y>=ay && _c.y<=by){ found.push(b); break; }
    }
    list = found;
  } else {
    /* prefer a single unit type in big selections */
    const counts = {};
    for (const u of found) counts[u.key] = (counts[u.key]||0)+1;
    let top = null, topN = 0;
    for (const k in counts) if (counts[k] > topN){ topN = counts[k]; top = k; }
    if (topN >= 3) list = found.filter(u=>u.key === top);
  }
  if (additive){
    for (const e of list) if (!e.selected){ e.selected = true; sel.push(e); }
    selDirty = true;
  } else setSelection(list);
}

function selectSameTypeOnScreen(){
  const one = sel[0];
  if (!one || one.kind !== 'unit') return;
  const list = [];
  for (let i=0;i<units.length;i++){
    const u = units[i];
    if (u.player !== HUMAN || u.key !== one.key || u.dead) continue;
    if (!project(u.x, u.y+1, u.z, _c)) continue;
    if (_c.x>=0 && _c.x<=CW && _c.y>=0 && _c.y<=CH) list.push(u);
  }
  setSelection(list);
}

/* ---------- commands ---------- */
function orderUnits(list, makeOrder, queued, quiet){
  let any = false;
  for (const e of list){
    if (e.kind !== 'unit' || e.player !== HUMAN) continue;
    if (e.dead) continue;
    if (!queued) clearOrders(e);
    e.orders.push(makeOrder(e));
    if (!e.order) e.order = e.orders[0];
    any = true;
  }
  return any;
}

function cmdMove(x, z, queued){
  const list = sel.slice();
  const n = list.filter(e=>e.kind==='unit').length;
  const spacing = n > 1 ? clamp(2.2*Math.sqrt(n), 3, 16) : 0;
  let i = 0;
  orderUnits(list, (e)=>{
    let ox = 0, oz = 0;
    if (spacing){
      const a = (i++)/Math.max(1,n)*TAU*1.6;
      const rr2 = spacing*Math.sqrt((i)/Math.max(1,n));
      ox = Math.cos(a)*rr2; oz = Math.sin(a)*rr2;
    }
    return {type:'move', x:clamp(x+ox,2,WORLD-2), z:clamp(z+oz,2,WORLD-2)};
  }, queued);
  if (n){ beacons.push({x, z, t:0, dur:0.85, c:[110,235,255]}); Snd.play('move'); }
}

function cmdAttackMove(x, z, queued){
  orderUnits(sel.slice(), ()=>({type:'attackMove', x, z}), queued);
  if (sel.length){ beacons.push({x, z, t:0, dur:0.95, c:[255,150,80]}); Snd.play('attack'); }
}

function cmdAttackTarget(target, queued){
  orderUnits(sel.slice(), ()=>({type:'attack', target}), queued);
  if (sel.length){ beacons.push({x:target.x, z:target.z, t:0, dur:0.95, c:[255,110,90]}); Snd.play('attack'); }
}

function cmdGather(node, queued){
  orderUnits(sel.slice(), (e)=>{ e.node = node; return {type:'gather', node}; }, queued);
  beacons.push({x:node.x, z:node.z, t:0, dur:0.9, c:[110,255,190]});
  Snd.play('move');
}

function cmdReturn(queued){
  orderUnits(sel.slice(), ()=>({type:'return'}), queued);
  Snd.play('move');
}

function cmdStop(){
  for (const e of sel){ if (e.kind !== 'unit') continue; clearOrders(e); e.hold = false; e.vx = 0; e.vz = 0; }
  Snd.play('click');
}
function cmdHold(){
  for (const e of sel){ if (e.kind !== 'unit') continue; clearOrders(e); e.hold = true; e.orders.push({type:'hold'}); e.order = e.orders[0]; }
  Snd.play('click');
}

function setRally(x, z, node){
  for (const e of sel){
    if (e.kind !== 'building' || e.player !== HUMAN) continue;
    e.rally = {x, z, node:node||null};
    beacons.push({x, z, t:0, dur:0.9, c:[110,255,170]});
    Snd.play('click');
  }
}

/* ---------- building placement ---------- */
function canPlace(key, x, z, player){
  player = player === undefined ? HUMAN : player;
  const d = BUILDS[key];
  const hw = d.w/2 + 1.6, hd = d.d/2 + 1.6;
  const mr = Math.max(hw,hd);
  if (x < hw+2 || z < hd+2 || x > WORLD-hw-2 || z > WORLD-hd-2){ invalidReason = 'Outside the frontier'; return false; }
  for (let i=0;i<buildings.length;i++){
    const b = buildings[i];
    if (b.dead) continue;
    if (dist2(x,z,b.x,b.z) < (b.rad + mr*0.82)*(b.rad + mr*0.82)){ invalidReason = 'Overlaps a structure'; return false; }
  }
  for (let i=0;i<rocks.length;i++){
    const r = rocks[i];
    if (dist2(x,z,r.x,r.z) < (r.r + mr*0.62)*(r.r + mr*0.62)){ invalidReason = 'Blocked by terrain'; return false; }
  }
  for (let i=0;i<nodes.length;i++){
    const n = nodes[i];
    if (n.amount<=0) continue;
    if (dist2(x,z,n.x,n.z) < (n.r + mr*0.6)*(n.r + mr*0.6)){ invalidReason = 'Cannot cover a deposit'; return false; }
  }
  let near = false;
  for (let i=0;i<buildings.length;i++){
    const b = buildings[i];
    if (b.dead || b.player !== player) continue;
    if (dist2(x,z,b.x,b.z) < 48*48){ near = true; break; }
  }
  if (!near){ invalidReason = 'Must build within 48 units of a friendly structure'; return false; }
  if (S.min[player] < d.cost){ invalidReason = 'Not enough Aetherium'; return false; }
  invalidReason = '';
  return true;
}

function placeBuilding(key, x, z){
  if (!canPlace(key, x, z)){ Snd.play('error'); toast(invalidReason, 'bad'); return false; }
  const d = BUILDS[key];
  S.min[HUMAN] -= d.cost;
  const b = spawnBuilding(key, HUMAN, x, z, false);
  const workers = sel.filter(e=>e.kind==='unit' && e.key==='worker').slice(0,3);
  if (!workers.length){
    for (const u of units) if (u.player===HUMAN && u.key==='worker' && !u.order){ workers.push(u); if (workers.length>=3) break; }
  }
  for (const w of workers){ clearOrders(w); w.orders.push({type:'build', ref:b}); w.order = w.orders[0]; }
  Snd.play('build');
  toast('Constructing ' + d.name + '.', 'good');
  placing = null;
  return true;
}

function enqueueUnit(b, key){
  const d = UNITS[key];
  if (!b.built) return false;
  if (b.queue.length >= 9){ Snd.play('error'); toast('Production queue is full.', 'warn'); return false; }
  if (S.min[HUMAN] < d.cost){ Snd.play('error'); toast('Not enough Aetherium for ' + d.name + '.', 'warn'); return false; }
  recomputeSupply();
  if (supplyFree(HUMAN) < d.supply){ Snd.play('error'); toast('Not enough supply — build a Supply Relay.', 'warn'); return false; }
  S.min[HUMAN] -= d.cost;
  b.queue.push({key, t:0, time:d.time});
  recomputeSupply();
  Snd.play('click');
  return true;
}

function startResearch(b, key){
  const d = UPGRADES[key];
  if (!b.built || b.research) return false;
  if (S.upg[HUMAN][key] >= d.max){ Snd.play('error'); toast(d.name + ' is already at maximum level.', 'warn'); return false; }
  if (S.min[HUMAN] < d.cost){ Snd.play('error'); toast('Not enough Aetherium for ' + d.name + '.', 'warn'); return false; }
  S.min[HUMAN] -= d.cost;
  b.research = {key, t:0, time:d.time};
  Snd.play('click');
  return true;
}

function cancelQueueItem(b, idx){
  const q = b.queue[idx];
  if (!q) return;
  S.min[HUMAN] += UNITS[q.key].cost;
  b.queue.splice(idx,1);
  recomputeSupply();
  Snd.play('click');
  toast('Production cancelled — full refund.', 'warn');
}

/* ================================================================
   HUD
   ================================================================ */
let selDirty = true;
let hudTimer = 0;
const el = {
  minerals: document.getElementById('minerals'),
  supply: document.getElementById('supply'),
  army: document.getElementById('armysize'),
  clock: document.getElementById('clock'),
  selTitle: document.getElementById('selTitle'),
  selBody: document.getElementById('selBody'),
  selCount: document.getElementById('selCount'),
  cmdGrid: document.getElementById('cmdGrid'),
  queue: document.getElementById('queue'),
  cmdHint: document.getElementById('cmdHint')
};
function fmtTime(t){
  const m = Math.floor(t/60), s = Math.floor(t%60);
  return m + ':' + (s<10?'0':'') + s;
}
function unitIcon(key){
  return { worker:'◆', ravager:'⚔', marksman:'✦', widow:'✧', juggernaut:'▰', talon:'▲' }[key] || '■';
}
function buildIcon(key){
  return { hq:'⌂', supply:'⚡', foundry:'▣', starport:'✈', lab:'⚗', turret:'♜', refinery:'⛁' }[key] || '▣';
}

function renderSelectionPanel(){
  const list = sel.filter(e=>!e.dead);
  if (!list.length){
    el.selTitle.textContent = 'No units selected';
    el.selBody.textContent = 'Drag a box over your forces, or click a unit. Right-click to issue contextual orders.';
    el.selCount.textContent = '';
    return;
  }
  el.selCount.textContent = list.length > 1 ? list.length + ' SELECTED' : '';
  if (list.length === 1){
    const e = list[0];
    const d = e.def;
    el.selTitle.textContent = d.name + (e.kind==='building' && !e.built ? ' (under construction)' : '');
    let html = '<div class="bar hp"><i style="width:' + clamp(e.hp/e.maxHp*100,0,100) + '%"></i></div>';
    html += '<b>Hull</b> ' + Math.ceil(e.hp) + ' / ' + e.maxHp + '<br>';
    html += '<b>Role</b> ' + (d.role || '') + '<br>';
    if (e.kind === 'unit'){
      html += '<b>Damage</b> ' + d.dmg + ' ' + DMGNAME[d.dmgType] + ' · <b>Range</b> ' + d.range + '<br>';
      html += '<b>Speed</b> ' + d.speed + ' · <b>Sight</b> ' + d.sight + ' · <b>Armour</b> ' + d.armor + '<br>';
      html += '<b>Supply</b> ' + d.supply + ' · <b>Targets</b> ' + (d.targets==='both'?'ground + air':d.targets);
      if (e.key === 'worker') html += '<br><b>Cargo</b> ' + e.carry + ' Aetherium';
    } else {
      html += '<b>Structure</b> · <b>Sight</b> ' + d.sight + '<br>';
      if (d.supply) html += '<b>Supply provided</b> ' + d.supply + '<br>';
      if (d.drop) html += 'Accepts Aetherium deliveries<br>';
      if (d.dmg) html += '<b>Defence</b> ' + d.dmg + ' ' + DMGNAME[d.dmgType] + ' · range ' + d.range + '<br>';
      if (e.rally) html += '<b>Rally</b> set';
      if (e.research) html += '<b>Researching</b> ' + UPGRADES[e.research.key].name + ' — ' + Math.round(e.research.t/e.research.time*100) + '%';
    }
    el.selBody.innerHTML = html;
    return;
  }
  const counts = {};
  let hp = 0, maxHp = 0;
  for (const e of list){ counts[e.key] = (counts[e.key]||0)+1; hp += e.hp; maxHp += e.maxHp; }
  el.selTitle.textContent = list.length + ' units selected';
  let html = '<div class="bar hp"><i style="width:' + clamp(hp/maxHp*100,0,100) + '%"></i></div>';
  html += '<b>Combined hull</b> ' + Math.ceil(hp) + ' / ' + maxHp + '<br>';
  let supply = 0;
  for (const e of list) supply += e.def.supply || 0;
  html += '<b>Supply</b> ' + supply + '<div class="iconrow">';
  for (const k in counts){
    const def = UNITS[k] || BUILDS[k];
    html += '<div class="picon">' + (UNITS[k] ? unitIcon(k) : buildIcon(k)) + '<span>' + counts[k] + '</span></div>';
  }
  html += '</div>';
  el.selBody.innerHTML = html;
}

function cmdButton(label, sub, hk, act, disabled, active){
  return '<button class="cmd' + (disabled?' disabled':'') + (active?' active':'') + '" data-act="' + act + '">' +
    (hk ? '<span class="hk">' + hk + '</span>' : '') +
    '<span class="nm">' + label + '</span><span class="sub">' + (sub||'') + '</span></button>';
}

function renderCommands(){
  const list = sel.filter(e=>!e.dead);
  let html = '';
  const workers = list.filter(e=>e.kind==='unit' && e.key==='worker');
  const prod = list.filter(e=>e.kind==='building' && e.def.trains && e.built);
  const labs = list.filter(e=>e.kind==='building' && e.def.research && e.built);
  const single = list.length===1 ? list[0] : null;
  const hasUnits = list.some(e=>e.kind==='unit');

  if (workers.length){
    for (const k in BUILDS){
      const d = BUILDS[k];
      const poor = S.min[HUMAN] < d.cost;
      html += cmdButton(buildIcon(k) + ' ' + d.name, d.cost + ' · ' + d.time + 's', d.hotkey, 'build:'+k, poor);
    }
  }
  if (prod.length){
    const p = prod[0];
    for (const k of p.def.trains){
      const d = UNITS[k];
      const poor = S.min[HUMAN] < d.cost || supplyFree(HUMAN) < d.supply;
      html += cmdButton(unitIcon(k) + ' ' + d.name, d.cost + ' · ' + d.supply + ' supply · ' + d.time + 's', d.hotkey, 'train:'+k, poor);
    }
  }
  if (labs.length){
    const p = labs[0];
    for (const k in UPGRADES){
      const d = UPGRADES[k];
      const lvl = S.upg[HUMAN][k];
      const maxed = lvl >= d.max;
      const poor = S.min[HUMAN] < d.cost;
      html += cmdButton('▲ ' + d.name, maxed ? 'MAX LEVEL' : ('Lv ' + lvl + ' → ' + (lvl+1) + ' · ' + d.cost), d.hotkey,
        'research:'+k, maxed || poor || !!p.research);
    }
  }
  if (hasUnits){
    html += cmdButton('◎ Attack-Move', 'Move and engage', 'A', 'cmd:am', false, amMode);
    html += cmdButton('■ Stop', 'Hold fire position', 'S', 'cmd:stop');
    html += cmdButton('⛨ Hold Position', 'Fire without chasing', 'H', 'cmd:hold', false, list.some(e=>e.hold));
    html += cmdButton('◈ Return Cargo', 'Deliver resources', '', 'cmd:return');
  }
  if (single && single.kind === 'building' && single.player === HUMAN){
    html += cmdButton('⚑ Rally Point', 'Right-click on the map', '', 'cmd:rally');
  }
  el.cmdGrid.innerHTML = html;
}

function renderQueue(){
  const list = sel.filter(e=>e.kind==='building' && e.def.trains);
  let html = '';
  for (const b of list){
    if (b.research){
      html += '<div class="qitem"><i style="height:' + (b.research.t/b.research.time*100) + '%"></i><span>' +
        UPGRADES[b.research.key].name.slice(0,8) + '</span></div>';
    }
    for (let i=0;i<b.queue.length;i++){
      const q = b.queue[i];
      const pct = q.t/q.time*100;
      html += '<div class="qitem" data-cancel="' + i + '" title="Click to cancel (full refund)"><i style="height:' + pct + '%"></i><span>' +
        UNITS[q.key].name.slice(0,8) + '</span></div>';
    }
    if (supplyBlocked(b.player)){
      html += '<div class="qitem" style="border-color:#ff5d7a"><span>SUPPLY BLOCKED</span></div>';
    }
  }
  if (!html) html = '<div style="font-size:11px;color:#6f93ad">Select a War Foundry, Aerodrome, Headquarters or Tech Nexus to produce.</div>';
  el.queue.innerHTML = html;
}

function updateHUD(){
  el.minerals.textContent = Math.floor(S.min[HUMAN]);
  const used = S.supUsed[HUMAN], res = S.supRes[HUMAN], cap = S.supCap[HUMAN];
  el.supply.textContent = (used + res) + (res ? ' (+' + res + ')' : '') + ' / ' + cap;
  el.supply.className = 'val' + (supplyBlocked(HUMAN) ? ' bad' : (supplyFree(HUMAN) < 4 ? ' warn' : ''));
  let army = 0;
  for (let i=0;i<units.length;i++) if (units[i].player===HUMAN && UNITS[units[i].key].dmg) army++;
  el.army.textContent = army;
  el.clock.textContent = fmtTime(S.t);
  if (selDirty){ renderSelectionPanel(); renderCommands(); selDirty = false; }
  renderQueue();
  if (amMode) el.cmdHint.textContent = 'ATTACK-MOVE: click a destination';
  else if (placing) el.cmdHint.textContent = 'PLACING: ' + BUILDS[placing].name + ' — click to build, right-click to cancel';
  else el.cmdHint.textContent = 'Left-click selects · Right-click orders';
}

/* ================================================================
   EVENT WIRING
   ================================================================ */
function screenPos(e){
  const r = view.getBoundingClientRect();
  return { x:e.clientX - r.left, y:e.clientY - r.top };
}

view.addEventListener('mousedown', (e)=>{
  if (S.over) return;
  Snd.init();
  const p = screenPos(e);
  mouse.x = p.x; mouse.y = p.y;
  if (e.button === 1){ mouse.mid = true; mouse.midX = p.x; mouse.midY = p.y; e.preventDefault(); return; }
  if (e.button === 2) return;
  mouse.down = true;
  dragSel.x0 = p.x; dragSel.y0 = p.y; dragSel.x1 = p.x; dragSel.y1 = p.y;
  dragSel.moved = false; dragSel.active = false;
});

view.addEventListener('mouseenter', ()=>{ mouse.over = true; });
view.addEventListener('mouseleave', ()=>{ mouse.over = false; mouse.down = false; mouse.mid = false; });

view.addEventListener('mousemove', (e)=>{
  const p = screenPos(e);
  mouse.over = true;
  if (mouse.mid){
    const dx = p.x - mouse.midX, dy = p.y - mouse.midY;
    cam.yaw -= dx*0.006;
    cam.x -= Math.sin(cam.yaw)*dy*0.45;
    cam.z -= Math.cos(cam.yaw)*dy*0.45;
    mouse.midX = p.x; mouse.midY = p.y;
    return;
  }
  mouse.x = p.x; mouse.y = p.y;
  if (mouse.down){
    dragSel.x1 = p.x; dragSel.y1 = p.y;
    if (Math.abs(dragSel.x1-dragSel.x0) + Math.abs(dragSel.y1-dragSel.y0) > 7) dragSel.moved = true;
    dragSel.active = dragSel.moved;
  }
});

view.addEventListener('mouseup', (e)=>{
  if (e.button === 1){ mouse.mid = false; return; }
  if (e.button !== 0) return;
  mouse.down = false;
  dragSel.active = false;
  const p = screenPos(e);
  mouse.x = p.x; mouse.y = p.y;
  screenToGround(p.x, p.y, mouse.world);

  if (placing){
    if (!placeBuilding(placing, mouse.world.x, mouse.world.z)) placing = placing;
    return;
  }
  if (amMode){
    const target = pickEntity(p.x, p.y);
    if (target && target.player !== HUMAN) cmdAttackTarget(target, e.shiftKey);
    else cmdAttackMove(mouse.world.x, mouse.world.z, e.shiftKey);
    amMode = false; selDirty = true;
    return;
  }
  if (gatherMode){
    const node = pickNode(p.x, p.y);
    if (node) cmdGather(node, e.shiftKey);
    gatherMode = false; selDirty = true;
    return;
  }
  if (dragSel.moved){
    selectRect(dragSel.x0, dragSel.y0, dragSel.x1, dragSel.y1, e.shiftKey);
    Snd.play('select');
    return;
  }
  const picked = pickEntity(p.x, p.y);
  if (picked && picked.player === HUMAN){
    const now = performance.now();
    if (now - lastClick.t < 320 && lastClick.id === picked.id && picked.kind === 'unit'){
      selectSameTypeOnScreen();
    } else {
      if (e.shiftKey) toggleSelect(picked);
      else setSelection([picked]);
    }
    lastClick.t = now; lastClick.id = picked.id;
    Snd.play('select');
  } else if (!e.shiftKey){
    clearSelection();
  }
});

view.addEventListener('contextmenu', (e)=>{
  e.preventDefault();
  if (S.over || S.paused) return;
  const p = screenPos(e);
  screenToGround(p.x, p.y, mouse.world);
  if (placing){ placing = null; selDirty = true; return; }
  if (amMode || gatherMode){ amMode = false; gatherMode = false; selDirty = true; return; }
  const picked = pickEntity(p.x, p.y);
  const node = pickNode(p.x, p.y);
  const queued = e.shiftKey;

  /* rally for buildings when only buildings selected */
  const onlyBuildings = sel.length && sel.every(x=>x.kind==='building');
  const onlyWorkers = sel.length && sel.some(x=>x.kind==='unit' && x.key==='worker') && sel.every(x=>x.kind==='unit' && x.key==='worker');
  if (onlyBuildings && sel.some(x=>x.def.trains)){
    setRally(mouse.world.x, mouse.world.z, node);
    return;
  }
  if (picked && picked.player !== HUMAN){
    cmdAttackTarget(picked, queued);
    return;
  }
  if (picked && picked.player === HUMAN && picked.kind === 'building' && picked.def.drop && sel.some(x=>x.key==='worker')){
    cmdReturn(queued);
    return;
  }
  if (node && sel.some(x=>x.key==='worker')){
    cmdGather(node, queued);
    return;
  }
  cmdMove(mouse.world.x, mouse.world.z, queued);
});

view.addEventListener('wheel', (e)=>{
  e.preventDefault();
  const f = Math.exp(e.deltaY*0.0011);
  cam.dist = clamp(cam.dist*f, MIN_ZOOM, MAX_ZOOM);
}, {passive:false});

window.addEventListener('keydown', (e)=>{
  if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
  keys[e.key.toLowerCase()] = true;
  const k = e.key.toLowerCase();

  if (k === 'escape'){
    if (!document.getElementById('help').classList.contains('hidden')){ closeHelp(); return; }
    if (placing || amMode || gatherMode){ placing = null; amMode = false; gatherMode = false; selDirty = true; return; }
    togglePause();
    return;
  }
  if (k === 'f1'){ e.preventDefault(); toggleHelp(); return; }
  if (!S.running || S.paused || S.over) return;

  if (k === 'a'){ amMode = true; gatherMode = false; selDirty = true; Snd.play('click'); return; }
  if (k === 'g'){ gatherMode = true; amMode = false; selDirty = true; Snd.play('click'); return; }
  if (k === 's' || k === 'x'){ cmdStop(); selDirty = true; return; }
  if (k === 'h'){ cmdHold(); selDirty = true; return; }

  if (k >= '0' && k <= '9'){
    const idx = +k;
    if (e.ctrlKey || e.metaKey){
      e.preventDefault();
      groups[idx] = sel.filter(x=>x.kind==='unit').map(x=>x.id);
      groupAnchor[idx] = groups[idx].length ? 1 : 0;
      toast('Control group ' + idx + ' set (' + groups[idx].length + ' units).', 'good');
      Snd.play('click');
    } else {
      const list = groups[idx].map(id=>units.find(u=>u.id===id)).filter(u=>u && !u.dead);
      if (list.length){
        setSelection(list);
        Snd.play('select');
        if (groupAnchor[idx] === 2){
          let cx=0, cz=0;
          for (const u of list){ cx += u.x; cz += u.z; }
          cam.x = cx/list.length; cam.z = cz/list.length;
          groupAnchor[idx] = 1;
        } else groupAnchor[idx] = 2;
      }
    }
    return;
  }

  /* command card hotkeys */
  const list = sel.filter(e2=>!e2.dead);
  const workers = list.some(e2=>e2.kind==='unit' && e2.key==='worker');
  const prod = list.find(e2=>e2.kind==='building' && e2.def.trains && e2.built);
  const lab = list.find(e2=>e2.kind==='building' && e2.def.research && e2.built);
  if (workers){
    for (const key in BUILDS){
      if (BUILDS[key].hotkey.toLowerCase() === k){ placing = key; amMode = false; selDirty = true; Snd.play('click'); return; }
    }
  }
  if (prod){
    for (const key of prod.def.trains){
      if (UNITS[key].hotkey.toLowerCase() === k){ enqueueUnit(prod, key); renderCommands(); return; }
    }
  }
  if (lab){
    for (const key in UPGRADES){
      if (UPGRADES[key].hotkey.toLowerCase() === k){ startResearch(lab, key); renderCommands(); return; }
    }
  }
});

window.addEventListener('keyup', (e)=>{ keys[e.key.toLowerCase()] = false; });

el.cmdGrid.addEventListener('click', (e)=>{
  const btn = e.target.closest('.cmd');
  if (!btn || btn.classList.contains('disabled')) return;
  const act = btn.dataset.act;
  Snd.init();
  if (act.startsWith('build:')){ placing = act.slice(6); amMode = false; selDirty = true; Snd.play('click'); }
  else if (act.startsWith('train:')){
    const prod = sel.find(x=>x.kind==='building' && x.def.trains && x.built);
    if (prod){ enqueueUnit(prod, act.slice(6)); renderCommands(); }
  }
  else if (act.startsWith('research:')){
    const lab = sel.find(x=>x.kind==='building' && x.def.research && x.built);
    if (lab){ startResearch(lab, act.slice(9)); renderCommands(); }
  }
  else if (act === 'cmd:am'){ amMode = true; placing = null; selDirty = true; Snd.play('click'); }
  else if (act === 'cmd:stop'){ cmdStop(); selDirty = true; }
  else if (act === 'cmd:hold'){ cmdHold(); selDirty = true; }
  else if (act === 'cmd:return'){ cmdReturn(false); Snd.play('move'); }
  else if (act === 'cmd:rally'){ toast('Right-click on the map to set the rally point.', 'good'); }
});

el.queue.addEventListener('click', (e)=>{
  const item = e.target.closest('.qitem');
  if (!item || !item.dataset.cancel) return;
  const b = sel.find(x=>x.kind==='building' && x.def.trains);
  if (b) cancelQueueItem(b, +item.dataset.cancel);
});

/* minimap */
function mmToGround(e){
  const r = mm.getBoundingClientRect();
  const x = (e.clientX - r.left)/r.width*WORLD;
  const z = (e.clientY - r.top)/r.height*WORLD;
  return {x:clamp(x,0,WORLD), z:clamp(z,0,WORLD)};
}
mm.addEventListener('mousedown', (e)=>{
  if (S.over) return;
  Snd.init();
  const g = mmToGround(e);
  if (e.button === 2){
    e.preventDefault();
    if (sel.some(x=>x.kind==='unit')) cmdAttackMove(g.x, g.z, e.shiftKey);
    return;
  }
  cam.x = g.x; cam.z = g.z;
});
mm.addEventListener('contextmenu', (e)=>e.preventDefault());
mm.addEventListener('mousemove', (e)=>{
  if (!(e.buttons & 1)) return;
  const g = mmToGround(e);
  cam.x = g.x; cam.z = g.z;
});

/* ================================================================
   AI OPPONENT — same rules, own fog of war
   ================================================================ */
function aiBuildings(key){
  const out = [];
  for (const b of buildings) if (b.player===ENEMY && !b.dead && (!key || b.key===key)) out.push(b);
  return out;
}
function aiCount(key){
  let n = 0;
  for (const b of buildings) if (b.player===ENEMY && !b.dead && b.key===key) n++;
  return n;
}
function aiPending(key){
  for (const b of buildings) if (b.player===ENEMY && !b.dead && b.key===key && !b.built) return true;
  return false;
}
function aiUnitCount(key){
  let n = 0;
  for (const u of units) if (u.player===ENEMY && !u.dead && u.key===key) n++;
  return n;
}
function aiPlace(key, nearX, nearZ, minR, maxR){
  for (let i=0;i<28;i++){
    const a = rnd()*TAU, r = rr(minR,maxR);
    const x = clamp(nearX + Math.cos(a)*r, 8, WORLD-8), z = clamp(nearZ + Math.sin(a)*r, 8, WORLD-8);
    if (canPlace(key, x, z, ENEMY)) return {x, z};
  }
  return null;
}
function aiNearestWorker(x, z, requireIdle){
  let best = null, bestD = 1e18;
  for (const u of units){
    if (u.player!==ENEMY || u.dead || u.key!=='worker') continue;
    if (requireIdle && u.order) continue;
    const d = dist2(u.x,u.z,x,z);
    if (d < bestD){ bestD = d; best = u; }
  }
  return best;
}
function aiStartBuild(key, x, z){
  if (S.min[ENEMY] < BUILDS[key].cost) return false;
  const w = aiNearestWorker(x, z, true) || aiNearestWorker(x, z, false);
  if (!w) return false;
  S.min[ENEMY] -= BUILDS[key].cost;
  const b = spawnBuilding(key, ENEMY, x, z, false);
  clearOrders(w); w.orders.push({type:'build', ref:b}); w.order = w.orders[0];
  return true;
}
function aiTryBuild(key, nearX, nearZ, minR, maxR){
  if (S.min[ENEMY] < BUILDS[key].cost + 40) return false;
  const spot = aiPlace(key, nearX, nearZ, minR, maxR);
  return spot ? aiStartBuild(key, spot.x, spot.z) : false;
}
function aiBaseCenter(){
  const hqs = aiBuildings('hq');
  if (hqs.length){ let x=0,z=0; for (const b of hqs){ x+=b.x; z+=b.z; } return {x:x/hqs.length, z:z/hqs.length}; }
  const bs = aiBuildings();
  if (bs.length){ return {x:bs[0].x, z:bs[0].z}; }
  return {x:184, z:56};
}
function aiTrainAt(buildingKey, unitKey){
  const list = aiBuildings(buildingKey);
  for (const b of list){
    if (!b.built || b.queue.length >= 3) continue;
    const d = UNITS[unitKey];
    if (S.min[ENEMY] < d.cost) return false;
    recomputeSupply();
    if (supplyFree(ENEMY) < d.supply) return false;
    S.min[ENEMY] -= d.cost;
    b.queue.push({key:unitKey, t:0, time:d.time});
    recomputeSupply();
    return true;
  }
  return false;
}

function aiAssignWorkers(){
  for (const u of units){
    if (u.player!==ENEMY || u.dead || u.key!=='worker') continue;
    if (u.order) continue;
    const drop = nearestDropoff(ENEMY, u.x, u.z);
    let node = null, bestD = 1e18;
    for (const n of nodes){
      if (n.amount<=0) continue;
      const d = dist2(n.x,n.z, drop?drop.x:u.x, drop?drop.z:u.z);
      if (d < bestD){ bestD = d; node = n; }
    }
    if (node){ u.node = node; u.orders.push({type:'gather', node}); u.order = u.orders[0]; }
  }
}

function aiTrainArmy(){
  const t = S.t;
  const cap = 1 + Math.floor(t/210);
  for (let i=0;i<cap;i++){
    const r = rnd();
    let key;
    if (aiUnitCount('marksman') + aiUnitCount('ravager') < 3) key = r < 0.5 ? 'ravager' : 'marksman';
    else if (t < 150) key = r < 0.62 ? 'ravager' : 'marksman';
    else if (t < 330) key = r < 0.36 ? 'ravager' : (r < 0.74 ? 'marksman' : 'widow');
    else key = r < 0.26 ? 'ravager' : (r < 0.56 ? 'marksman' : (r < 0.74 ? 'widow' : 'juggernaut'));
    aiTrainAt('foundry', key);
    if (t > 260 && rnd() < 0.34) aiTrainAt('starport', 'talon');
    if (aiUnitCount('worker') < 15 && S.min[ENEMY] > 160) aiTrainAt('hq', 'worker');
  }
}

function aiResearch(){
  for (const b of aiBuildings('lab')){
    if (!b.built || b.research) continue;
    const order = ['weapons','armor','thrusters','harvest','weapons','armor'];
    for (const k of order){
      if (S.upg[ENEMY][k] >= UPGRADES[k].max) continue;
      if (S.min[ENEMY] < UPGRADES[k].cost + 120) break;
      S.min[ENEMY] -= UPGRADES[k].cost;
      b.research = {key:k, t:0, time:UPGRADES[k].time};
      break;
    }
  }
}

function aiArmy(){
  const p = ENEMY;
  const idle = [];
  for (const u of units){
    if (u.player!==p || u.dead) continue;
    if (!UNITS[u.key].dmg) continue;
    if (!u.order) idle.push(u);
  }
  /* defend threatened assets */
  if (S.ai.threatT && S.t - S.ai.threatT < 14 && idle.length >= 2 && S.ai.threatPos){
    for (const u of idle){
      u.orders.push({type:'attackMove', x:S.ai.threatPos.x + rr(-6,6), z:S.ai.threatPos.z + rr(-6,6)});
      u.order = u.orders[0];
    }
    return;
  }
  if (idle.length >= S.ai.waveSize && S.t >= S.ai.attackAt){
    let tx = null, tz = null, bestD = 1e18;
    const base = aiBaseCenter();
    for (const [id,g] of knowledge[p]){
      const d = dist2(base.x, base.z, g.x, g.z);
      if (d < bestD){ bestD = d; tx = g.x; tz = g.z; }
    }
    if (tx === null){
      /* nothing known: probe the map — after a while, check the rival's suspected landing zone */
      if (S.t > 150){ tx = 56 + rr(-24,24); tz = 184 + rr(-24,24); }
      else {
        const cand = nodes.filter(n=>n.amount>0 && seenTo(1,n.x,n.z));
        if (cand.length){
          const n = cand[Math.floor(rnd()*cand.length)];
          tx = n.x + rr(-16,16); tz = n.z + rr(-16,16);
        } else { tx = WORLD*0.35 + rr(-25,25); tz = WORLD*0.35 + rr(-25,25); }
      }
    }
    for (const u of idle){
      u.orders.push({type:'attackMove', x:clamp(tx+rr(-9,9),4,WORLD-4), z:clamp(tz+rr(-9,9),4,WORLD-4)});
      u.order = u.orders[0];
    }
    S.ai.attackAt = S.t + 40 + rnd()*25;
    S.ai.waveSize = Math.min(34, S.ai.waveSize + 2 + Math.floor(S.t/260));
  }
}

function aiScout(){
  if (S.t < S.ai.nextScout) return;
  S.ai.nextScout = S.t + 26 + rnd()*22;
  const cand = nodes.filter(n=>n.amount>0 && !seenTo(1,n.x,n.z));
  let tx, tz;
  if (knowledge[ENEMY].size === 0 && S.t > 100){
    /* the rival's landing zone is suspected — send eyes to confirm */
    tx = 56 + rr(-32,32); tz = 184 + rr(-32,32);
  } else if (cand.length){ const n = cand[Math.floor(rnd()*cand.length)]; tx = n.x; tz = n.z; }
  else { tx = rr(20,WORLD-20); tz = rr(20,WORLD-20); }
  const scout = aiNearestWorker(1e9, 1e9, true);
  const fighter = units.find(u=>u.player===ENEMY && !u.dead && UNITS[u.key].dmg && !u.order);
  const s = fighter || scout;
  if (s){
    clearOrders(s);
    s.orders.push({type:'move', x:tx, z:tz});
    s.order = s.orders[0];
  }
}

function aiTick(){
  aiAssignWorkers();
  const base = aiBaseCenter();
  const min = S.min[ENEMY];
  const workers = aiUnitCount('worker');

  if (supplyFree(ENEMY) < 8 && min > 130 && !aiPending('supply')){
    aiTryBuild('supply', base.x, base.z, 12, 26);
  }
  if (aiCount('foundry') === 0 && min > 220 && !aiPending('foundry')){
    aiTryBuild('foundry', base.x, base.z, 14, 26);
  }
  if (workers < 15 && min > 170){
    aiTrainAt('hq','worker');
    aiTrainAt('hq','worker');
  }
  if (S.t > 220 && aiCount('starport') === 0 && min > 520 && !aiPending('starport')){
    aiTryBuild('starport', base.x, base.z, 16, 30);
  }
  if (S.t > 180 && aiCount('lab') === 0 && min > 360 && !aiPending('lab')){
    aiTryBuild('lab', base.x, base.z, 15, 28);
  }
  if (workers > 8 && aiCount('refinery') < 2 && min > 400 && !aiPending('refinery')){
    const n = nodes.filter(n2=>n2.amount>600 && seenTo(1,n2.x,n2.z) &&
      dist2(n2.x,n2.z,base.x,base.z) > 22*22)[0];
    if (n) aiTryBuild('refinery', n.x, n.z, 12, 19);
  }
  if (min > 620 && aiCount('turret') < 2 + Math.floor(S.t/170) && !aiPending('turret')){
    /* face the player's side of the map */
    const ang = Math.atan2(WORLD*0.3 - base.x, WORLD*0.3 - base.z);
    const tx = base.x + Math.sin(ang)*rr(18,30), tz = base.z + Math.cos(ang)*rr(18,30);
    if (!aiTryBuild('turret', tx, tz, 2, 12)) aiTryBuild('turret', base.x, base.z, 16, 30);
  }
  if (min > 1150 && aiCount('hq') < 3 && S.t > 300 && !aiPending('hq')){
    const n = nodes.filter(n2=>n2.amount>800 && seenTo(1,n2.x,n2.z) &&
      dist2(n2.x,n2.z,base.x,base.z) > 46*46)[0];
    if (n) aiTryBuild('hq', n.x, n.z, 20, 30);
  }
  if (min > 460) aiResearch();
  aiTrainArmy();
  aiArmy();
  aiScout();
}

/* ================================================================
   GAME FLOW
   ================================================================ */
function resetGame(){
  units.length = 0; buildings.length = 0; projs.length = 0; fxList.length = 0; beacons.length = 0;
  knowledge[0].clear(); knowledge[1].clear();
  fog[0].vis.fill(0); fog[0].exp.fill(0); fog[1].vis.fill(0); fog[1].exp.fill(0);
  nextId = 1;
  S.t = 0;
  S.min = [800, 800];
  S.kills = [0,0]; S.lost = [0,0]; S.gathered = [0,0];
  S.upg = [{weapons:0,armor:0,thrusters:0,harvest:0},{weapons:0,armor:0,thrusters:0,harvest:0}];
  S.ai = { nextBuild:0, nextArmy:0, nextScout:55, attackAt:200, waveSize:4, threatT:0, threatPos:null };
  S.over = false; S.paused = false;
  baseAlertT = 0;
  _seed = 20240617;
  genWorld();

  spawnBuilding('hq', HUMAN, 56, 184, true);
  for (let i=0;i<4;i++) spawnUnit('worker', HUMAN, 52 + (i%2)*7, 172 + Math.floor(i/2)*8);
  spawnBuilding('hq', ENEMY, 184, 56, true);
  for (let i=0;i<4;i++) spawnUnit('worker', ENEMY, 188 - (i%2)*7, 68 - Math.floor(i/2)*8);

  cam.x = 58; cam.z = 176; cam.dist = 130; cam.yaw = Math.atan2(120-56, 120-184);
  clearSelection();
  recomputeSupply();
  computeVision(); updateKnowledge();
  /* each side has surveyed its starting deposits */
  for (const n of nodes){
    if (dist(n.x,n.z,56,184) < 34) stampExplored(fog[0], n.x, n.z, 9);
    if (dist(n.x,n.z,184,56) < 34) stampExplored(fog[1], n.x, n.z, 9);
  }
  for (const g of groups) g.length = 0;
  selDirty = true;
}

function startGame(){
  document.getElementById('title').classList.add('hidden');
  document.getElementById('end').classList.add('hidden');
  document.getElementById('pause').classList.add('hidden');
  resetGame();
  S.running = true; S.paused = false;
  Snd.init(); Snd.play('complete');
  toast('Frontier landing complete. Build, scout, and destroy the enemy Headquarters.', 'good');
  setTimeout(()=>toast('Tip: Prospectors gather Aetherium automatically when ordered onto a deposit.', 'good'), 6000);
}

function togglePause(){
  if (S.over || !S.running) return;
  S.paused = !S.paused;
  document.getElementById('pause').classList.toggle('hidden', !S.paused);
  Snd.play('click');
}
function toggleHelp(){
  const h = document.getElementById('help');
  const show = h.classList.contains('hidden');
  h.classList.toggle('hidden', !show);
  Snd.play('click');
}
function closeHelp(){ document.getElementById('help').classList.add('hidden'); }

function endGame(win){
  if (S.over) return;
  S.over = true; S.running = false;
  const box = document.getElementById('end');
  document.getElementById('endTitle').textContent = win ? 'VICTORY' : 'DEFEAT';
  document.getElementById('endTitle').className = 'endtitle ' + (win ? 'won' : 'lost');
  document.getElementById('endSub').textContent = win
    ? 'THE KHAR DOMINION HAS BEEN DRIVEN FROM THE FRONTIER'
    : 'THE EXPEDITION HAS LOST ITS HEADQUARTERS';
  let army = 0;
  for (const u of units) if (u.player===HUMAN && UNITS[u.key].dmg) army++;
  document.getElementById('endStats').innerHTML = [
    ['MISSION TIME', fmtTime(S.t)],
    ['AETHERIUM MINED', Math.floor(S.gathered[HUMAN])],
    ['ENEMIES DESTROYED', S.kills[HUMAN]],
    ['UNITS LOST', S.lost[HUMAN]],
    ['FORCES SURVIVING', army],
    ['SUPPLY CAPACITY', S.supCap[HUMAN]]
  ].map(s=>'<div class="sbox"><div class="k">'+s[0]+'</div><div class="v">'+s[1]+'</div></div>').join('');
  box.classList.remove('hidden');
  Snd.play(win ? 'victory' : 'defeat');
}

/* buttons */
document.getElementById('btnStart').addEventListener('click', startGame);
document.getElementById('btnRestart').addEventListener('click', ()=>{ startGame(); });
document.getElementById('btnRestart2').addEventListener('click', ()=>{ startGame(); });
document.getElementById('btnResume').addEventListener('click', togglePause);
document.getElementById('btnPause').addEventListener('click', togglePause);
document.getElementById('btnHelp').addEventListener('click', toggleHelp);
document.getElementById('btnHelp2').addEventListener('click', toggleHelp);
document.getElementById('btnCloseHelp').addEventListener('click', closeHelp);
document.getElementById('btnMute').addEventListener('click', (e)=>{
  Snd.init();
  Snd.muted = !Snd.muted;
  e.target.textContent = Snd.muted ? 'SOUND: OFF' : 'SOUND: ON';
  if (!Snd.muted) Snd.play('click');
});

/* ================================================================
   CAMERA INPUT + MAIN LOOP
   ================================================================ */
function updateCameraInput(dt){
  const speed = cam.dist*0.85*dt;
  let mv = 0, mh = 0;
  if (keys['arrowup']) mv += 1;
  if (keys['arrowdown']) mv -= 1;
  if (keys['arrowleft']) mh -= 1;
  if (keys['arrowright']) mh += 1;
  if (mouse.over && S.running && !S.paused && !S.over){
    if (mouse.x < 10) mh -= 1;
    if (mouse.x > CW-10) mh += 1;
    if (mouse.y < 10) mv += 1;
    if (mouse.y > CH-10) mv -= 1;
  }
  if (mv || mh){
    const fw = Math.sin(cam.yaw), fz = Math.cos(cam.yaw);
    const rx = Math.cos(cam.yaw), rz = -Math.sin(cam.yaw);
    cam.x += (fw*mv + rx*mh)*speed;
    cam.z += (fz*mv + rz*mh)*speed;
  }
  if (keys['q']) cam.yaw -= 1.5*dt;
  if (keys['e']) cam.yaw += 1.5*dt;
}

function checkVictory(){
  let hqH = 0, hqE = 0;
  for (const b of buildings){
    if (b.dead || b.key !== 'hq') continue;
    if (b.player === HUMAN) hqH++; else hqE++;
  }
  if (hqE === 0) endGame(true);
  else if (hqH === 0) endGame(false);
}

function render(){
  updateCamera();
  screenToGround(mouse.x, mouse.y, mouse.world);
  drawSky();
  drawGroundPlane();
  drawGrid();
  drawGroundDetail();
  drawObjects();
  drawEffects();
  drawFog();
  drawWorldOverlay();
  drawMinimap();
}

let lastTime = performance.now();
let victoryTimer = 0, aiTimer = 0, sweepTimer = 0;
function frame(now){
  requestAnimationFrame(frame);
  const dt = Math.min(0.06, Math.max(0.001, (now - lastTime)/1000));
  lastTime = now;
  if (S.running && !S.paused && !S.over){
    updateCameraInput(dt);
    updateWorld(dt);
    aiTimer -= dt;
    if (aiTimer <= 0){ aiTimer = 1.0; aiTick(); }
    victoryTimer -= dt;
    if (victoryTimer <= 0){ victoryTimer = 1.0; checkVictory(); }
    hudTimer -= dt;
    if (hudTimer <= 0){ hudTimer = 0.25; updateHUD(); }
    if (units.length + buildings.length > 0 && (S.t % 30) < dt) toast('War grows: ' + units.length + ' units active on the frontier.', 'good');
  } else {
    hudTimer -= dt;
    if (hudTimer <= 0){ hudTimer = 0.35; updateHUD(); }
  }
  render();
}

/* ================================================================
   BOOT
   ================================================================ */
resize();
resetGame();
updateHUD();
requestAnimationFrame(frame);

window.FW = { S, units, buildings, nodes, cam, sel, spawnUnit, spawnBuilding, resetGame, startGame,
  updateWorld, aiTick, knowledge, fog, UNITS, BUILDS, cmdMove, cmdAttackMove, endGame, aiTimer:0,
  applyDamage, canPlace, placeBuilding, enqueueUnit, startResearch, project, screenToGround, checkVictory };

</script>
</body>
</html>
<!-- agent-meta {"model":"xiaomi/mimo-v2.6-pro","provider":"openrouter","persona":"full","sessionId":"1d10e891-81bb-48bd-884a-58336012d827","tokensIn":737694,"tokensOut":7669,"tokensTotal":745363,"cost":0.030222608400000007,"turns":17,"toolCalls":18,"failedToolCalls":0,"timestamp":"2026-10-01T16:02:35.968Z"} -->