From afa7a00c29f457cb43c9f402c9b6c1066bdbfd26 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 13 Sep 2026 23:20:00 -0600 Subject: [PATCH] Add Tier-3 mining research nodes and premium ship upgrades - Add `premium_arm` and `advanced_storage` research nodes (Tier 3) to the mining tech tree, each gated by its Tier-2 parent. - Add `mining-arm-premium` (4.0 minerals/s) and `mining-storage-advanced` (500 capacity) ship builds in data/builds.json. - Update existing Tier-2 nodes to declare their new children in `unlocks.research`. - Extend test suites for the two new research nodes, their build wiring, cost/affordability, DAG layout (3 rows), and availability gating. --- data/builds.json | 42 ++++++++++++++++++++++++++++++++++++ data/research/mining.json | 24 ++++++++++++++++++--- dev/builds.test.mjs | 22 ++++++++++++++----- dev/research-builds.test.mjs | 27 +++++++++++++++++------ 4 files changed, 100 insertions(+), 15 deletions(-) diff --git a/data/builds.json b/data/builds.json index e3771de..0c6139c 100644 --- a/data/builds.json +++ b/data/builds.json @@ -157,6 +157,27 @@ "color": "#ff8a3d" } }, + "mining-arm-premium": { + "label": "Premium Mining Arm", + "description": "The ship's ultimate arm: it mines asteroids at 4.0 minerals per second — four times the base arm. Supersedes the Advanced 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": 400 + }, + "duration": 20, + "requires": ["mining/premium_arm"], + "repeatable": false, + "effects": { + "mining": { + "rate": 4.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.", @@ -178,6 +199,27 @@ "color": "#ff8a3d" } }, + "mining-storage-advanced": { + "label": "Advanced Mining Storage", + "description": "Expands the ship's mineral hold to 500 (150 more than the Improved 350). Supersedes the Improved Mining Storage (the bigger hold always wins). A ship upgrade: built from any planet's console, counted wherever the ship lands.", + "icon": "storage", + "category": "mining", + "targets": ["ship"], + "cost": { + "minerals": 300 + }, + "duration": 20, + "requires": ["mining/advanced_storage"], + "repeatable": false, + "effects": { + "mining": { + "capacity": 500 + } + }, + "theme": { + "color": "#ff8a3d" + } + }, "cargo-bay-8": { "label": "Cargo Bay \u2014 8", "description": "Expands the ship's cargo hold from 4 to 8 bays \u2014 roughly 2\u00d7 the goods per run, and the trade income to match. A ship upgrade: built from any planet's console, paid in credits, counted wherever the ship lands (never re-bought).", diff --git a/data/research/mining.json b/data/research/mining.json index 2bd6aab..8f97f3a 100644 --- a/data/research/mining.json +++ b/data/research/mining.json @@ -1,5 +1,5 @@ { - "_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).", + "_comment": "MINING — the player's ability to extract. All five 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); premium_arm + advanced_storage are Tier 3 (premium_arm requires advanced_arm, advanced_storage requires improved_storage). 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": { @@ -17,7 +17,16 @@ "icon": "mining2", "duration": 60, "requires": ["improved_arm"], - "unlocks": { "builds": ["mining-arm-advanced"], "research": [] }, + "unlocks": { "builds": ["mining-arm-advanced"], "research": ["premium_arm"] }, + "effects": {} + }, + "premium_arm": { + "label": "Premium Arm", + "description": "Once built, a Premium Mining Arm is four times as fast as a standard mining arm.", + "icon": "mining2", + "duration": 60, + "requires": ["advanced_arm"], + "unlocks": { "builds": ["mining-arm-premium"], "research": [] }, "effects": {} }, "improved_storage": { @@ -26,7 +35,16 @@ "icon": "storage", "duration": 60, "requires": ["improved_arm"], - "unlocks": { "builds": ["mining-storage-improved"], "research": [] }, + "unlocks": { "builds": ["mining-storage-improved"], "research": ["advanced_storage"] }, + "effects": {} + }, + "advanced_storage": { + "label": "Advanced Storage", + "description": "Once built, your ship will be able to store 500 minerals (150 more than the Improved 350).", + "icon": "storage", + "duration": 60, + "requires": ["improved_storage"], + "unlocks": { "builds": ["mining-storage-advanced"], "research": [] }, "effects": {} } } diff --git a/dev/builds.test.mjs b/dev/builds.test.mjs index 1aaf778..473a0c8 100644 --- a/dev/builds.test.mjs +++ b/dev/builds.test.mjs @@ -86,7 +86,7 @@ check('model: cargo bays cost CREDITS (not minerals)', costLines(cargo.builds['c check('model: a credits wallet affords the 8-bay (300 cr) build', canAfford(cargo.builds['cargo-bay-8'], { minerals: 0, credits: 300 })); check('model: a credits wallet BELOW the cost does not afford it', !canAfford(cargo.builds['cargo-bay-8'], { minerals: 100000, credits: 299 })); check('model: a minerals-only wallet (old economy) does NOT afford a credit build', !canAfford(cargo.builds['cargo-bay-8'], 100000)); -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") holds the five ship-upgrade builds', !!miningCat.builds['mining-arm-improved'] && !!miningCat.builds['mining-arm-advanced'] && !!miningCat.builds['mining-arm-premium'] && !!miningCat.builds['mining-storage-improved'] && !!miningCat.builds['mining-storage-advanced']); 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); @@ -148,20 +148,30 @@ check('model: a level-3 tether satisfies the level-2 gate', isAvailable(tl3, ctx // ---------------------------------------------------------------------- const armImproved = builds.builds['mining-arm-improved']; const armAdvanced = builds.builds['mining-arm-advanced']; +const armPremium = builds.builds['mining-arm-premium']; const storageImproved = builds.builds['mining-storage-improved']; +const storageAdvanced = builds.builds['mining-storage-advanced']; -check('model: the mining builds target the SHIP (targets: ["ship"])', isShipScoped(armImproved) && isShipScoped(armAdvanced) && isShipScoped(storageImproved)); +check('model: the mining builds target the SHIP (targets: ["ship"])', isShipScoped(armImproved) && isShipScoped(armAdvanced) && isShipScoped(armPremium) && isShipScoped(storageImproved) && isShipScoped(storageAdvanced)); 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 premium arm effect is 4.0 minerals/s (4× the base arm)', armPremium.effects?.mining?.rate === 4.0); check('model: the improved storage effect is a 350 hold', storageImproved.effects?.mining?.capacity === 350); +check('model: the advanced storage effect is a 500 hold', storageAdvanced.effects?.mining?.capacity === 500); 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: premium arm is LOCKED before the research', isAvailable(armPremium, ctx()) === false); +check('model: premium arm is READY once researched', isAvailable(armPremium, ctx({ miningResearch: ['premium_arm'] })) === true); +check('model: premium arm is gated by its OWN research (advanced_arm does not unlock it)', isAvailable(armPremium, ctx({ miningResearch: ['advanced_arm'] })) === false); 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: advanced storage is LOCKED before the research', isAvailable(storageAdvanced, ctx()) === false); +check('model: advanced storage is READY once researched', isAvailable(storageAdvanced, ctx({ miningResearch: ['advanced_storage'] })) === true); +check('model: advanced storage is gated by its OWN research (improved_storage does not unlock it)', isAvailable(storageAdvanced, ctx({ miningResearch: ['improved_storage'] })) === false); 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); @@ -185,12 +195,14 @@ check('model: rowState reads built for a mining build (ship-scoped ctx)', rowSta 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) +// the costs (200 / 300 / 400 minerals for the arms, 200 / 300 for the holds) + 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: premium arm costs 400 minerals, 20 s', costLines(armPremium).every((l) => l.res === 'minerals' && l.amount === 400) && armPremium.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); +check('model: advanced storage costs 300 minerals, 20 s', costLines(storageAdvanced).every((l) => l.res === 'minerals' && l.amount === 300) && storageAdvanced.duration === 20); +check('model: mining builds are unaffordable below their cost', canAfford(armImproved, 199) === false && canAfford(armAdvanced, 299) === false && canAfford(armPremium, 399) === false && canAfford(storageImproved, 199) === false && canAfford(storageAdvanced, 299) === false); +check('model: mining builds are affordable at their cost', canAfford(armImproved, 200) === true && canAfford(armAdvanced, 300) === true && canAfford(armPremium, 400) === true && canAfford(storageImproved, 200) === true && canAfford(storageAdvanced, 300) === true); // ---------------------------------------------------------------------- // 4. Row states — the list's paint source diff --git a/dev/research-builds.test.mjs b/dev/research-builds.test.mjs index 2598001..b4402f2 100644 --- a/dev/research-builds.test.mjs +++ b/dev/research-builds.test.mjs @@ -133,8 +133,8 @@ check('exploration: tether_l2 + tether_l3 carry no immediate tether effect (thei // 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); +check('mining: nodes is a non-empty map (5 techs)', Object.keys(mnodes).length === 5); +check('mining: nothing is starting (all five 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; } @@ -143,15 +143,21 @@ for (const [id, n] of Object.entries(mnodes)) { 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 are the short button copy (the tree node has no room for "Mining" — the category tab says it)', mnodes.improved_arm?.label === 'Improved Arm' && mnodes.advanced_arm?.label === 'Advanced Arm' && mnodes.improved_storage?.label === 'Improved Storage'); -check('mining: all three take 60 s (research.timeUnit)', Object.values(mnodes).every((n) => n.duration === 60)); +check('mining: labels are the short button copy (the tree node has no room for "Mining" — the category tab says it)', mnodes.improved_arm?.label === 'Improved Arm' && mnodes.advanced_arm?.label === 'Advanced Arm' && mnodes.premium_arm?.label === 'Premium Arm' && mnodes.improved_storage?.label === 'Improved Storage' && mnodes.advanced_storage?.label === 'Advanced Storage'); +check('mining: all five 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: premium_arm is Tier 3 under advanced_arm', (mnodes.premium_arm?.requires ?? []).length === 1 && (mnodes.premium_arm?.requires ?? []).includes('advanced_arm')); +check('mining: advanced_storage is Tier 3 under improved_storage', (mnodes.advanced_storage?.requires ?? []).length === 1 && (mnodes.advanced_storage?.requires ?? []).includes('improved_storage')); 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: advanced_arm declares its child (premium_arm) in unlocks.research', JSON.stringify(mnodes.advanced_arm?.unlocks?.research ?? []) === JSON.stringify(['premium_arm'])); +check('mining: improved_storage declares its child (advanced_storage) in unlocks.research', JSON.stringify(mnodes.improved_storage?.unlocks?.research ?? []) === JSON.stringify(['advanced_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: premium_arm declares its build (mining-arm-premium)', JSON.stringify(mnodes.premium_arm?.unlocks?.builds ?? []) === JSON.stringify(['mining-arm-premium'])); 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)); +check('mining: advanced_storage declares its build (mining-storage-advanced)', JSON.stringify(mnodes.advanced_storage?.unlocks?.builds ?? []) === JSON.stringify(['mining-storage-advanced'])); +check('mining: all five 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) @@ -216,21 +222,28 @@ check('state: an old save without research restores as a fresh start', (() => { // ---------------------------------------------------------------------- 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: loadCategory returns the mining tree', !!mtree && !!mtree.nodes.improved_arm && mtree.order.length === 5); 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); +check('model: mining layout — 3 rows (Tier 1 → two Tier-2 → two Tier-3)', mlayout.rows === 3 && mlayout.level.improved_arm === 0 && mlayout.level.advanced_arm === 1 && mlayout.level.improved_storage === 1 && mlayout.level.premium_arm === 2 && mlayout.level.advanced_storage === 2); 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: both Tier-3 projects wait on their Tier-2 parent (locked)', !isAvailable(mtree, mstate, 'premium_arm') && !isAvailable(mtree, mstate, 'advanced_storage')); check('mining: missingRequires names the parent (Tier 2 ← Tier 1)', JSON.stringify(missingRequires(mtree, mstate, 'advanced_arm')) === JSON.stringify(['improved_arm'])); +check('mining: missingRequires names the Tier-3 parent (premium_arm ← advanced_arm)', JSON.stringify(missingRequires(mtree, mstate, 'premium_arm')) === JSON.stringify(['advanced_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')); +check('mining: Tier-3 stays locked until its own Tier-2 parent is researched', !isAvailable(mtree, mstate, 'premium_arm') && !isAvailable(mtree, mstate, 'advanced_storage')); +mstate.unlock('mining', 'advanced_arm'); +mstate.unlock('mining', 'improved_storage'); +check('mining: premium_arm opens once advanced_arm is researched', isAvailable(mtree, mstate, 'premium_arm')); +check('mining: advanced_storage opens once improved_storage is researched', isAvailable(mtree, mstate, 'advanced_storage')); // ---------------------------------------------------------------------- // 5. Builds — the build console (data/builds.json → js/ui/BuildWindow.js)