Add Mining research category and ship-scoped build upgrades

- New `data/research/mining.json` tree (Improved/Advanced Mining Arm, Improved Storage) as blueprints that unlock matching builds
- Three new ship-scoped builds in the console's Mining tab: mining arm rate 1.5/2.0 minerals/s and hold cap 350; MAX-guarded effects applied to `ship.stats`
- Tether Level 3 promoted from research-direct effect to a build (tether-l3) gated on L2 tether, consistent with L2 blueprint pattern
- `BuildState.isBuiltAnywhere` + `isShipScoped` so ship upgrades count as built wherever the ship lands (no re-buy), records still key the planet
- `GameScene._restoreMiningUpgrades` re-derives ship mining stats from build records on load; `_applyMiningUpgrade` handles rate/capacity with toasts and HUD refresh
- New procedural icons: tether3, mining, mining2, storage
- Updated tests (builds, research-builds), diagnostics (BuildDiag v4), docs, README, manifest
This commit is contained in:
Brian Fertig 2026-09-07 11:50:36 -06:00
parent 146dc207df
commit 546e5c9bf3
16 changed files with 623 additions and 74 deletions

View File

@ -111,8 +111,10 @@ node dev/server.mjs 8080
`maxConcurrent: 1`). A deck progress bar tracks the run; completion fires
the tech's `effects` (e.g. `tether: {level: N}` grows the home tether's
range) and a toast. Each category's tree is its own JSON file under
`data/research/` (the first is **Exploration**: Tether Level 14,
Tether Anchoring, Signal Amplification). Unlocks and in-progress work
`data/research/` (**Exploration**: Tether Level 13, Tether Anchoring,
Signal Amplification; **Mining**: Improved Mining Arm, Advanced Mining
Arm, Improved Mining Storage — all three unlock the matching build in
the console's MINING tab). Unlocks and in-progress work
persist in the save bank — and resume correctly on load
- **The Build console** — the **BUILD** deck button (on a planet surface,
where the deck's SHOP/BUILD/TAKE OFF live) opens a full-screen window:
@ -158,7 +160,8 @@ orbit/
│ ├── research.json # RESEARCH: global rules (time unit, one at a time) + category registry
│ │ # trees live one-per-file below (section name = file basename)
│ ├── research/
│ │ └── exploration.json# the Exploration tree: tether levels, anchoring, signal amp
│ │ ├── exploration.json# the Exploration tree: tether levels, anchoring, signal amp
│ │ └── mining.json # the Mining tree: improved/advanced mining arm, improved storage (blueprints)
│ ├── builds.json # BUILDING: the build console's categories/resources + the buildable items
│ ├── actionbar.json # the command deck: 6 slots (Research, Scan, Ship, ·, ·, Menu)
│ └── naming.json # names: star/galaxy syllable pools + the curated PLANET & STATION name banks
@ -208,21 +211,30 @@ Beyond flying, Orbit is built around two progression verbs:
effect kinds plug in without touching the tree data.
- **Building** (`data/builds.json`) — *cost-based*, paid in **minerals**
(and credits, when they land; `resources`). A build is a **one-off
install on a planet** (`category: planet | cargo`): it names its `cost`,
its `duration` (seconds), the research that unlocks it (`requires`,
mirroring the research tree's `unlocks.builds`), optional world-state
gates (`planetRequires`, e.g. the tether level on that world), and its
install** (`category: planet | cargo | mining`): planet builds land on
the world's surface (`targets: ["planet"]`); the Mining tab's builds are
**ship upgrades** (`targets: ["ship"]` — the improved/advanced mining
arm set the ship's mining rate to 1.5 / 2.0 minerals per second, the
improved storage grows the hold to 350): built from any planet's
console, the effect is ship-wide, and it counts as built wherever the
ship lands (no re-buy). Each build names its `cost`, its `duration`
(seconds), the research that unlocks it (`requires`, mirroring the
research tree's `unlocks.builds`), optional world-state gates
(`planetRequires`, e.g. the tether level on that world), and its
`effects` (e.g. `tether: {level: 2}` → the world's tether field
strengthens). The BUILD deck button (on a planet surface) opens the
console window (`js/ui/BuildWindow.js`): looping build feed, category
tabs, the build LIST (locked items grayed with their missing gates),
per-build dossier + highlighted cost + BUILD button. While a build runs
the deck is locked (one at a time) and the state ticks on the game-loop
clock — so a build started on the surface keeps running if the player
takes off. The rules are pure (`js/build/BuildModel.js`, `BuildState.js`
— Node-tested in `dev/builds.test.mjs`); the window is a passive view
that asks `GameScene.beginBuild()` to run the gates, charge the cost,
and start the clock, and the scene applies `effects` on completion.
strengthens; `mining: {rate: 2.0}` / `mining: {capacity: 350}` → the
ship's stats, MAX wins). The BUILD deck button (on a planet surface)
opens the console window (`js/ui/BuildWindow.js`): looping build feed,
category tabs, the build LIST (locked items grayed with their missing
gates), per-build dossier + highlighted cost + BUILD button. While a
build runs the deck is locked (one at a time) and the state ticks on
the game-loop clock — so a build started on the surface keeps running
if the player takes off. The rules are pure (`js/build/BuildModel.js`,
`BuildState.js` — Node-tested in `dev/builds.test.mjs`); the window is
a passive view that asks `GameScene.beginBuild()` to run the gates,
charge the cost, and start the clock, and the scene applies `effects`
on completion (ship upgrades re-derive from the build records on load,
`GameScene._restoreMiningUpgrades`).
- **The command deck** (`data/actionbar.json`, rendered by
`js/ui/ActionBar.js`) — the cyberpunk bar across the bottom of the
screen. Six evenly spaced slots: **Research, Scan, Ship, ·, ·, Menu**

Binary file not shown.

View File

@ -16,6 +16,11 @@
"id": "cargo",
"label": "Cargo",
"accent": "#7ce8a4"
},
{
"id": "mining",
"label": "Mining",
"accent": "#ff8a3d"
}
],
"defaultCategory": "planet",
@ -84,6 +89,94 @@
"theme": {
"color": "#00e5ff"
}
},
"tether-l3": {
"label": "Tether - Level 3",
"description": "Extends the planet's tether to level 3: the field's range grows from 10240 px to 20480 px around the world. Requires the level-3 tether research and a planet already holding a level-2 tether. Installed in 30 seconds for 300 minerals.",
"icon": "tether3",
"category": "planet",
"targets": ["planet"],
"cost": {
"minerals": 300
},
"duration": 30,
"requires": ["exploration/tether_l3"],
"planetRequires": {
"tetherLevel": 2
},
"repeatable": false,
"effects": {
"tether": {
"level": 3,
"anchor": "target"
}
},
"theme": {
"color": "#00e5ff"
}
},
"mining-arm-improved": {
"label": "Improved Mining Arm",
"description": "A faster mining arm for the ship: it mines asteroids at 1.5 minerals per second (the base arm does 1.0). A ship upgrade — installed from any planet's console, it counts as built wherever the ship lands and is never re-bought.",
"icon": "mining",
"category": "mining",
"targets": ["ship"],
"cost": {
"minerals": 200
},
"duration": 20,
"requires": ["mining/improved_arm"],
"repeatable": false,
"effects": {
"mining": {
"rate": 1.5
}
},
"theme": {
"color": "#ff8a3d"
}
},
"mining-arm-advanced": {
"label": "Advanced Mining Arm",
"description": "The ship's fastest arm: it mines asteroids at 2.0 minerals per second — twice the base arm. Supersedes the Improved Mining Arm (the faster arm always wins). A ship upgrade: built from any planet's console, counted wherever the ship lands.",
"icon": "mining2",
"category": "mining",
"targets": ["ship"],
"cost": {
"minerals": 300
},
"duration": 20,
"requires": ["mining/advanced_arm"],
"repeatable": false,
"effects": {
"mining": {
"rate": 2.0
}
},
"theme": {
"color": "#ff8a3d"
}
},
"mining-storage-improved": {
"label": "Improved Mining Storage",
"description": "Expands the ship's mineral hold to 350 (100 more than the base 250). A ship upgrade: built from any planet's console, counted wherever the ship lands.",
"icon": "storage",
"category": "mining",
"targets": ["ship"],
"cost": {
"minerals": 200
},
"duration": 20,
"requires": ["mining/improved_storage"],
"repeatable": false,
"effects": {
"mining": {
"capacity": 350
}
},
"theme": {
"color": "#ff8a3d"
}
}
},
@ -91,9 +184,10 @@
"_comment": "Shape of one entry in `builds` (the _ keys are documentation, never builds).",
"label": "string — shown in the list and the detail readout",
"description": "string — detail readout text",
"icon": "tether | tether2 | anchor | signal | diamond — procedural glyph (js/research/ResearchIcons.js)",
"category": "planet | cargo — which tab of the console (registry above)",
"icon": "tether | tether2 | tether3 | anchor | signal | mining | mining2 | storage | diamond — procedural glyph (js/research/ResearchIcons.js)",
"category": "planet | cargo | mining — which tab of the console (registry above)",
"targets": ["planet"],
"_targets": "[\"planet\"] = installed on the planet's surface; [\"ship\"] = a SHIP upgrade (mining arms/storage): built from any planet's console, the effect is ship-wide and it counts as BUILT wherever the ship lands (never re-bought)",
"cost": {
"credits": 0,
"minerals": 200
@ -109,8 +203,13 @@
"tether": {
"level": 2,
"anchor": "target"
},
"mining": {
"rate": 1.5,
"capacity": 350
}
},
"_effects": "applied on completion, data-driven (GameScene._applyBuildEffects): tether.level = the target world's tether field (anchor target = the built-from planet, home = the home world); mining.rate = the ship's mining speed (minerals/s, MAX wins — never downgrades); mining.capacity = the ship's mineral hold cap (MAX wins)",
"theme": {
"color": "#00e5ff"
}

