diff --git a/assets/images/planets.png b/assets/images/planets.png index f760ce9..1f820c9 100644 Binary files a/assets/images/planets.png and b/assets/images/planets.png differ diff --git a/assets/images/planets.psd b/assets/images/planets.psd index 98ce9ba..974c091 100644 Binary files a/assets/images/planets.psd and b/assets/images/planets.psd differ diff --git a/assets/videos/capital-land-neutral.mp4 b/assets/videos/capital-land-neutral.mp4 new file mode 100644 index 0000000..3233229 Binary files /dev/null and b/assets/videos/capital-land-neutral.mp4 differ diff --git a/dev/build-shot.html b/dev/build-shot.html new file mode 100644 index 0000000..f273cc6 --- /dev/null +++ b/dev/build-shot.html @@ -0,0 +1,17 @@ + + + + + + Orbit — Build console (dev shot) + + + + +
+ + + diff --git a/dev/build-shot.mjs b/dev/build-shot.mjs new file mode 100644 index 0000000..94e6a1e --- /dev/null +++ b/dev/build-shot.mjs @@ -0,0 +1,138 @@ +/** + * Dev-only: boot the game (menu → new game → land on the home world), + * open the BUILD console and stage the full row-state matrix, so a + * headless screenshot shows every indicator in one frame: + * + * PLANET tab — tether-l1 BUILT (the lit row: soft accent fill, glow + * ring, icon halo, the stamped seal, "INSTALLED ON …"), + * tether-l2 ACTIVE (progress bar + the pulsing tab pip), + * tether-l3 LOCKED (missing requirements); + * MINING tab — improved arm BUILT ship-wide (installed on ANOTHER + * world: "SHIP-WIDE · INSTALLED FROM KEPLER-186F"), + * advanced arm READY, storage LOCKED. + * + * node dev/server.mjs 8080 + * chrome --headless --disable-gpu --remote-debugging-port=9333 \ + * --window-size=1280,720 about:blank + * # planet tab (tether-l1 selected — the BUILT detail readout): + * node dev/cdp-shot.mjs "http://127.0.0.1:8080/dev/build-shot.html" out.png \ + * "!!window.__BUILD_SHOT?.ready" 25000 \ + * "document.getElementById('report').style.display='none'; true" + * # mining tab (improved arm selected — the SHIP-WIDE built readout): + * node dev/cdp-shot.mjs "http://127.0.0.1:8080/dev/build-shot.html" out-mining.png \ + * "!!window.__BUILD_SHOT?.ready" 25000 \ + * "window.__buildShotMining(); document.getElementById('report').style.display='none'; true" + */ +import Phaser from '../js/vendor/phaser.js'; +import { config } from '../js/config/Config.js'; +import { ConfigLoader } from '../js/config/ConfigLoader.js'; +import { createGameConfig } from '../js/config/GameConfig.js'; +import { MenuScene } from '../js/scenes/MenuScene.js'; +import { GameScene } from '../js/scenes/GameScene.js'; +import { SurfaceScene } from '../js/scenes/SurfaceScene.js'; + +const data = await ConfigLoader.load(); +config.init(data); + +// Capture console errors + uncaught exceptions for the report. +const errors = []; +const origErr = console.error.bind(console); +console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); }; +window.addEventListener('error', (e) => errors.push(String(e.message))); +window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`)); + +const gameConfig = createGameConfig(); +gameConfig.scene = [MenuScene, GameScene, SurfaceScene]; +const game = new Phaser.Game(gameConfig); +window.game = game; + +// The report lives OUTSIDE the canvas — a DOM
 the screenshot can
