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.
This commit is contained in:
Brian Fertig 2026-09-13 23:20:00 -06:00
parent 58aab347c2
commit afa7a00c29
4 changed files with 100 additions and 15 deletions

View File

@ -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).",

View File

@ -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/<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).",
"_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/<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); 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": {}
}
}

View File

@ -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

View File

@ -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)