diff --git a/README.md b/README.md index e8381f8..bca6b36 100644 --- a/README.md +++ b/README.md @@ -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 1–4, - Tether Anchoring, Signal Amplification). Unlocks and in-progress work + `data/research/` (**Exploration**: Tether Level 1–3, 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** diff --git a/assets/videos/rocky-surface-03.mp4 b/assets/videos/rocky-surface-03.mp4 new file mode 100644 index 0000000..0409876 Binary files /dev/null and b/assets/videos/rocky-surface-03.mp4 differ diff --git a/data/builds.json b/data/builds.json index a494010..df223f9 100644 --- a/data/builds.json +++ b/data/builds.json @@ -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" } diff --git a/data/manifest.json b/data/manifest.json index 5949eb2..bf0644e 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -16,6 +16,7 @@ "naming.json", "research.json", "research/exploration.json", + "research/mining.json", "builds.json", "actionbar.json", "landing.json", diff --git a/data/research.json b/data/research.json index 8161062..0db1353 100644 --- a/data/research.json +++ b/data/research.json @@ -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/.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.", diff --git a/data/research/exploration.json b/data/research/exploration.json index b9ab848..692181b 100644 --- a/data/research/exploration.json +++ b/data/research/exploration.json @@ -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^(level−1), 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^(level−1) (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", diff --git a/data/research/mining.json b/data/research/mining.json new file mode 100644 index 0000000..df986b9 --- /dev/null +++ b/data/research/mining.json @@ -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/\"]` 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": {} + } + } +} diff --git a/dev/builds.test.mjs b/dev/builds.test.mjs index 48a2bbf..bdeaccb 100644 --- a/dev/builds.test.mjs +++ b/dev/builds.test.mjs @@ -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); diff --git a/dev/research-builds.test.mjs b/dev/research-builds.test.mjs index 1decd74..b5a0896 100644 --- a/dev/research-builds.test.mjs +++ b/dev/research-builds.test.mjs @@ -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); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 0623d08..d3a63e0 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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/"]` 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 1–3, Tether Anchoring, Signal Amplification); add a category = + Level 1–3, 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 diff --git a/js/build/BuildModel.js b/js/build/BuildModel.js index df71636..8a72590 100644 --- a/js/build/BuildModel.js +++ b/js/build/BuildModel.js @@ -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). diff --git a/js/build/BuildState.js b/js/build/BuildState.js index 78b2810..73516f7 100644 --- a/js/build/BuildState.js +++ b/js/build/BuildState.js @@ -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) ?? [])]; } diff --git a/js/dev/BuildDiag.js b/js/dev/BuildDiag.js index d6d5cf8..229ecb9 100644 --- a/js/dev/BuildDiag.js +++ b/js/dev/BuildDiag.js @@ -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'}`, diff --git a/js/research/ResearchIcons.js b/js/research/ResearchIcons.js index a91802b..b431a7f 100644 --- a/js/research/ResearchIcons.js +++ b/js/research/ResearchIcons.js @@ -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; diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 846ba2b..4a35dbc 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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; diff --git a/js/ui/BuildWindow.js b/js/ui/BuildWindow.js index 1ebf4ec..78a324c 100644 --- a/js/ui/BuildWindow.js +++ b/js/ui/BuildWindow.js @@ -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'; }