+// always read (headless canvases don't have to cooperate).
+const report = document.createElement('pre');
+report.id = 'report';
+report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;max-width:70%;margin:0;padding:8px 12px;font:13px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.92);border:1px solid #1b3a5a;white-space:pre-wrap;';
+document.body.appendChild(report);
+const setReport = (lines) => { report.textContent = (Array.isArray(lines) ? lines : [lines]).join('\n'); };
+setReport('booting… (menu → new game → home world → build console)');
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function waitFor(fn, tries = 150, everyMs = 100) {
+  for (let i = 0; i < tries; i++) {
+    const v = fn();
+    if (v) return v;
+    await sleep(everyMs);
+  }
+  return null;
+}
+
+try {
+  // 1) The real menu flow: NEW GAME (fresh seed, registry reset).
+  const menu = await waitFor(() => { const m = game.scene.getScene('MenuScene'); return m?.newGameBtn ? m : null; });
+  if (!menu) throw new Error('menu never booted');
+  menu.startNewGame();
+  const gs = await waitFor(() => { const g = game.scene.getScene('GameScene'); return g?.ship && g?.buildState ? g : null; }, 200);
+  if (!gs) throw new Error('GameScene never booted');
+  await sleep(800);
+
+  // 2) Land on the home world the game's own way.
+  gs.startLanding(gs.commsTargetFor(gs.planet));
+  const ss = await waitFor(() => { const c = game.scene.getScene('SurfaceScene'); return c?.buildWindow ? c : null; });
+  if (!ss) throw new Error('surface never booted');
+  await sleep(700);
+  const win = ss.buildWindow;
+  const home = win.planetName;
+
+  // 3) Stage the state matrix (the scene's own seams — the same calls a
+  //    player's run makes): blueprints researched, a ship upgrade
+  //    installed on ANOTHER world, the level-2 tether build in flight.
+  gs.ship.setMinerals(1000);
+  gs.researchState.unlock('exploration', 'tether_l2');
+  gs.researchState.unlock('mining', 'improved_arm');
+  gs.researchState.unlock('mining', 'advanced_arm');
+  gs.buildState.markBuilt('Kepler-186f', 'mining-arm-improved'); // ship-wide, from elsewhere
+  const began = gs.beginBuild(home, 'tether-l2');
+  if (!began.ok) throw new Error(`beginBuild refused: ${began.reason}`);
+
+  // 4) Open the console and settle the boot reveal.
+  win.open();
+  await waitFor(() => win.openState === 'open' && !win.reveal.length, 60, 100);
+  await sleep(300);
+  win.refresh();
+  win.select('planet', 'tether-l1'); // the BUILT row's detail readout
+  await sleep(300);
+
+  // The report: row states, pip states, the installed lines, errors.
+  const { rowState } = await import('../js/build/BuildModel.js');
+  const planetList = win.lists.get('planet');
+  const miningList = win.lists.get('mining');
+  const st = (list, id) =>
+    `${id}=${rowState(list.entry.builds[id], win.ctxFor(id), win._activeOnPlanet(), id).toUpperCase()}`;
+  const pips = (tab) => tab.pips.map((p) => `${p.id.slice(0, 6)}:${p.state}`).join(' ');
+  const lines = [
+    errors.length === 0 ? 'SMOKE OK — no console errors' : `ERRORS:\n${errors.slice(0, 3).join('\n')}`,
+    `window: ${win.openState} · cat=${win.activeCat} · sel=${win.selected ? win.selected.category + '/' + win.selected.id : '—'}`,
+    `planet rows: ${planetList.entry.order.map((id) => st(planetList, id)).join('  ')}`,
+    `mining rows: ${miningList.entry.order.map((id) => st(miningList, id)).join('  ')}`,
+    `planet pips: ${pips(win.tabs.find((t) => t.id === 'planet'))}`,
+    `mining pips: ${pips(win.tabs.find((t) => t.id === 'mining'))}`,
+    `builtOn(mining-arm-improved): ${gs.buildState.builtOn('mining-arm-improved')}`,
+    `builtOn(tether-l1): ${gs.buildState.builtOn('tether-l1')}`,
+    `installed line (arm): ${win._installedLine('mining-arm-improved')}`,
+    `installed line (l1): ${win._installedLine('tether-l1')}`,
+  ];
+  setReport(lines);
+  window.__BUILD_SHOT = { ready: true, lines, errors };
+  // second shot: flip to the MINING tab with the ship-wide BUILT arm selected
+  window.__buildShotMining = () => {
+    win.switchCategory('mining');
+    win.select('mining', 'mining-arm-improved');
+    win.refresh();
+  };
+  console.info('build-shot: report painted');
+} catch (err) {
+  errors.push(`FATAL: ${err.message}`);
+  setReport(['FATAL: ' + err.message, ...errors.slice(0, 3)]);
+  window.__BUILD_SHOT = { ready: true, fatal: String(err.message), errors };
+  console.error('build-shot: fatal', err);
+}
diff --git a/dev/builds.test.mjs b/dev/builds.test.mjs
index bdeaccb..78c7df9 100644
--- a/dev/builds.test.mjs
+++ b/dev/builds.test.mjs
@@ -167,6 +167,13 @@ check('state: isBuiltAnywhere sees a ship-scoped build installed on ANY planet',
 check('state: isBuiltAnywhere is false for an uninstalled build', shipS.isBuiltAnywhere('mining-arm-advanced') === false);
 check('state: the per-planet isBuilt is unchanged (the record still keys the planet)', shipS.isBuilt('Kepler-186f', 'mining-arm-improved') === true && shipS.isBuilt('Home World', 'mining-arm-improved') === false);
 
+// builtOn — the world a build was installed on (the console's
+// "INSTALLED ON …" / "SHIP-WIDE · INSTALLED FROM …" lines name this
+// world, never the planet the console happens to sit on)
+check('state: builtOn names the world the build was installed on', shipS.builtOn('mining-arm-improved') === 'Kepler-186f');
+check('state: builtOn is null for an uninstalled build', shipS.builtOn('mining-arm-advanced') === null);
+check('state: builtOn is null for unknown ids', shipS.builtOn(null) === null);
+
 check('model: rowState reads built for a mining build (ship-scoped ctx)', rowState(armImproved, ctx({ miningResearch: ['improved_arm'], isBuilt: () => true }), null, 'mining-arm-improved') === 'built');
 check('model: rowState reads available for a researched mining build', rowState(armImproved, ctx({ miningResearch: ['improved_arm'] }), null, 'mining-arm-improved') === 'available');
 check('model: rowState reads locked before the research', rowState(armImproved, ctx(), null, 'mining-arm-improved') === 'locked');
diff --git a/js/build/BuildState.js b/js/build/BuildState.js
index 73516f7..931cf58 100644
--- a/js/build/BuildState.js
+++ b/js/build/BuildState.js
@@ -62,6 +62,20 @@ export class BuildState {
     return [...(this.built.get(planet) ?? [])];
   }
 
+  /**
+   * The world a build was installed on (the record holding it; null when
+   * never built). One-off builds land on exactly one world, so the first
+   * hit is the record. The console's "INSTALLED ON …" / "INSTALLED FROM …"
+   * lines name THIS world (BuildWindow._installedLine) — a ship upgrade
+   * built on Kepler shows "FROM KEPLER" wherever the ship lands, not the
+   * planet the console happens to sit on.
+   */
+  builtOn(build) {
+    if (!build) return null;
+    for (const [planet, set] of this.built) if (set.has(build)) return planet;
+    return null;
+  }
+
   // ── the single in-progress build ─────────────────────────────────────────
   getActive() {
     return this.active;
diff --git a/js/ui/BuildWindow.js b/js/ui/BuildWindow.js
index 472e4f3..0a663d3 100644
--- a/js/ui/BuildWindow.js
+++ b/js/ui/BuildWindow.js
@@ -77,7 +77,8 @@ const C = {
 /**
  * Draw a cut-corner plate with its top-left at (x, y) into an existing
  * Graphics (CyberShape.points is centred on (0,0) — translate by the
- * plate centre). Mirrors ResearchWindow.panel().
+ * plate centre). Mirrors ResearchWindow.panel(), including the wide,
+ * low-alpha `glow` stroke pass (the "powered" ring under a lit plate).
  */
 function panel(g, x, y, w, h, o = {}) {
   const cx = x + w / 2;
@@ -93,6 +94,10 @@ function panel(g, x, y, w, h, o = {}) {
     g.lineStyle(o.lineWidth ?? 1.5, o.stroke, o.strokeAlpha ?? 1);
     g.strokePoints(pts, true);
   }
+  if (o.glow !== undefined) {
+    g.lineStyle((o.lineWidth ?? 1.5) + 4, o.glow, o.glowAlpha ?? 0.22);
+    g.strokePoints(pts, true);
+  }
 }
 
 /** Corner brackets framing a rect (x, y = top-left). */
@@ -113,6 +118,29 @@ function brackets(g, x, y, w, h, o = {}) {
   g.lineBetween(x2, y2, x2, y2 - len);
 }
 
+/**
+ * Radial halo texture (white → tintable per row) — the "powered" glow
+ * behind a built row's icon. Mirrors ResearchWindow's halo (same radial
+ * recipe, own key — the texture registry is game-global, so both
+ * windows coexist). Built once per scene.
+ */
+function ensureHaloTexture(scene) {
+  const key = 'build_halo';
+  if (scene.textures?.exists(key)) return key;
+  const c = document.createElement('canvas');
+  c.width = 64;
+  c.height = 64;
+  const ctx = c.getContext('2d');
+  const grad = ctx.createRadialGradient(32, 32, 2, 32, 32, 32);
+  grad.addColorStop(0, 'rgba(255,255,255,0.9)');
+  grad.addColorStop(0.4, 'rgba(255,255,255,0.28)');
+  grad.addColorStop(1, 'rgba(255,255,255,0)');
+  ctx.fillStyle = grad;
+  ctx.fillRect(0, 0, 64, 64);
+  scene.textures.addCanvas(key, c);
+  return key;
+}
+
 export class BuildWindow extends Phaser.GameObjects.Container {
   static VIDEO_KEY = 'build_console';
 
@@ -209,6 +237,17 @@ export class BuildWindow extends Phaser.GameObjects.Container {
     return a && a.planet === this.planetName ? a : null;
   }
 
+  /** The "where is it" line for an installed build — scope-aware:
+   *  planet-scoped → INSTALLED ON ; ship-scoped → SHIP-WIDE,
+   *  named after the world it was installed FROM (BuildState.builtOn),
+   *  never the planet the console happens to sit on (the old line said
+   *  "INSTALLED ON " for a ship upgrade built on another world). */
+  _installedLine(id) {
+    const world = String(this.state?.builtOn(id) ?? this.planetName ?? '');
+    if (isShipScoped(defById(id))) return `SHIP-WIDE · INSTALLED FROM ${world.toUpperCase()}`;
+    return `INSTALLED ON ${world.toUpperCase()}`;
+  }
+
   // ------------------------------------------------------------ geometry
   _measure() {
     const W = this.scene.scale.width;
@@ -544,10 +583,19 @@ export class BuildWindow extends Phaser.GameObjects.Container {
           letterSpacing: 2,
         })
         .setScrollFactor(0);
-      const w = txt.width + 44;
+      // progress pips — one per build in this category: filled = built,
+      // pulsing = in progress, hollow = not yet (the research tab idiom).
+      // loadBuilds mirrors what _buildLists paints, so the pip count
+      // tracks the row list even though the lists are built after the tabs.
+      const list = loadBuilds(cat.id);
+      const nPips = list.order.length;
+      const pipS = 5;
+      const pipGap = 4;
+      const pipSpan = nPips ? nPips * pipS + (nPips - 1) * pipGap : 0;
+      const w = txt.width + 44 + (pipSpan ? pipSpan + 14 : 0);
       const y = bodyY + tabH / 2;
       const g = this.scene.add.graphics().setScrollFactor(0);
-      const tab = { id: cat.id, x, y, w, h: tabH, txt, g, accent, hover: false };
+      const tab = { id: cat.id, x, y, w, h: tabH, txt, g, accent, hover: false, pips: [] };
       const tabRect = new Phaser.Geom.Rectangle(x, y - tabH / 2, w, tabH);
       g.setInteractive({
         useHandCursor: true,
@@ -568,6 +616,21 @@ export class BuildWindow extends Phaser.GameObjects.Container {
         this.switchCategory(cat.id);
       });
       txt.setPosition(x + 26, y - 4);
+      const pipX0 = x + 26 + txt.width + 14;
+      const pipY = bodyY + tabH / 2 - 1;
+      for (let i = 0; i < nPips; i++) {
+        const pg = this.scene.add.graphics().setScrollFactor(0).setDepth(2);
+        this.add(pg);
+        tab.pips.push({
+          g: pg,
+          id: list.order[i],
+          def: list.builds[list.order[i]],
+          x: pipX0 + i * (pipS + pipGap),
+          y: pipY,
+          s: pipS,
+          state: 'todo',
+        });
+      }
       g.setDepth(2);
       txt.setDepth(3);
       this.add(g);
@@ -606,6 +669,33 @@ export class BuildWindow extends Phaser.GameObjects.Container {
       t.g.fillTriangle(dx, dy - 4.5, dx + 4.5, dy, dx, dy + 4.5, dx - 4.5, dy);
       t.txt.setColor(active ? toCss(t.accent) : toCss(C.ink));
       t.txt.setAlpha(active ? 1 : t.hover ? 0.9 : 0.62);
+
+      // progress pips — state from BuildState (a cheap repaint: a few
+      // 5 px squares each; update() only pulses the in-progress one)
+      const run = this._activeOnPlanet();
+      for (const p of t.pips) {
+        p.g.clear();
+        // ship-scoped builds count as built wherever the ship lands;
+        // planet-scoped ones only on this world (the row rule, mirrored)
+        const built = p.def && isShipScoped(p.def)
+          ? (this.state?.isBuiltAnywhere(p.id) ?? false)
+          : (this.state?.isBuilt(this.planetName, p.id) ?? false);
+        const st = built ? 'done' : run && run.build === p.id ? 'progress' : 'todo';
+        p.state = st;
+        if (st === 'done') {
+          p.g.fillStyle(t.accent, 1);
+          p.g.fillRect(p.x, p.y - p.s / 2, p.s, p.s);
+          p.g.setAlpha(active ? 1 : 0.8);
+        } else if (st === 'progress') {
+          p.g.fillStyle(t.accent, 1);
+          p.g.fillRect(p.x, p.y - p.s / 2, p.s, p.s);
+          p.g.setAlpha(0.35); // update() drives the pulse
+        } else {
+          p.g.lineStyle(1, active ? 0x6284b0 : 0x3d4c74, active ? 0.95 : 0.6);
+          p.g.strokeRect(p.x, p.y - p.s / 2, p.s, p.s);
+          p.g.setAlpha(active ? 1 : 0.65);
+        }
+      }
     }
   }
 
@@ -635,6 +725,14 @@ export class BuildWindow extends Phaser.GameObjects.Container {
         const y = startY + i * (rowH + rowGap);
         const w = rightW - 16;
         const g = this.scene.add.graphics().setScrollFactor(0);
+        // the powered halo behind the icon — "built" rows drive its
+        // alpha in _paintRow (the research lit-node idiom)
+        const halo = this.scene.add
+          .image(x + 34, y + rowH / 2, ensureHaloTexture(this.scene))
+          .setDisplaySize(56, 56)
+          .setScrollFactor(0)
+          .setBlendMode(Phaser.BlendModes.ADD)
+          .setAlpha(0);
         const icon = this.scene.add
           .image(x + 34, y + rowH / 2, ensureIcon(this.scene, def.icon ?? 'diamond', accent))
           .setDisplaySize(40, 40)
@@ -668,7 +766,7 @@ export class BuildWindow extends Phaser.GameObjects.Container {
           .setScrollFactor(0);
         tag.setOrigin(1, 0);
         const bar = this.scene.add.graphics().setScrollFactor(0);
-        const row = { id, def, x, y, w, h: rowH, g, icon, label, sub, tag, bar, hover: false, accent };
+        const row = { id, def, x, y, w, h: rowH, g, halo, icon, label, sub, tag, bar, hover: false, accent };
         const hit = new Phaser.Geom.Rectangle(x, y, w, rowH);
         g.setInteractive({
           useHandCursor: true,
@@ -689,6 +787,7 @@ export class BuildWindow extends Phaser.GameObjects.Container {
           this.select(cat.id, id);
         });
         g.setDepth(2);
+        halo.setDepth(2.5); // over the plate (2), under the icon (3)
         icon.setDepth(3);
         label.setDepth(3);
         sub.setDepth(3);
@@ -696,7 +795,7 @@ export class BuildWindow extends Phaser.GameObjects.Container {
         bar.setDepth(3);
         // v4 Container.add(child, index) — multi-add is the ARRAY form
         // (add(a, b, c) would silently drop every child after `a`).
-        cont.add([g, icon, label, sub, tag, bar]);
+        cont.add([g, halo, icon, label, sub, tag, bar]);
         rows.push(row);
       }
 
@@ -736,9 +835,11 @@ export class BuildWindow extends Phaser.GameObjects.Container {
     const w = row.w;
     const h = row.h;
 
+    let fill = C.panel;
     let fillA = 0.4;
     let stroke = 0x22405f;
     let strokeA = 0.55;
+    let glowA = 0;
     let labelColor = toCss(C.faint);
     let subColor = toCss(C.faint);
     let tagColor = toCss(C.faint);
@@ -781,6 +882,7 @@ export class BuildWindow extends Phaser.GameObjects.Container {
       fillA = 0.2;
       stroke = row.accent;
       strokeA = 1;
+      glowA = 0.25;
       labelColor = toCss(0xffffff);
       subColor = toCss(row.accent);
       tagColor = toCss(row.accent);
@@ -791,16 +893,21 @@ export class BuildWindow extends Phaser.GameObjects.Container {
       sub = `IN PROGRESS · ${pct}% · ${Math.ceil((p?.remainingMs ?? 0) / 1000)}S LEFT`;
       tag = `${pct}%`;
     } else if (st === 'built') {
-      fillA = 0.18;
+      // LIT — the research "unlocked" idiom: a soft accent fill, a glow
+      // ring, the icon halo and the stamped seal. Reads "installed" at a
+      // glance; the sub line names the scope (planet vs ship-wide).
+      fill = row.accent;
+      fillA = 0.13;
       stroke = row.accent;
-      strokeA = 0.55;
+      strokeA = 0.95;
+      glowA = 0.3;
       labelColor = toCss(C.ink);
       subColor = toCss(C.faint);
-      tagColor = toCss(C.dim);
-      tag = 'BUILT ✓';
+      tagColor = toCss(row.accent);
+      tag = 'BUILT';
       iconTint = 0xffffff;
-      iconAlpha = 0.85;
-      sub = `INSTALLED ON ${String(this.planetName ?? '').toUpperCase()}`;
+      iconAlpha = 1;
+      sub = this._installedLine(row.id);
     } else {
       // locked — grayed out, with the missing requirements
       const missing = missingRequirements(row.def, this.ctxFor(row.id));
@@ -808,7 +915,19 @@ export class BuildWindow extends Phaser.GameObjects.Container {
     }
     if (row.hover) fillA = Math.min(0.9, fillA + 0.12);
 
-    panel(g, row.x, row.y, w, h, { notch: 10, fill: C.panel, fillAlpha: fillA, stroke, strokeAlpha: strokeA });
+    panel(g, row.x, row.y, w, h, {
+      notch: 10,
+      fill,
+      fillAlpha: fillA,
+      stroke,
+      strokeAlpha: strokeA,
+      ...(glowA ? { glow: row.accent, glowAlpha: glowA } : {}),
+    });
+    // the powered halo behind the icon (research: 0.5 lit, 0.32 active)
+    if (row.halo) {
+      row.halo.setTint(row.accent);
+      row.halo.setAlpha(st === 'built' ? 0.5 : st === 'active' ? 0.32 : 0);
+    }
     row.icon.setTint(iconTint);
     row.icon.setAlpha(iconAlpha);
     row.label.setColor(labelColor);
@@ -817,6 +936,19 @@ export class BuildWindow extends Phaser.GameObjects.Container {
     row.tag.setText(tag);
     row.tag.setColor(tagColor);
 
+    // the stamped seal (built rows) — the research "certified" glyph:
+    // an accent diamond with the check knocked out, left of the BUILT tag
+    if (st === 'built') {
+      const { rightX, rightW } = this.geo;
+      const gx = rightX + rightW - 26 - row.tag.width - 12;
+      const gy = row.y + h / 2;
+      g.fillStyle(row.accent, 1);
+      g.fillTriangle(gx, gy - 6, gx + 6, gy, gx, gy + 6, gx - 6, gy);
+      g.lineStyle(1.8, 0x050b14, 1);
+      g.lineBetween(gx - 3.2, gy + 0.8, gx - 0.9, gy + 3.2);
+      g.lineBetween(gx - 0.9, gy + 3.2, gx + 3.2, gy - 2.8);
+    }
+
     // selection brackets
     const sel = this.selected;
     if (sel && sel.category === this.activeCat && sel.id === row.id) {
@@ -850,6 +982,7 @@ export class BuildWindow extends Phaser.GameObjects.Container {
   _paintAll(catId) {
     const list = this.lists.get(catId);
     if (!list) return;
+    this._paintTabs(); // the tab pips track the same state (a build flipping to BUILT fills one)
     for (const row of list.rows) this._paintRow(row);
   }
 
@@ -1080,12 +1213,15 @@ export class BuildWindow extends Phaser.GameObjects.Container {
     const state = this.state;
     if (!sel || !state) return 'locked';
     const active = state.getActive();
+    const def = this._selDef();
+    // BUILT outranks BUSY: an installed build isn't waiting on the
+    // console — it's done (the research console reads OWNED the same
+    // way, mid-project; the deck bar carries the in-flight status).
+    if (def && (isShipScoped(def) ? state.isBuiltAnywhere(sel.id) : state.isBuilt(this.planetName, sel.id))) return 'built';
     if (active) {
       if (active.planet === this.planetName && active.build === sel.id) return 'active';
       return 'busy';
     }
-    const def = this._selDef();
-    if (isShipScoped(def) ? state.isBuiltAnywhere(sel.id) : state.isBuilt(this.planetName, sel.id)) return 'built';
     if (def && isAvailable(def, this.ctxFor(sel.id))) {
       return canAfford(def, this.minerals()) ? 'build' : 'unaffordable';
     }
@@ -1178,7 +1314,7 @@ export class BuildWindow extends Phaser.GameObjects.Container {
       meta = `IN PROGRESS · ${Math.round((p?.fraction ?? 0) * 100)}% · ${Math.ceil((p?.remainingMs ?? 0) / 1000)}S LEFT · DURATION ${durStr}`;
       metaColor = accent;
     } else if (st === 'built') {
-      meta = `INSTALLED ON ${String(this.planetName ?? '').toUpperCase()} · DURATION ${durStr}`;
+      meta = `${this._installedLine(sel.id)} · DURATION ${durStr}`;
       metaColor = accent;
     } else if (st === 'busy') {
       meta = `BUILD CONSOLE BUSY — ONE BUILD AT A TIME · DURATION ${durStr}`;
@@ -1193,7 +1329,8 @@ export class BuildWindow extends Phaser.GameObjects.Container {
     this.dMeta.setText(meta);
     this.dMeta.setColor(toCss(metaColor));
 
-    // cost line — the highlighted resource requirement (minerals)
+    // cost line — the highlighted resource requirement (minerals); a
+    // BUILT build shows it dimmed + PAID (the cost already landed)
     const lines = costLines(def);
     if (!lines.length) {
       this.dCost.setText('COST  —  NO COST · STARTING EQUIPMENT');
@@ -1202,9 +1339,14 @@ export class BuildWindow extends Phaser.GameObjects.Container {
       const costStr = lines
         .map((l) => `${l.amount} ${String(config.get(`builds.resources.${l.res}.label`, l.res))}`)
         .join(' + ');
-      const afford = canAfford(def, this.minerals());
-      this.dCost.setText(`COST  ◆ ${costStr}${afford ? '' : '  ·  INSUFFICIENT MINERALS'}`);
-      this.dCost.setColor(toCss(afford ? C.neon : C.amber));
+      if (st === 'built') {
+        this.dCost.setText(`COST  ◆ ${costStr}  ·  PAID`);
+        this.dCost.setColor(toCss(C.faint));
+      } else {
+        const afford = canAfford(def, this.minerals());
+        this.dCost.setText(`COST  ◆ ${costStr}${afford ? '' : '  ·  INSUFFICIENT MINERALS'}`);
+        this.dCost.setColor(toCss(afford ? C.neon : C.amber));
+      }
     }
 
     this._paintDetailBtn();
@@ -1323,6 +1465,7 @@ export class BuildWindow extends Phaser.GameObjects.Container {
       this.tabs.forEach((t, i) => {
         push(t.g, 300 + i * 70, 240, 'fade');
         push(t.txt, 300 + i * 70, 240, 'fade');
+        for (const p of t.pips) push(p.g, 300 + i * 70, 240, 'fade');
       });
       const entry = this.activeCat ? this.lists.get(this.activeCat) : null;
       if (entry) {
@@ -1330,6 +1473,7 @@ export class BuildWindow extends Phaser.GameObjects.Container {
           // baseY = o.y (0) — the panel shape is drawn at absolute coords,
           // so the object itself must not be repositioned.
           push(row.g, 380 + i * 90, 260, 'rise');
+          push(row.halo, 400 + i * 90, 240, 'fade');
           push(row.icon, 400 + i * 90, 240, 'fade');
           push(row.label, 400 + i * 90, 240, 'fade');
           push(row.sub, 400 + i * 90, 240, 'fade');
@@ -1469,6 +1613,17 @@ export class BuildWindow extends Phaser.GameObjects.Container {
       this._lastMinerals = m;
       if (this.selected) this._paintDetail(false);
     }
+
+    // tab pips: the in-progress one pulses (the rest are static — painted
+    // in _paintTabs on state flips). Last in update(): a _paintAll above
+    // may have just reset the pip's alpha, and the pulse must win the frame.
+    const run = this._activeOnPlanet();
+    if (run) {
+      for (const t of this.tabs) {
+        const pip = t.pips.find((p) => p.id === run.build && p.state === 'progress');
+        if (pip) pip.g.setAlpha(0.3 + 0.45 * Math.abs(Math.sin(time * 0.005)));
+      }
+    }
   }
 
   // ── teardown ───────────────────────────────────────────────────────────────