View File

@ -16,6 +16,7 @@
"naming.json",
"research.json",
"research/exploration.json",
"research/mining.json",
"builds.json",
"actionbar.json",
"landing.json",

View File

@ -6,6 +6,7 @@
"defaultCategory": "exploration",
"categories": [
{ "id": "exploration", "label": "Exploration", "accent": "#00e5ff" },
{ "id": "mining", "label": "Mining", "accent": "#ffc94d" },
{ "id": "system", "label": "System", "accent": "#5fd4ff", "dynamic": true }
],
"_dynamic": "A category flagged `dynamic: true` has NO data/research/<id>.json — its tree is built at runtime per situation (js/research/SystemCategory.js: one tech pair per solar system, named after the system the player is in — its Map tech is granted on entry, its jumpgate tech researchable once every NAV point — planets, space stations, jump gates — is discovered, and activation flips the gates on, here and in the linked systems' return gates). Static categories keep the one-file-per-category rule; the tests exempt dynamic ones from the tree-file check.",

View File

@ -1,5 +1,5 @@
{
"_comment": "EXPLORATION — the player's reach. Tether tech raises the home tether's level (effects.tether.level → TetherField.setLevel; radius = 5,120 × 2^(level1), see data/tether.json and docs/PROJECT_NOTES.md) — except Tether Level 2, which is the BLUEPRINT only (effects: {}): the player installs it as a TETHER - LEVEL 2 build on a world holding a level-1 tether (data/builds.json → tether-l2; the build carries the effect). Tether Anchoring and Signal Amplification grant capability flags (seam) that future mechanics read. Node contract: `requires` = parents that must be RESEARCHED first (the DAG edges — authoritative); `unlocks` = what completing the tech opens: `builds` (ids in data/builds.json the tech makes available — the build's own `requires` is the authoritative gate, this list is the declaration, and the test keeps the two in lock-step) and `research` (a readable mirror of the children's `requires` edges); `effects` = what the scene applies on completion. `starting` = already researched on a fresh run, so its children are available the first time the console opens. duration is in research.timeUnit (seconds); 0 = granted, never researched.",
"_comment": "EXPLORATION — the player's reach. Tether tech is the BLUEPRINT only (effects: {}) — the player installs it as a build on a world (data/builds.json, the build carries the effect): Tether Level 2 → a TETHER - LEVEL 2 build on a world holding a level-1 tether (→ tether-l2), Tether Level 3 → a TETHER - LEVEL 3 build on a world holding a level-2 tether (→ tether-l3). Radius = 5,120 × 2^(level1) (data/tether.json). Tether Anchoring and Signal Amplification grant capability flags (seam) that future mechanics read. Node contract: `requires` = parents that must be RESEARCHED first (the DAG edges — authoritative); `unlocks` = what completing the tech opens: `builds` (ids in data/builds.json the tech makes available — the build's own `requires` is the authoritative gate, this list is the declaration, and the test keeps the two in lock-step) and `research` (a readable mirror of the children's `requires` edges); `effects` = what the scene applies on completion. `starting` = already researched on a fresh run, so its children are available the first time the console opens. duration is in research.timeUnit (seconds); 0 = granted, never researched.",
"starting": ["tether_l1"],
"nodes": {
"tether_l1": {
@ -22,12 +22,12 @@
},
"tether_l3": {
"label": "Tether Level 3",
"description": "Third stage: 20,480 m of tether. The outer belt of an average system sits comfortably inside the ring — less time parked on the line, more time working it.",
"icon": "tether",
"description": "A third stage of field power. The blueprint for the strongest tether: install it with a TETHER - LEVEL 3 build on a world that holds a level-2 tether (300 minerals, 30 s — data/builds.json) and that world's boundary ring pulls out to 20,480 m — the outer belt of an average system sits comfortably inside it.",
"icon": "tether3",
"duration": 150,
"requires": ["tether_l2"],
"unlocks": { "builds": [], "research": [] },
"effects": { "tether": { "level": 3 } }
"unlocks": { "builds": ["tether-l3"], "research": [] },
"effects": {}
},
"tether_anchors": {
"label": "Tether Anchoring",

33
data/research/mining.json Normal file
View File

@ -0,0 +1,33 @@
{
"_comment": "MINING — the player's ability to extract. All three techs are BLUEPRINTS (effects: {}): the descriptions are written \"Once built, ...\" because the effect lands on the SHIP-UPGRADE BUILD in data/builds.json — the node's `unlocks.builds` declares it and the build's `requires: [\"mining/<node id>\"]` is the authoritative gate (dev/research-builds.test.mjs keeps both sides in lock-step). improved_arm is Tier 1 (the root); advanced_arm + improved_storage are Tier 2 (they require improved_arm). Node contract is the same as data/research/exploration.json: `requires` = parents that must be RESEARCHED first (the DAG edges — authoritative); `unlocks` = { builds, research } declaration; `effects` = what the scene applies on completion (empty here — the build carries it); `starting` = already researched on a fresh run (none here — everything is researched, never granted). duration is in research.timeUnit (seconds).",
"starting": [],
"nodes": {
"improved_arm": {
"label": "Improved Mining Arm",
"description": "Once built, an Improved Mining Arm can mine asteroids at 50% increased speed.",
"icon": "mining",
"duration": 60,
"requires": [],
"unlocks": { "builds": ["mining-arm-improved"], "research": ["advanced_arm", "improved_storage"] },
"effects": {}
},
"advanced_arm": {
"label": "Advanced Mining Arm",
"description": "Once built, an Advanced Mining Arm is twice as fast as a standard mining arm",
"icon": "mining2",
"duration": 60,
"requires": ["improved_arm"],
"unlocks": { "builds": ["mining-arm-advanced"], "research": [] },
"effects": {}
},
"improved_storage": {
"label": "Improved Mining Storage",
"description": "Once built, your ship will be able to store 100 more minerals (350 total)",
"icon": "storage",
"duration": 60,
"requires": ["improved_arm"],
"unlocks": { "builds": ["mining-storage-improved"], "research": [] },
"effects": {}
}
}
}

View File

@ -28,6 +28,7 @@ const read = (name) => JSON.parse(fs.readFileSync(join(dataDir, name), 'utf8'));
const manifest = read('manifest.json');
const research = read('research.json');
const exploration = read('research/exploration.json');
const mining = read('research/mining.json');
const builds = read('builds.json');
const actionbar = read('actionbar.json');
@ -41,7 +42,7 @@ const check = (label, cond) => {
// The pure modules (no Phaser — Node-safe)
// ----------------------------------------------------------------------
const { config } = await import('../js/config/Config.js');
config.init({ research, exploration, builds, actionbar });
config.init({ research, exploration, mining, builds, actionbar });
const {
categories,
loadBuilds,
@ -51,6 +52,7 @@ const {
rowState,
costLines,
canAfford,
isShipScoped,
} = await import('../js/build/BuildModel.js');
const { BuildState } = await import('../js/build/BuildState.js');
@ -58,7 +60,9 @@ const { BuildState } = await import('../js/build/BuildState.js');
// fresh run — nothing researched beyond the starting set, home holds a
// level-1 tether, nothing built anywhere.
const ctx = (over = {}) => ({
isResearchUnlocked: (c, n) => c === 'exploration' && (n === 'tether_l1' || (over.research ?? []).includes(n)),
isResearchUnlocked: (c, n) =>
(c === 'exploration' && (n === 'tether_l1' || (over.research ?? []).includes(n))) ||
(c === 'mining' && (over.miningResearch ?? []).includes(n)),
tetherLevel: () => over.tetherLevel ?? 1,
isBuilt: typeof over.isBuilt === 'function' ? over.isBuilt : () => false,
});
@ -67,13 +71,16 @@ const ctx = (over = {}) => ({
// 1. The category registry + per-category build lists
// ----------------------------------------------------------------------
check('model: categories() is the console tab registry', categories().every((c) => c.id && c.label));
check('model: the console has the Planet + Cargo tabs', ['planet', 'cargo'].every((id) => categories().some((c) => c.id === id)));
check('model: the console has the Planet + Cargo + Mining tabs', ['planet', 'cargo', 'mining'].every((id) => categories().some((c) => c.id === id)));
const planet = loadBuilds('planet');
const cargo = loadBuilds('cargo');
check('model: loadBuilds("planet") holds both tether builds', !!planet.builds['tether-l1'] && !!planet.builds['tether-l2']);
const miningCat = loadBuilds('mining');
check('model: loadBuilds("planet") holds the three tether builds', !!planet.builds['tether-l1'] && !!planet.builds['tether-l2'] && !!planet.builds['tether-l3']);
check('model: loadBuilds("planet") keeps registry entry (label/accent)', planet.label === 'Planet' && typeof planet.accent === 'string');
check('model: loadBuilds("cargo") is empty today (no cargo modules yet)', Object.keys(cargo.builds).length === 0);
check('model: loadBuilds("mining") holds the three ship-upgrade builds', !!miningCat.builds['mining-arm-improved'] && !!miningCat.builds['mining-arm-advanced'] && !!miningCat.builds['mining-storage-improved']);
check('model: loadBuilds("mining") keeps registry entry (label/accent)', miningCat.label === 'Mining' && typeof miningCat.accent === 'string');
check('model: an unknown category falls back to the default', loadBuilds('nope').id === builds.defaultCategory);
// ----------------------------------------------------------------------
@ -120,6 +127,57 @@ check('model: a stronger existing tether satisfies the gate', isAvailable(tl2, c
// already built on the planet → never offered again (one-off)
check('model: an installed build is not available again', isAvailable(tl2, ctx({ research: ['tether_l2'], isBuilt: () => true })) === false);
// the tether-l3 build — same two gates, one level up (L2 research + a
// level-2 tether on the planet)
const tl3 = builds.builds['tether-l3'];
check('model: tether-l3 is LOCKED before the research', isAvailable(tl3, ctx()) === false);
check('model: tether-l3 is READY once researched (planet holds L2)', isAvailable(tl3, ctx({ research: ['tether_l3'], tetherLevel: 2 })) === true);
check('model: tether-l3 stays LOCKED without a level-2 tether on the planet', isAvailable(tl3, ctx({ research: ['tether_l3'], tetherLevel: 1 })) === false);
check('model: a level-3 tether satisfies the level-2 gate', isAvailable(tl3, ctx({ research: ['tether_l3'], tetherLevel: 3 })) === true);
// ----------------------------------------------------------------------
// 3b. The Mining builds — ship upgrades (research gate only, no planet
// gate; the effect is the ship's arm rate / hold capacity)
// ----------------------------------------------------------------------
const armImproved = builds.builds['mining-arm-improved'];
const armAdvanced = builds.builds['mining-arm-advanced'];
const storageImproved = builds.builds['mining-storage-improved'];
check('model: the mining builds target the SHIP (targets: ["ship"])', isShipScoped(armImproved) && isShipScoped(armAdvanced) && isShipScoped(storageImproved));
check('model: isShipScoped is false for planet builds', isShipScoped(tl2) === false);
check('model: the improved arm effect is 1.5 minerals/s', armImproved.effects?.mining?.rate === 1.5);
check('model: the advanced arm effect is 2.0 minerals/s', armAdvanced.effects?.mining?.rate === 2.0);
check('model: the improved storage effect is a 350 hold', storageImproved.effects?.mining?.capacity === 350);
check('model: improved arm is LOCKED before the research', isAvailable(armImproved, ctx()) === false);
check('model: improved arm is READY once researched (NO planet gate — the effect is the ship)', isAvailable(armImproved, ctx({ miningResearch: ['improved_arm'] })) === true);
check('model: advanced arm is LOCKED before the research', isAvailable(armAdvanced, ctx()) === false);
check('model: advanced arm is READY once researched', isAvailable(armAdvanced, ctx({ miningResearch: ['advanced_arm'] })) === true);
check('model: improved storage is LOCKED before the research', isAvailable(storageImproved, ctx()) === false);
check('model: improved storage is READY once researched', isAvailable(storageImproved, ctx({ miningResearch: ['improved_storage'] })) === true);
check('model: a mining build installed on ANY planet reads built here (ctx.isBuilt = isBuiltAnywhere)', isAvailable(armAdvanced, ctx({ miningResearch: ['advanced_arm'], isBuilt: () => true })) === false);
check('model: each mining build is gated by its OWN research (improved arm research does not unlock the advanced arm)', isAvailable(armAdvanced, ctx({ miningResearch: ['improved_arm'] })) === false);
// SHIP-SCOPED build records: built on one planet ⇒ built on the ship,
// wherever it lands (BuildState.isBuiltAnywhere — the console + the
// beginBuild gate both read it for targets:["ship"] builds).
const shipS = new BuildState();
shipS.markBuilt('Kepler-186f', 'mining-arm-improved');
check('state: isBuiltAnywhere sees a ship-scoped build installed on ANY planet', shipS.isBuiltAnywhere('mining-arm-improved') === true);
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);
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');
// the costs (200 / 300 / 200 minerals) + the build time (20 s each)
check('model: improved arm costs 200 minerals, 20 s', costLines(armImproved)[0] !== undefined && costLines(armImproved).every((l) => l.res === 'minerals' && l.amount === 200) && armImproved.duration === 20);
check('model: advanced arm costs 300 minerals, 20 s', costLines(armAdvanced).every((l) => l.res === 'minerals' && l.amount === 300) && armAdvanced.duration === 20);
check('model: improved storage costs 200 minerals, 20 s', costLines(storageImproved).every((l) => l.res === 'minerals' && l.amount === 200) && storageImproved.duration === 20);
check('model: mining builds are unaffordable below their cost', canAfford(armImproved, 199) === false && canAfford(armAdvanced, 299) === false && canAfford(storageImproved, 199) === false);
check('model: mining builds are affordable at their cost', canAfford(armImproved, 200) === true && canAfford(armAdvanced, 300) === true && canAfford(storageImproved, 200) === true);
// ----------------------------------------------------------------------
// 4. Row states — the list's paint source
// ----------------------------------------------------------------------
@ -136,6 +194,9 @@ const lines = costLines(tl2);
check('model: costLines(tether-l2) = 200 minerals', lines.length === 1 && lines[0].res === 'minerals' && lines[0].amount === 200);
check('model: canAfford is false below the cost', canAfford(tl2, 199) === false);
check('model: canAfford is true at/above the cost', canAfford(tl2, 200) === true && canAfford(tl2, 5000) === true);
const lines3 = costLines(builds.builds['tether-l3']);
check('model: costLines(tether-l3) = 300 minerals', lines3.length === 1 && lines3[0].res === 'minerals' && lines3[0].amount === 300);
check('model: tether-l3 is unaffordable below 300 minerals', canAfford(builds.builds['tether-l3'], 299) === false && canAfford(builds.builds['tether-l3'], 300) === true);
check('model: a free build has no cost lines', costLines(builds.builds['tether-l1']).length === 0);
check('model: a free build is always affordable', canAfford(builds.builds['tether-l1'], 0) === true);

View File

@ -27,6 +27,7 @@ const read = (name) => JSON.parse(fs.readFileSync(join(dataDir, name), 'utf8'));
const manifest = read('manifest.json');
const research = read('research.json');
const exploration = read('research/exploration.json');
const mining = read('research/mining.json');
const builds = read('builds.json');
const actionbar = read('actionbar.json');
@ -40,7 +41,7 @@ const hex = /^#[0-9a-fA-F]{6}$/;
// ----------------------------------------------------------------------
// 1. Manifest registration (section name = file basename)
// ----------------------------------------------------------------------
for (const f of ['research.json', 'research/exploration.json', 'builds.json', 'actionbar.json']) {
for (const f of ['research.json', 'research/exploration.json', 'research/mining.json', 'builds.json', 'actionbar.json']) {
check(`manifest registers ${f}`, manifest.files.includes(f));
}
@ -78,7 +79,7 @@ check('exploration: nodes is a non-empty map', typeof nodes === 'object' && Obje
check('exploration: starting ⊆ nodes', Array.isArray(exploration.starting) && exploration.starting.every((id) => nodes[id]));
check('exploration: starts with Tether Level 1 (the first thing to research)', (exploration.starting ?? []).includes('tether_l1'));
const ICON_NAMES = ['tether', 'tether2', 'anchor', 'signal', 'diamond', 'map', 'gate']; // mirrors js/research/ResearchIcons.js
const ICON_NAMES = ['tether', 'tether2', 'tether3', 'mining', 'mining2', 'storage', 'anchor', 'signal', 'diamond', 'map', 'gate']; // mirrors js/research/ResearchIcons.js
let nodeFieldsOk = true;
let requiresOk = true;
let unlocksOk = true;
@ -122,23 +123,41 @@ for (const id of Object.keys(nodes)) {
check('exploration: the tree is a DAG (no cycles)', !cyclic);
check('exploration: has at least one root (a node with no requires)', Object.values(nodes).some((n) => !(n.requires ?? []).length));
// tether level chain (3 levels total): L2 is a BUILD (research unlocks
// it, the build applies it — data/builds.json → tether-l2), so it carries
// no immediate effect; L3 still applies its tether raise on research
// completion.
const l2NoEffect = !nodes.tether_l2 || !nodes.tether_l2.effects?.tether;
const levelsOk = [3].every((lvl) => {
const n = nodes[`tether_l${lvl}`];
return n && n.effects?.tether?.level === lvl;
});
check('exploration: tether_l3 raises the tether to level 3', levelsOk);
check('exploration: tether_l2 carries no immediate tether effect (the build applies it)', l2NoEffect);
// tether level chain (3 levels total): L2 and L3 are BLUEPRINTS (research
// unlocks their build, the build applies the raise — data/builds.json →
// tether-l2 / tether-l3), so neither carries an immediate tether effect.
const blueprints = [2, 3].every((lvl) => !nodes[`tether_l${lvl}`]?.effects?.tether);
check('exploration: tether_l2 + tether_l3 carry no immediate tether effect (their builds apply it)', blueprints);
// ----------------------------------------------------------------------
// 3b. The mining tree — RAW JSON contract (data/research/mining.json)
// ----------------------------------------------------------------------
const mnodes = mining.nodes ?? {};
check('mining: nodes is a non-empty map (3 techs)', Object.keys(mnodes).length === 3);
check('mining: nothing is starting (all three must be researched)', (mining.starting ?? []).length === 0);
let mfieldsOk = true;
for (const [id, n] of Object.entries(mnodes)) {
if (!n || typeof n !== 'object') { mfieldsOk = false; continue; }
if (typeof n.label !== 'string' || typeof n.description !== 'string' || !ICON_NAMES.includes(n.icon ?? 'diamond') || typeof n.duration !== 'number' || n.duration < 0) mfieldsOk = false;
if (!Array.isArray(n.requires) || !n.requires.every((r) => typeof r === 'string' && mnodes[r])) mfieldsOk = false;
if (!Array.isArray(n.unlocks?.builds) || !Array.isArray(n.unlocks?.research)) mfieldsOk = false;
}
check('mining: every node has label/description/icon/duration/requires/unlocks', mfieldsOk);
check('mining: labels match the brief', mnodes.improved_arm?.label === 'Improved Mining Arm' && mnodes.advanced_arm?.label === 'Advanced Mining Arm' && mnodes.improved_storage?.label === 'Improved Mining Storage');
check('mining: all three take 60 s (research.timeUnit)', Object.values(mnodes).every((n) => n.duration === 60));
check('mining: improved_arm is the Tier-1 root', !!mnodes.improved_arm && (mnodes.improved_arm.requires ?? []).length === 0);
check('mining: both Tier-2 projects require the Tier-1 root', (mnodes.advanced_arm?.requires ?? []).includes('improved_arm') && (mnodes.improved_storage?.requires ?? []).includes('improved_arm'));
check('mining: Tier 1 declares its two children in unlocks.research', JSON.stringify(mnodes.improved_arm?.unlocks?.research ?? []) === JSON.stringify(['advanced_arm', 'improved_storage']));
check('mining: improved_arm declares its build (mining-arm-improved)', JSON.stringify(mnodes.improved_arm?.unlocks?.builds ?? []) === JSON.stringify(['mining-arm-improved']));
check('mining: advanced_arm declares its build (mining-arm-advanced)', JSON.stringify(mnodes.advanced_arm?.unlocks?.builds ?? []) === JSON.stringify(['mining-arm-advanced']));
check('mining: improved_storage declares its build (mining-storage-improved)', JSON.stringify(mnodes.improved_storage?.unlocks?.builds ?? []) === JSON.stringify(['mining-storage-improved']));
check('mining: all three are blueprints (no effects — the effect lives on the build)', Object.values(mnodes).every((n) => Object.keys(n.effects ?? {}).length === 0));
// ----------------------------------------------------------------------
// 4. The REAL code path — ResearchModel + ResearchState (pure modules)
// ----------------------------------------------------------------------
const { config } = await import('../js/config/Config.js');
config.init({ research, exploration, builds, actionbar });
config.init({ research, exploration, mining, builds, actionbar });
const { categories, loadCategory, issues, layoutTree, isAvailable, missingRequires } = await import('../js/research/ResearchModel.js');
const { ResearchState } = await import('../js/research/ResearchState.js');
@ -152,6 +171,7 @@ check('model: issues(tree) is clean', Array.isArray(issues(tree)) && issues(tree
// two declarations together.
const { unlocksOf, buildDefs, unlockIssues, buildIssues } = await import('../js/research/ResearchModel.js');
check('model: unlocksOf normalizes {builds, research}', JSON.stringify(unlocksOf(tree, 'tether_l2')) === JSON.stringify({ builds: ['tether-l2'], research: ['tether_l3', 'tether_anchors'] }));
check('model: unlocksOf(tether_l3) declares the level-3 build', JSON.stringify(unlocksOf(tree, 'tether_l3')) === JSON.stringify({ builds: ['tether-l3'], research: [] }));
check('model: unlocksOf is empty for a node without unlocks', JSON.stringify(unlocksOf(tree, 'signal_amp')) === JSON.stringify({ builds: [], research: [] }));
check('model: unlockIssues(tree) is clean (mirrors + build wiring agree)', unlockIssues(tree).length === 0);
check('model: buildIssues() is clean (every build require resolves)', buildIssues().length === 0);
@ -193,6 +213,27 @@ check('state: restore keeps the project in flight', restored.getActive() !== nul
check('state: restored project finishes 51s after the load', restored.progress(500_000).fraction < 0.2 && restored.progress(551_000).fraction >= 1);
check('state: an old save without research restores as a fresh start', (() => { const s = new ResearchState(); s.restoreActive(null, 0); return s.getActive() === null; })());
// ----------------------------------------------------------------------
// 4b. The mining tree — the REAL code path (ResearchModel + ResearchState)
// ----------------------------------------------------------------------
const mtree = loadCategory('mining');
check('model: categories() registers Mining', categories().some((c) => c.id === 'mining'));
check('model: loadCategory returns the mining tree', !!mtree && !!mtree.nodes.improved_arm && mtree.order.length === 3);
check('model: mining: issues(tree) is clean', Array.isArray(issues(mtree)) && issues(mtree).length === 0);
check('model: mining: unlockIssues is clean (mirror + build wiring agree)', unlockIssues(mtree).length === 0);
const mlayout = layoutTree(mtree);
check('model: mining layout — 2 rows (Tier 1 → two Tier-2 children)', mlayout.rows === 2 && mlayout.level.improved_arm === 0 && mlayout.level.advanced_arm === 1 && mlayout.level.improved_storage === 1);
const mstate = new ResearchState();
check('mining: Tier 1 is the first thing to research (available on a fresh run)', isAvailable(mtree, mstate, 'improved_arm'));
check('mining: both Tier-2 projects wait on Tier 1 (locked)', !isAvailable(mtree, mstate, 'advanced_arm') && !isAvailable(mtree, mstate, 'improved_storage'));
check('mining: missingRequires names the parent (Tier 2 ← Tier 1)', JSON.stringify(missingRequires(mtree, mstate, 'advanced_arm')) === JSON.stringify(['improved_arm']));
check('mining: start() claims the slot', mstate.start('mining', 'improved_arm', 60_000, 0) === true);
check('mining: a second start() is refused while one runs', mstate.start('mining', 'advanced_arm', 60_000, 0) === false);
check('mining: tick() reports completion at the deadline', mstate.tick(59_999).length === 0 && mstate.tick(60_000).length === 1);
check('mining: Tier 1 is researched after completion', mstate.isUnlocked('mining', 'improved_arm'));
check('mining: both Tier-2 projects open up after Tier 1', isAvailable(mtree, mstate, 'advanced_arm') && isAvailable(mtree, mstate, 'improved_storage'));
// ----------------------------------------------------------------------
// 5. Builds — the build console (data/builds.json → js/ui/BuildWindow.js)
// ----------------------------------------------------------------------
@ -218,6 +259,17 @@ check('builds: tether-l2 costs 200 minerals, 20 s, one-off', tl2.repeatable ===
check('builds: tether_l2 declares the build in its unlocks (declaration side)', (nodes.tether_l2?.unlocks?.builds ?? []).includes('tether-l2'));
check('builds: tether-l2 effect raises a level-2 tether on the target', tl2.effects?.tether?.level === 2 && tl2.effects?.tether?.anchor === 'target');
// the tether-l3 build — same pattern, one level up: the research is the
// blueprint, the build installs the level-3 ring (300 minerals).
const tl3 = builds.builds['tether-l3'] ?? {};
check('builds: tether-l3 entry exists (the level-3 tether build)', !!tl3);
check('builds: tether-l3 is a planet build', JSON.stringify(tl3.targets ?? []) === JSON.stringify(['planet']));
check('builds: tether-l3 is gated on exploration/tether_l3 (authoritative side)', JSON.stringify(tl3.requires ?? []) === JSON.stringify(['exploration/tether_l3']));
check('builds: tether-l3 needs a level-2 tether on the planet', tl3.planetRequires?.tetherLevel === 2);
check('builds: tether-l3 costs 300 minerals, 30 s, one-off', tl3.repeatable === false && tl3.cost?.minerals === 300 && tl3.duration === 30);
check('builds: tether_l3 declares the build in its unlocks (declaration side)', (nodes.tether_l3?.unlocks?.builds ?? []).includes('tether-l3'));
check('builds: tether-l3 effect raises a level-3 tether on the target', tl3.effects?.tether?.level === 3 && tl3.effects?.tether?.anchor === 'target');
// the tether-l1 build — the home world's starting install
const tl1 = builds.builds['tether-l1'] ?? {};
check('builds: tether-l1 entry exists (the starting install)', !!tl1);

View File

@ -500,6 +500,16 @@ than follow-on tech. Each node's `unlocks` is the declaration side:
ANCHORING · BUILD · TETHER - LEVEL 2`), read through
`ResearchModel.unlocksOf(tree, id)` + `buildDefs()` — the one read
point the build UI uses too.
- The second static category is **Mining** (`data/research/mining.json`):
`improved_arm` (Tier 1, root) → `advanced_arm` + `improved_storage`
(Tier 2, both require the Tier-1). All three are BLUEPRINTS — 60 s
each, `effects: {}` — and each declares its matching SHIP-UPGRADE build
in `data/builds.json` (`mining-arm-improved` / `mining-arm-advanced` /
`mining-storage-improved`, the console's Mining tab) — the build's
`requires: ["mining/<node id>"]` is the authoritative gate, the node's
`unlocks.builds` the declaration (the test keeps both in lock-step). New
glyph set for the tab: `mining`, `mining2`, `storage`
(js/research/ResearchIcons.js) — the build rows reuse the same glyphs.
**Code layering (same rules as the tether):**
- `js/research/ResearchModel.js` — PURE (no Phaser): `roots`, `issues`
@ -611,15 +621,17 @@ button appears only when the build is available AND affordable.
**Division of labor (the Research split, exactly):**
- `js/build/BuildModel.js` — PURE (no Phaser): `categories`, `loadBuilds`
(category → its builds), `startingPairs` (pre-installed on fresh runs —
`tether-l1.starting: ["home"]`), `missingRequirements` (research +
`planetRequires.tetherLevel`), `isAvailable`, `rowState` (built /
active / available / locked), `costLines`, `canAfford`. Node-tested by
(category → its builds), `defById`, `startingPairs` (pre-installed on
fresh runs — `tether-l1.starting: ["home"]`), `missingRequirements`
(research + `planetRequires.tetherLevel`), `isAvailable`, `rowState`
(built / active / available / locked), `costLines`, `canAfford`,
`isShipScoped` (`targets: ["ship"]` — the ship upgrades). Node-tested by
`dev/builds.test.mjs`.
- `js/build/BuildState.js` — PURE: the run's build records (`built`:
planet → set of buildIds) + the single in-progress build (`active`,
`maxConcurrent: 1`); `start/progress/tick/restoreActive`,
`toJSON(now)/fromJSON`. Node-tested.
`toJSON(now)/fromJSON`, and `isBuiltAnywhere(build)` (ship-scoped
builds count as built if installed on ANY planet). Node-tested.
- `js/ui/BuildWindow.js` — the scene-facing window (depth 80, above the
save panel). A **passive view**: it asks via `onBuild(buildId)`;
`SurfaceScene.beginSurfaceBuild``GameScene.beginBuild` enforces the
@ -627,13 +639,19 @@ button appears only when the build is available AND affordable.
mineral cost — the full amount paid up front), then `BuildState.start`
runs the clock. The window re-renders from `BuildState` + `BuildModel`
only.
- `GameScene``beginBuild`, `completeBuild`, `_applyBuildEffects` (the
**effects seam**: `tether {level: N}` → the world's tether strengthens
to at least N via `TetherField.setLevel` — never a downgrade — + toast;
`capability "flag"``scene.researchCapabilities`; unknown shapes log
and no-op). The state lives on the GameScene (`buildState`), so the
records + the in-progress build outlive the surface stay AND save with
the run (`record.builds`).
- `GameScene``beginBuild` (the ship-scoped gate:
`isBuiltAnywhere` for `targets: ["ship"]` builds), `completeBuild`,
`_applyBuildEffects` (the **effects seam**: `tether {level: N}` → the
world's tether strengthens to at least N via `TetherField.setLevel`
never a downgrade — + toast; `capability "flag"`
`scene.researchCapabilities`; `mining {rate, capacity}`
`_applyMiningUpgrade` — the ship's stats, MAX wins; unknown shapes log
and no-op). `_restoreMiningUpgrades()` re-derives the ship's mining
stats from the build records in `create()` (the save carries the
records, not the stats — same seam as `_rematerializeBuiltTethers`;
idempotent, a fresh run has none). The state lives on the GameScene
(`buildState`), so the records + the in-progress build outlive the
surface stay AND save with the run (`record.builds`).
**The clock (important — different from research):** the build's time
base is the **game-loop clock** (`game.loop.now` — the engine's global
@ -669,6 +687,22 @@ must not un-install the home world's level-1 tether. A player resuming a
run always has Tether - Level 1 built on home (pinned in
`dev/builds.test.mjs`).
**Ship-scoped builds (the Mining tab) — one-off installs, ship-wide
effect:** `targets: ["ship"]` (the mining arms + storage, data/builds.json
→ the Mining category) are installed from any planet's console but the
effect is the SHIP's: `effects.mining.rate``ship.stats.miningSpeed`
(minerals/s — `Mining._extract` multiplies the base economy rate by it,
read live per tick) and `effects.mining.capacity`
`ship.stats.mineralStorage` (the hold cap — `Ship.storageRoom` /
`addMinerals` read it live, so the corner HUD's `n / CAP` follows). Both
are MAX-guarded (the faster arm / bigger hold always wins — building the
advanced arm then the improved keeps 2.0/s). "Built" is SHIP-SCOPED:
`BuildState.isBuiltAnywhere` (installed on any planet ⇒ built wherever
the ship lands — `BuildWindow.ctxFor` + `_detailStatus` + the
`GameScene.beginBuild` gate all read it for these builds, so no re-buy on
the next world). The records still key the planet they were built from
(the save format is unchanged).
**Name casing (a real bug this caught):** world state is keyed by the
world's CANONICAL name (its discovery name — build records, tether
labels). The comms panel DISPLAYS names uppercased — and used to carry
@ -690,8 +724,9 @@ normalizes the name against the known worlds before launch
`js/main.js`) is a read-only observer — it never mutates state.
- Opening the Build console logs a one-line `[orbit-diag v…]` summary:
planet, home, `isHome`, the `built` map, the planet's tether level, the
L1/L2 row states (with missing gates), a stale-JS probe
(`jsSeed=true|false`), and the loaded `tether-l1.starting` field.
L1/L2/L3 row states (with missing gates), the ship's mining stats
(`mining={rate, hold, cap}`), a stale-JS probe (`jsSeed=true|false`),
and the loaded `tether-l1.starting` field.
- `orbitDiag()` in DevTools → Console prints the full report: env, JS
probes, the `data/builds.json` contract as loaded, home/current
planet, built records, tether objects, research state, the save bank's
@ -953,18 +988,21 @@ The player holds a REPUTATION (standing) on each planet and space station:
Pure rules/state in `js/research/` (Node-tested), the window is a
passive view, `effects` carry the payload (e.g. `tether.level` grows
the home tether). First category **Exploration** is live (Tether
Level 13, Tether Anchoring, Signal Amplification); add a category =
Level 13, Tether Anchoring, Signal Amplification); second category
**Mining** is live (Improved Mining Arm → Advanced Mining Arm +
Improved Mining Storage — all 60 s blueprints, each unlocking its
ship-upgrade build in the console's Mining tab); add a category =
one JSON file + one line in the registry + one line in the manifest
(`js/ui/ResearchWindow.js`, `js/research/*`, `data/research.json`,
`data/research/exploration.json`, dev/research-builds.test.mjs)
- [x] Tether progression: level 2 is a **build** — research
`tether_l2` (blueprint, no effect) → BUILD slot on a world holding a
level-1 tether → 200 minerals / 20 s → the world's tether strengthens
to level 2 (10240 px), effect and all, outliving the stay. Level 3
is still research-direct (`effects.tether.level` on the home
tether); anchoring extra rings on planets/stations remains
- [x] Tether progression: levels 2 and 3 are **builds** — research
`tether_l2` / `tether_l3` (blueprint, no effect) → BUILD slot on a
world holding a level-1 / level-2 tether → 200 minerals / 20 s (L2)
or 300 minerals / 30 s (L3) → the world's tether strengthens to
level 2 (10240 px) or level 3 (20480 px), effect and all, outliving
the stay. Anchoring extra rings on planets/stations remains
(the `add`/`setLevel`/`onChange` seams are in place)
(`data/builds.json → tether-l2`, `js/build/*`,
(`data/builds.json → tether-l2 / tether-l3`, `js/build/*`,
`js/ui/BuildWindow.js`, `dev/builds.test.mjs`)
- [ ] Factions & pirates: claim settlements (`owner`), flags, borders,
and the player's place in a populated galaxy (the reputation layer
@ -1007,6 +1045,9 @@ The player holds a REPUTATION (standing) on each planet and space station:
their remaining time
(`js/ui/BuildWindow.js`, `js/build/BuildModel.js`,
`js/build/BuildState.js`, `data/builds.json`, dev/builds.test.mjs)
— incl. the Mining tab: three ship-scoped upgrades (the improved /
advanced mining arm → 1.5 / 2.0 minerals per second, the improved
storage → 350 hold), 200 / 300 / 200 minerals, 20 s each
- [x] Ship base stats: `data/ship.json → stats` (hullIntegrity 100,
shields 0, cargoHold 100, mineralStorage 250), exposed as
`ship.stats` — combat/trading/mining systems will read them as

View File

@ -68,6 +68,17 @@ export function defById(id) {
return Object.prototype.hasOwnProperty.call(defs, id) ? defs[id] : null;
}
/**
* SHIP-SCOPED build (data/builds.json targets: ["ship"]): the effect
* is on the ship (the mining arms + storage), not the planet installed
* from any planet's console, counted as BUILT wherever the ship lands
* (BuildState.isBuiltAnywhere), never re-bought. Planet-scoped builds
* (targets: ["planet"]) are the ordinary one-off surface installs.
*/
export function isShipScoped(def) {
return Array.isArray(def?.targets) && def.targets.includes('ship');
}
/**
* [planet, buildId] pairs pre-installed on a fresh run the home world
* starts with its level-1 tether (data/builds.json tether-l1.starting).

View File

@ -45,6 +45,19 @@ export class BuildState {
return !!(set && build && set.has(build));
}
/**
* SHIP-SCOPED builds (data/builds.json targets: ["ship"], e.g. the
* mining arms/storage): the effect is on the ship, not the planet so
* "already built" is true if it was installed on ANY planet. The
* console shows such a build BUILT wherever the ship lands (no
* re-buy), and the scene's beginBuild gate mirrors this check.
*/
isBuiltAnywhere(build) {
if (!build) return false;
for (const set of this.built.values()) if (set.has(build)) return true;
return false;
}
buildsOn(planet) {
return [...(this.built.get(planet) ?? [])];
}

View File

@ -21,9 +21,9 @@
* check again. The version marker below changes with each revision.
*/
import { config } from '../config/Config.js';
import { defById, rowState, missingRequirements } from '../build/BuildModel.js';
import { defById, rowState, missingRequirements, isShipScoped } from '../build/BuildModel.js';
export const DIAG_V = 3;
export const DIAG_V = 4;
const _errors = [];
const _errCap = (msg) => {
@ -58,16 +58,22 @@ function tetherFor(gs, name) {
/**
* The exact row state the Build window paints for one build id, using
* the same pure functions and context as js/ui/BuildWindow.js.
* the same pure functions and context as js/ui/BuildWindow.js (SHIP-
* scoped builds the mining arms/storage read isBuiltAnywhere, the
* window's ctx does too).
*/
function rowFor(gs, ss, id) {
const planet = ss?.planetName ?? homeNameOf(gs);
const def = defById(id);
if (!def || !planet) return 'NO DEF/PLANET';
const shipScoped = isShipScoped(def);
const ctx = {
isResearchUnlocked: (c, n) => gs?.researchState?.isUnlocked?.(c, n) ?? false,
tetherLevel: () => gs?.tetherLevelFor?.(planet) ?? 0,
isBuilt: () => gs?.buildState?.isBuilt?.(planet, id) ?? false,
isBuilt: () =>
shipScoped
? gs?.buildState?.isBuiltAnywhere?.(id) ?? false
: gs?.buildState?.isBuilt?.(planet, id) ?? false,
};
const active = gs?.buildState?.getActive?.();
const activeOnPlanet = active && active.planet === planet ? active : null;
@ -119,9 +125,23 @@ function dataFacts() {
planetRequires: l2.planetRequires ?? null,
}
: 'NOT IN DATA (stale data/builds.json?)';
for (const id of ['mining-arm-improved', 'mining-arm-advanced', 'mining-storage-improved']) {
const d = defById(id);
out[id] = d
? { cost: d.cost ?? null, duration: d.duration ?? null, requires: d.requires ?? null, targets: d.targets ?? null, effects: d.effects ?? null }
: 'NOT IN DATA (stale data/builds.json?)';
}
return out;
}
/** The ship's derived mining stats (the build effects land here). */
function miningStatsOf(gs) {
const st = gs?.ship?.stats;
return st
? { rate: st.miningSpeed ?? null, hold: gs?.ship?.minerals ?? null, cap: st.mineralStorage ?? null }
: null;
}
/** One-line summary — the same facts a support request needs. */
export function brief() {
const gs = sceneOf('GameScene');
@ -137,6 +157,8 @@ export function brief() {
`builtMap=${JSON.stringify(bm ?? null)} ` +
`tether=${t ? `${t.id}:L${t.level}` : 'NONE'} ` +
`L1=${rowFor(gs, ss, 'tether-l1')} L2=${rowFor(gs, ss, 'tether-l2')} ` +
`L3=${rowFor(gs, ss, 'tether-l3')} ` +
`mining=${JSON.stringify(miningStatsOf(gs) ?? null)} ` +
`jsSeed=${typeof gs?._seedStartingBuilds === 'function'} ` +
`dataL1starting=${JSON.stringify(defById('tether-l1')?.starting ?? null)} ` +
`errors=${_errors.length}`;
@ -167,10 +189,21 @@ export function full() {
`[state] builtMap=${JSON.stringify(builtMapOf(gs) ?? null)}`,
` homeTether=${JSON.stringify(tetherFor(gs, home) ?? null)} ` +
`planetTether=${JSON.stringify(tetherFor(gs, planet) ?? null)}`,
` miningStats=${JSON.stringify(miningStatsOf(gs) ?? null)}`,
` researchTetherL2=${gs?.researchState?.isUnlocked?.('exploration', 'tether_l2') ?? 'n/a'} ` +
`researchTetherL3=${gs?.researchState?.isUnlocked?.('exploration', 'tether_l3') ?? 'n/a'} ` +
`researchMining=${JSON.stringify({
improved_arm: gs?.researchState?.isUnlocked?.('mining', 'improved_arm') ?? 'n/a',
advanced_arm: gs?.researchState?.isUnlocked?.('mining', 'advanced_arm') ?? 'n/a',
improved_storage: gs?.researchState?.isUnlocked?.('mining', 'improved_storage') ?? 'n/a',
})} ` +
`activeBuild=${JSON.stringify(gs?.buildState?.getActive?.() ?? null)}`,
`[rows] L1=${rowFor(gs, ss, 'tether-l1')}`,
` L2=${rowFor(gs, ss, 'tether-l2')}`,
` L3=${rowFor(gs, ss, 'tether-l3')}`,
` ARM-I=${rowFor(gs, ss, 'mining-arm-improved')}`,
` ARM-A=${rowFor(gs, ss, 'mining-arm-advanced')}`,
` STORE=${rowFor(gs, ss, 'mining-storage-improved')}`,
`[saves] ${JSON.stringify(saveBank())}`,
`[scenes] ${JSON.stringify(scenes)}`,
`[errors since boot] ${_errors.length ? _errors.join(' | ') : 'none'}`,

View File

@ -6,7 +6,7 @@
*/
import { toColor } from '../utils/Color.js';
export const ICON_NAMES = ['tether', 'tether2', 'anchor', 'signal', 'diamond', 'map', 'gate'];
export const ICON_NAMES = ['tether', 'tether2', 'tether3', 'mining', 'mining2', 'storage', 'anchor', 'signal', 'diamond', 'map', 'gate'];
export function iconKey(name, color = 0x00e5ff) {
const n = ICON_NAMES.includes(name) ? name : 'diamond';
@ -96,6 +96,111 @@ export function ensureIcon(scene, name, color = 0x00e5ff) {
}
diamond(cx + 52 * Math.cos(-Math.PI / 3), cy + 52 * Math.sin(-Math.PI / 3), 8);
diamond(cx + 52 * Math.cos(Math.PI / 3), cy + 52 * Math.sin(Math.PI / 3), 8);
} else if (n === 'tether3') {
// Level-3 tether: two bright concentric level rings + core + tick
// marks + THREE satellites riding the outer ring (L1 one, L2 two).
const rings = [
[52, 1.0],
[34, 0.85],
];
for (const [r, a] of rings) {
ctx.globalAlpha = a;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(cx, cy, 8, 0, Math.PI * 2);
ctx.fill();
for (const a of [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2]) {
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 55, cy + Math.sin(a) * 55);
ctx.lineTo(cx + Math.cos(a) * 61, cy + Math.sin(a) * 61);
ctx.stroke();
}
for (const a of [-Math.PI / 2, Math.PI / 6, (5 * Math.PI) / 6]) {
diamond(cx + 52 * Math.cos(a), cy + 52 * Math.sin(a), 8);
}
} else if (n === 'mining') {
// Improved Mining Arm: a drill bit (shaft + diamond tip) striking an
// asteroid, chips flying.
ctx.globalAlpha = 0.9;
ctx.beginPath();
ctx.arc(74, 74, 26, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(82, 66, 5, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.arc(68, 84, 4, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(24, 24);
ctx.lineTo(52, 52);
ctx.stroke();
diamond(58, 58, 9);
diamond(88, 42, 5);
diamond(44, 90, 4);
} else if (n === 'mining2') {
// Advanced Mining Arm: the same drill, plus speed lines behind the
// shaft (twice as fast) and one more chip flying.
ctx.globalAlpha = 0.55;
ctx.beginPath();
ctx.moveTo(32, 14);
ctx.lineTo(50, 32);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(14, 32);
ctx.lineTo(32, 50);
ctx.stroke();
ctx.globalAlpha = 0.9;
ctx.beginPath();
ctx.arc(74, 74, 26, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(82, 66, 5, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.arc(68, 84, 4, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(24, 24);
ctx.lineTo(50, 50);
ctx.stroke();
diamond(57, 57, 10);
diamond(88, 42, 5);
diamond(44, 90, 4);
diamond(96, 62, 4);
} else if (n === 'storage') {
// Improved Mining Storage: a hex container with a stack of mineral
// diamonds inside + a growth arrow breaking out the top (more room).
ctx.globalAlpha = 0.95;
ctx.beginPath();
for (let i = 0; i < 6; i++) {
const a = -Math.PI / 2 + (i * Math.PI) / 3;
const x = 64 + 38 * Math.cos(a);
const y = 70 + 38 * Math.sin(a);
if (i) ctx.lineTo(x, y);
else ctx.moveTo(x, y);
}
ctx.closePath();
ctx.stroke();
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.moveTo(64, 44);
ctx.lineTo(64, 20);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(56, 28);
ctx.lineTo(64, 20);
ctx.lineTo(72, 28);
ctx.stroke();
diamond(64, 74, 11);
diamond(48, 88, 6);
diamond(80, 88, 6);
} else if (n === 'anchor') {
// Anchor ring + stock + crossbar + flukes (the relay bolted to a body).
ctx.globalAlpha = 0.9;

View File

@ -50,7 +50,7 @@ import {
} from '../research/SystemCategory.js';
import { BuildWindow } from '../ui/BuildWindow.js';
import { BuildState } from '../build/BuildState.js';
import { startingPairs, defById } from '../build/BuildModel.js';
import { startingPairs, defById, isShipScoped } from '../build/BuildModel.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
const HEADER_FONT = () => fontStack('header', FONT_FALLBACK);
@ -693,6 +693,11 @@ export class GameScene extends Phaser.Scene {
// a jump cut / scene restart rebuilds the field to the native set
// only (idempotent: existing tethers are only ever strengthened).
this._rematerializeBuiltTethers();
// The ship's MINING upgrades are derived the same way (build records
// are the source of truth — the save carries them, not the stats):
// a load / jump-cut re-asserts the arm rate + hold cap the player
// installed (idempotent; a fresh run has none).
this._restoreMiningUpgrades();
// Entering a system charts it: the SYSTEM category's '{System} Map'
// tech (duration 0) is owned on arrival — a new run gets the starting
@ -2559,7 +2564,14 @@ export class GameScene extends Phaser.Scene {
const def = buildDefs()[buildId];
if (!state || !def) return { ok: false, reason: 'BUILD SYSTEM OFFLINE' };
if (state.getActive()) return { ok: false, reason: 'A BUILD IS ALREADY IN PROGRESS' };
if (state.isBuilt(planetName, buildId)) {
// SHIP-scoped builds (targets: ["ship"] — the mining arms/storage) are
// installed ON THE SHIP: already built on ANY planet counts (the
// console shows them BUILT everywhere — no re-buy).
if (isShipScoped(def)) {
if (state.isBuiltAnywhere(buildId)) {
return { ok: false, reason: 'ALREADY INSTALLED ON THE SHIP' };
}
} else if (state.isBuilt(planetName, buildId)) {
return { ok: false, reason: 'ALREADY INSTALLED ON THIS WORLD' };
}
// research gate — the blueprint must be researched (the authoritative
@ -2651,12 +2663,78 @@ export class GameScene extends Phaser.Scene {
} else if (type === 'capability' && typeof spec === 'string') {
this.researchCapabilities = this.researchCapabilities ?? new Set();
this.researchCapabilities.add(spec);
} else if (type === 'mining' && spec && typeof spec === 'object') {
// SHIP upgrades (the mining arms/storage): effects.mining.rate =
// the ship's mining speed (minerals/s), effects.mining.capacity =
// the mineral hold's cap. MAX wins — never a downgrade.
this._applyMiningUpgrade(spec);
} else {
console.warn(`[orbit] build: unknown effect ${type}`, spec);
}
}
}
/**
* Apply a mining SHIP upgrade (a build's effects.mining, data/builds.json):
* `rate` stats.miningSpeed (minerals/s Mining multiplies the base
* economy rate by it), `capacity` stats.mineralStorage (the hold's
* cap ship.storageRoom / the corner HUD read n / cap). MAX wins: a
* slower arm or a smaller hold is never installed over a better one
* (build the advanced arm, then the improved the ship keeps 2.0/s).
*/
_applyMiningUpgrade(spec) {
const ship = this.ship;
if (!ship?.stats) return;
if (Number.isFinite(Number(spec.rate))) {
const rate = Math.max(0, Number(spec.rate));
if (rate > (Number(ship.stats.miningSpeed) || 0)) {
ship.stats.miningSpeed = rate;
this.consoleToast(`MINING ARM UPGRADED — ${rate} PER SECOND`, {
glyph: '▲',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
}
}
if (Number.isFinite(Number(spec.capacity))) {
const cap = Math.max(0, Math.round(Number(spec.capacity)));
if (cap > (Number(ship.stats.mineralStorage) || 0)) {
ship.stats.mineralStorage = cap;
this.refreshMineralHud(); // the corner readout is n / CAP — push the new cap
this.consoleToast(`MINERAL STORAGE EXPANDED — ${cap} CAPACITY`, {
glyph: '▲',
glyphColor: toCss(themeColor('amber', 0xffc94d)),
});
}
}
}
/**
* Re-derive the ship's mining upgrades from the build RECORDS (the
* save carries the records, not the ship's derived stats): the MAX
* effects.mining.rate / capacity across every installed build (any
* planet). Idempotent + MAX-guarded a fresh run has no mining
* records and keeps its base stats; a load / jump-cut restore re-asserts
* them. Runs in create(), right after the built tethers are
* re-anchored (same seam: world/ship state re-derived from the
* outliving build records).
*/
_restoreMiningUpgrades() {
if (!this.buildState || !this.ship?.stats) return;
let rate = 0;
let capacity = 0;
for (const ids of this.buildState.built.values()) {
for (const id of ids) {
const spec = defById(id)?.effects?.mining;
if (!spec) continue;
if (Number.isFinite(Number(spec.rate))) rate = Math.max(rate, Number(spec.rate));
if (Number.isFinite(Number(spec.capacity))) capacity = Math.max(capacity, Number(spec.capacity));
}
}
if (rate > (Number(this.ship.stats.miningSpeed) || 0)) this.ship.stats.miningSpeed = rate;
if (capacity > (Number(this.ship.stats.mineralStorage) || 0)) this.ship.stats.mineralStorage = capacity;
if (rate > 0 || capacity > 0) this.refreshMineralHud(); // the HUD was pushed with the FRESH hold earlier in create()
}
/** The RESEARCH slot's progress bar (update() calls it every frame). */
_deckResearchBar(time) {
const bar = this._researchDeckBar;

View File

@ -52,6 +52,7 @@ import {
costLines,
canAfford,
defById,
isShipScoped,
} from '../build/BuildModel.js';
import { ensureIcon } from '../research/ResearchIcons.js';
@ -185,12 +186,20 @@ export class BuildWindow extends Phaser.GameObjects.Container {
playSfxOn(this.scene, name);
}
/** The caller's view of the world for one build (BuildModel ctx). */
/** The caller's view of the world for one build (BuildModel ctx).
* SHIP-scoped builds (targets: ["ship"] the mining arms/storage)
* count as built wherever the ship landed (BuildState.isBuiltAnywhere)
* the effect is on the ship, so "built on this planet" would let a
* re-buy happen on every other world. */
ctxFor(buildId) {
const shipScoped = isShipScoped(defById(buildId));
return {
isResearchUnlocked: (c, n) => this.researchUnlocked(c, n),
tetherLevel: () => this.tetherLevel(),
isBuilt: () => this.state?.isBuilt(this.planetName, buildId) ?? false,
isBuilt: () =>
shipScoped
? this.state?.isBuiltAnywhere(buildId) ?? false
: this.state?.isBuilt(this.planetName, buildId) ?? false,
};
}
@ -1075,8 +1084,8 @@ export class BuildWindow extends Phaser.GameObjects.Container {
if (active.planet === this.planetName && active.build === sel.id) return 'active';
return 'busy';
}
if (state.isBuilt(this.planetName, sel.id)) return 'built';
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';
}