From 138096cd2b0740b247b29e7cd3e1613b2b068c22 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Mon, 14 Sep 2026 19:13:56 -0600 Subject: [PATCH] Add Cargo research category with quest and build-completion gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduce data/research/cargo.json: a 3-node chain (8 → 12 → 16 bays) where each node is a blueprint that unlocks the matching ship-hold build - Add `build` gate field to ResearchModel.js (attachBuildGates) so a node can require a named build to be completed before it becomes researchable, composing with the existing quest gate - Wire both gates in GameScene and ResearchWindow so node availability, lock notes, and the RESEARCH button reflect quest + build state - Gate cargo_bay_8 on the special_delivery quest; cargo_bay_12/16 on Improved/Advanced Mining Storage builds - Update builds.json to require the corresponding research nodes for cargo-bay-8/-12/-16 - Add comprehensive tests covering the cargo tree structure, pure DAG availability, live gate composition, and build-side lock-step --- data/builds.json | 6 +-- data/manifest.json | 1 + data/research.json | 4 +- data/research/cargo.json | 36 +++++++++++++ dev/research-builds.test.mjs | 102 ++++++++++++++++++++++++++++++++++- js/research/ResearchModel.js | 54 ++++++++++++++++++- js/scenes/GameScene.js | 19 ++++--- js/ui/ResearchWindow.js | 21 +++++--- 8 files changed, 222 insertions(+), 21 deletions(-) create mode 100644 data/research/cargo.json diff --git a/data/builds.json b/data/builds.json index 0c6139c..09e533a 100644 --- a/data/builds.json +++ b/data/builds.json @@ -230,7 +230,7 @@ "credits": 300 }, "duration": 20, - "requires": [], + "requires": ["cargo/cargo_bay_8"], "repeatable": false, "effects": { "cargo": { @@ -251,7 +251,7 @@ "credits": 1200 }, "duration": 30, - "requires": [], + "requires": ["cargo/cargo_bay_12"], "repeatable": false, "effects": { "cargo": { @@ -272,7 +272,7 @@ "credits": 3000 }, "duration": 40, - "requires": [], + "requires": ["cargo/cargo_bay_16"], "repeatable": false, "effects": { "cargo": { diff --git a/data/manifest.json b/data/manifest.json index c2f723b..c1dad1a 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -18,6 +18,7 @@ "research.json", "research/exploration.json", "research/mining.json", + "research/cargo.json", "builds.json", "actionbar.json", "landing.json", diff --git a/data/research.json b/data/research.json index e2de9aa..d78d23a 100644 --- a/data/research.json +++ b/data/research.json @@ -7,6 +7,7 @@ "categories": [ { "id": "system", "label": "System", "accent": "#5fd4ff", "dynamic": true }, { "id": "mining", "label": "Mining", "accent": "#ffc94d" }, + { "id": "cargo", "label": "Cargo", "accent": "#7ce8a4" }, { "id": "exploration", "label": "Exploration", "accent": "#00e5ff" } ], "_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.", @@ -42,5 +43,6 @@ }, "effects": { "tether": { "level": 2 } } }, - "_questGate": "Optional node field — `quest: `: a STORY-QUEST gate on top of `requires`. The named quest (data/quests.json) must have its reward CLAIMED (the dossier's CLAIM action — the same paid-out witness the run's chain uses to grant the next quest) before the node is researchable. The model stays pure: the scene supplies the live predicate, and js/research/ResearchModel.js → attachQuestGates wires it into the tree's optional `available`/`lockNote` hooks (isAvailable consults the hook like any extra gate — the SYSTEM category's chart gate is the same seam; the console's LOCKED readout shows the quest's title via lockNote). In use: mining/premium_arm + mining/advanced_storage wait on 'special_delivery' (data/research/mining.json)." + "_questGate": "Optional node field — `quest: `: a STORY-QUEST gate on top of `requires`. The named quest (data/quests.json) must have its reward CLAIMED (the dossier's CLAIM action — the same paid-out witness the run's chain uses to grant the next quest) before the node is researchable. The model stays pure: the scene supplies the live predicate, and js/research/ResearchModel.js → attachQuestGates wires it into the tree's optional `available`/`lockNote` hooks (isAvailable consults the hook like any extra gate — the SYSTEM category's chart gate is the same seam; the console's LOCKED readout shows the quest's title via lockNote). In use: mining/premium_arm + mining/advanced_storage (data/research/mining.json) and cargo/cargo_bay_8 (data/research/cargo.json) wait on 'special_delivery'.", + "_buildGate": "Optional node field — `build: `: a BUILD-COMPLETION gate on top of `requires`. The named build (data/builds.json) must be BUILT before the node is researchable (for a ship upgrade, built anywhere — the scene's live predicate reads its BuildState built set). Same pure seam as the quest gate: js/research/ResearchModel.js → attachBuildGates wires it into the same optional `available`/`lockNote` hooks (the console's LOCKED readout shows ` BUILD REQUIRED` via lockNote). The two gates compose (each is checked first in the order it was attached). In use: cargo/cargo_bay_12 waits on the mining-storage-improved build, cargo/cargo_bay_16 on mining-storage-advanced (data/research/cargo.json)." } diff --git a/data/research/cargo.json b/data/research/cargo.json new file mode 100644 index 0000000..470085d --- /dev/null +++ b/data/research/cargo.json @@ -0,0 +1,36 @@ +{ + "_comment": "CARGO — the player's trade capacity: the ship's hold, bay for bay (the 16-slot grid, data/economy.json → cargo.maxSlots). All three techs are BLUEPRINTS (effects: {}): the node's `unlocks.builds` declares the matching ship-hold build (data/builds.json → cargo-bay-8 / -12 / -16) and the build's `requires: [\"cargo/\"]` is the authoritative gate (dev/research-builds.test.mjs keeps both sides in lock-step). The hold's life is 0 at the run's start (data/ship.json), 4 by the bar scene (data/vignettes.json → effects.cargoHold), then this chain: 8 → 12 → 16. One node per bay count, a strict chain — cargo_bay_8 is the root; cargo_bay_12 requires it; cargo_bay_16 requires cargo_bay_12 (MAX wins — the biggest hold always supersedes, the build's effect never downgrades). On top of the DAG, the three tiers carry gates in the style of the mining Tier-3 techs (node fields, the scene wires the live predicates via attachQuestGates + attachBuildGates — js/research/ResearchModel.js): cargo_bay_8 waits on `quest: \"special_delivery\"` (the 'Special Delivery' reward must be CLAIMED — you learn to move cargo before you grow the hold), cargo_bay_12 on `build: \"mining-storage-improved\"` (the Improved Mining Storage build must be BUILT), cargo_bay_16 on `build: \"mining-storage-advanced\"` (the Advanced Mining Storage build BUILT). Node contract is the same as data/research/mining.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 — everything is researched, never granted). duration is in research.timeUnit (seconds).", + "starting": [], + "nodes": { + "cargo_bay_8": { + "label": "Cargo - 8 Bays", + "description": "The blueprint for the ship's first trade upgrade: the 8-bay hold. Once researched, the 8-bay bay can be built from any planet's console (300 credits, 20 s) — roughly 2× the goods per run, and the trade income to match.", + "icon": "storage", + "duration": 60, + "requires": [], + "quest": "special_delivery", + "unlocks": { "builds": ["cargo-bay-8"], "research": ["cargo_bay_12"] }, + "effects": {} + }, + "cargo_bay_12": { + "label": "Cargo - 12 Bays", + "description": "The blueprint for the 12-bay hold. Once researched, the 12-bay bay can be built (1200 credits, 30 s) — three quarters of a full hold in a single run, supersedes the 8-bay bay.", + "icon": "storage", + "duration": 60, + "requires": ["cargo_bay_8"], + "build": "mining-storage-improved", + "unlocks": { "builds": ["cargo-bay-12"], "research": ["cargo_bay_16"] }, + "effects": {} + }, + "cargo_bay_16": { + "label": "Cargo - 16 Bays", + "description": "The blueprint for the full 16-bay hold — a freighter's worth of goods in one run, the biggest the ship can carry. Once researched, the 16-bay bay can be built (3000 credits, 40 s), superseding every smaller bay.", + "icon": "storage", + "duration": 60, + "requires": ["cargo_bay_12"], + "build": "mining-storage-advanced", + "unlocks": { "builds": ["cargo-bay-16"], "research": [] }, + "effects": {} + } + } +} diff --git a/dev/research-builds.test.mjs b/dev/research-builds.test.mjs index 52d7858..f5b71c6 100644 --- a/dev/research-builds.test.mjs +++ b/dev/research-builds.test.mjs @@ -28,6 +28,7 @@ const manifest = read('manifest.json'); const research = read('research.json'); const exploration = read('research/exploration.json'); const mining = read('research/mining.json'); +const cargo = read('research/cargo.json'); const quests = read('quests.json'); const builds = read('builds.json'); const actionbar = read('actionbar.json'); @@ -42,7 +43,7 @@ const hex = /^#[0-9a-fA-F]{6}$/; // ---------------------------------------------------------------------- // 1. Manifest registration (section name = file basename) // ---------------------------------------------------------------------- -for (const f of ['research.json', 'research/exploration.json', 'research/mining.json', 'builds.json', 'actionbar.json']) { +for (const f of ['research.json', 'research/exploration.json', 'research/mining.json', 'research/cargo.json', 'builds.json', 'actionbar.json']) { check(`manifest registers ${f}`, manifest.files.includes(f)); } @@ -55,6 +56,7 @@ check('research: one project at a time (maxConcurrent === 1)', research.maxConcu check('research: categories is a non-empty array', Array.isArray(research.categories) && research.categories.length > 0); check('research: every category has id/label/accent', research.categories.every((c) => typeof c.id === 'string' && typeof c.label === 'string' && hex.test(c.accent ?? ''))); check('research: defaultCategory names a real category', (research.categories ?? []).some((c) => c.id === research.defaultCategory)); +check('research: tab order is System, Mining, Cargo, Exploration (Cargo between Mining and Exploration)', JSON.stringify(research.categories.map((c) => c.id)) === JSON.stringify(['system', 'mining', 'cargo', 'exploration'])); // every STATIC category in the registry has its own tree file in the // manifest. A category flagged `dynamic: true` (currently: `system`) @@ -173,7 +175,7 @@ check('mining: the Tier-1 + Tier-2 techs carry no quest gate', // 4. The REAL code path — ResearchModel + ResearchState (pure modules) // ---------------------------------------------------------------------- const { config } = await import('../js/config/Config.js'); -config.init({ research, exploration, mining, builds, actionbar, quests }); +config.init({ research, exploration, mining, cargo, builds, actionbar, quests }); const { categories, loadCategory, issues, layoutTree, isAvailable, missingRequires } = await import('../js/research/ResearchModel.js'); const { ResearchState } = await import('../js/research/ResearchState.js'); const { questDef } = await import('../js/quests/QuestState.js'); @@ -294,6 +296,102 @@ const { attachQuestGates } = await import('../js/research/ResearchModel.js'); })()); } +// ---------------------------------------------------------------------- +// 4c. The cargo tree — data/research/cargo.json (the Cargo tab, between +// Mining and Exploration): 8 Bays → 12 Bays → 16 Bays, each a +// blueprint for its ship-hold build (data/builds.json → cargo-bay-*). +// ---------------------------------------------------------------------- +const cnodes = cargo.nodes ?? {}; +check('cargo: nodes is a non-empty map (3 techs — the 8 → 12 → 16 chain)', Object.keys(cnodes).length === 3); +check('cargo: nothing is starting (all three must be researched)', (cargo.starting ?? []).length === 0); +let cfieldsOk = true; +for (const [id, n] of Object.entries(cnodes)) { + if (!n || typeof n !== 'object') { cfieldsOk = false; continue; } + if (typeof n.label !== 'string' || typeof n.description !== 'string' || !ICON_NAMES.includes(n.icon ?? 'diamond') || typeof n.duration !== 'number' || n.duration < 0) cfieldsOk = false; + if (!Array.isArray(n.requires) || !n.requires.every((r) => typeof r === 'string' && cnodes[r])) cfieldsOk = false; + if (!Array.isArray(n.unlocks?.builds) || !Array.isArray(n.unlocks?.research)) cfieldsOk = false; +} +check('cargo: every node has label/description/icon/duration/requires/unlocks', cfieldsOk); +check('cargo: labels read Cargo - 8 / 12 / 16 Bays', cnodes.cargo_bay_8?.label === 'Cargo - 8 Bays' && cnodes.cargo_bay_12?.label === 'Cargo - 12 Bays' && cnodes.cargo_bay_16?.label === 'Cargo - 16 Bays'); +check('cargo: cargo_bay_8 is the root (no requires — the chain opens here)', (cnodes.cargo_bay_8?.requires ?? []).length === 0); +check('cargo: cargo_bay_12 waits on cargo_bay_8', JSON.stringify(cnodes.cargo_bay_12?.requires ?? []) === JSON.stringify(['cargo_bay_8'])); +check('cargo: cargo_bay_16 waits on cargo_bay_12', JSON.stringify(cnodes.cargo_bay_16?.requires ?? []) === JSON.stringify(['cargo_bay_12'])); +check('cargo: 8 Bays declares its build (cargo-bay-8) + child (cargo_bay_12)', JSON.stringify(cnodes.cargo_bay_8?.unlocks ?? null) === JSON.stringify({ builds: ['cargo-bay-8'], research: ['cargo_bay_12'] })); +check('cargo: 12 Bays declares its build (cargo-bay-12) + child (cargo_bay_16)', JSON.stringify(cnodes.cargo_bay_12?.unlocks ?? null) === JSON.stringify({ builds: ['cargo-bay-12'], research: ['cargo_bay_16'] })); +check('cargo: 16 Bays declares its build (cargo-bay-16) and no child', JSON.stringify(cnodes.cargo_bay_16?.unlocks ?? null) === JSON.stringify({ builds: ['cargo-bay-16'], research: [] })); +check('cargo: cargo_bay_8 gates on the special_delivery quest (reward CLAIMED first)', cnodes.cargo_bay_8?.quest === 'special_delivery'); +check('cargo: cargo_bay_12 gates on the mining-storage-improved build (BUILT first)', cnodes.cargo_bay_12?.build === 'mining-storage-improved'); +check('cargo: cargo_bay_16 gates on the mining-storage-advanced build (BUILT first)', cnodes.cargo_bay_16?.build === 'mining-storage-advanced'); +check('cargo: the gated builds are REAL builds (a typo would be a gate that never lifts)', !!builds.builds['mining-storage-improved'] && !!builds.builds['mining-storage-advanced']); +check('cargo: the gated quest is DEFINED (a typo would be a gate that never lifts)', questDef('special_delivery') !== null); +check('cargo: all three are blueprints (no effects — the build carries the hold)', Object.values(cnodes).every((n) => Object.keys(n.effects ?? {}).length === 0)); + +const ctree = loadCategory('cargo'); +check('model: loadCategory returns the cargo tree', !!ctree && !!ctree.nodes.cargo_bay_8 && ctree.order.length === 3); +check('model: cargo: issues(tree) is clean', Array.isArray(issues(ctree)) && issues(ctree).length === 0); +check('model: cargo: unlockIssues is clean (mirror + build wiring agree)', unlockIssues(ctree).length === 0); +check('model: cargo layout — a 3-row chain (8 → 12 → 16)', (() => { const l = layoutTree(ctree); return l.rows === 3 && l.level.cargo_bay_8 === 0 && l.level.cargo_bay_12 === 1 && l.level.cargo_bay_16 === 2; })()); + +const cstate = new ResearchState(); +check('cargo (pure DAG): 8 Bays is the root (available on a fresh run)', isAvailable(ctree, cstate, 'cargo_bay_8')); +check('cargo (pure DAG): 12 + 16 Bays wait on their parent (locked)', !isAvailable(ctree, cstate, 'cargo_bay_12') && !isAvailable(ctree, cstate, 'cargo_bay_16')); +check('cargo: missingRequires names the parent (12 ← 8)', JSON.stringify(missingRequires(ctree, cstate, 'cargo_bay_12')) === JSON.stringify(['cargo_bay_8'])); +cstate.unlock('cargo', 'cargo_bay_8'); +check('cargo (pure DAG): 12 Bays opens once 8 Bays is researched', isAvailable(ctree, cstate, 'cargo_bay_12')); +check('cargo (pure DAG): 16 Bays stays locked until its own parent is researched', !isAvailable(ctree, cstate, 'cargo_bay_16')); +cstate.unlock('cargo', 'cargo_bay_12'); +check('cargo (pure DAG): 16 Bays opens once 12 Bays is researched', isAvailable(ctree, cstate, 'cargo_bay_16')); + +// The live gates — the scene's wiring (attachBuildGates + attachQuestGates, +// the same seam as the mining Tier-3 quest gate): fresh run = quest NOT +// CLAIMED, nothing BUILT → every cargo tech locked; each gate lifts as its +// condition lands (claim → 8 Bays; Improved Storage built → 12 Bays; +// Advanced Storage built → 16 Bays). +const { attachBuildGates } = await import('../js/research/ResearchModel.js'); +{ + const gates = (built, claimed) => attachQuestGates( + attachBuildGates(loadCategory('cargo'), (bid) => built.includes(bid)), + (qid) => (claimed ? qid === 'special_delivery' : false), + ); + const gstate = new ResearchState(); + check('cargo-gates: fresh run — 8 Bays waits on the quest (SPECIAL DELIVERY REQUIRED)', + !isAvailable(gates([], false), gstate, 'cargo_bay_8') && gates([], false).lockNote(gstate, 'cargo_bay_8') === 'SPECIAL DELIVERY REQUIRED'); + check('cargo-gates: fresh run — 12 + 16 Bays locked (parent + build gates)', + !isAvailable(gates([], false), gstate, 'cargo_bay_12') && !isAvailable(gates([], false), gstate, 'cargo_bay_16')); + check('cargo-gates: quest CLAIMED — 8 Bays opens (the first cargo research)', + isAvailable(gates([], true), gstate, 'cargo_bay_8')); + gstate.unlock('cargo', 'cargo_bay_8'); + check('cargo-gates: Improved Storage NOT built — 12 Bays stays locked (the build gate)', + !isAvailable(gates([], true), gstate, 'cargo_bay_12') && gates([], true).lockNote(gstate, 'cargo_bay_12') === 'IMPROVED MINING STORAGE BUILD REQUIRED'); + check('cargo-gates: Improved Storage NOT built — 16 Bays stays locked too', + !isAvailable(gates([], true), gstate, 'cargo_bay_16')); + const g2 = new ResearchState(); + g2.unlock('cargo', 'cargo_bay_8'); + check('cargo-gates: Improved Storage BUILT — 12 Bays opens (8 Bays researched + the build done)', + isAvailable(gates(['mining-storage-improved'], true), g2, 'cargo_bay_12')); + g2.unlock('cargo', 'cargo_bay_12'); + check('cargo-gates: Advanced Storage NOT built — 16 Bays stays locked (the build gate)', + !isAvailable(gates(['mining-storage-improved'], true), g2, 'cargo_bay_16') && + gates(['mining-storage-improved'], true).lockNote(g2, 'cargo_bay_16') === 'ADVANCED MINING STORAGE BUILD REQUIRED'); + check('cargo-gates: both storages BUILT — 16 Bays opens (12 Bays researched + the build done)', + isAvailable(gates(['mining-storage-improved', 'mining-storage-advanced'], true), g2, 'cargo_bay_16')); + check('cargo-gates: lockNote reports the right gate per node (quest on 8 Bays, the builds on 12 + 16)', + (() => { + const none = gates([], false); + const st = new ResearchState(); + return none.lockNote(st, 'cargo_bay_8') === 'SPECIAL DELIVERY REQUIRED' && + none.lockNote(st, 'cargo_bay_12') === 'IMPROVED MINING STORAGE BUILD REQUIRED' && + none.lockNote(st, 'cargo_bay_16') === 'ADVANCED MINING STORAGE BUILD REQUIRED'; + })()); +} + +// The BUILDS side — the authoritative gate for each hold expansion (the +// research node's unlocks.builds is the declaration; the build's requires +// is what beginBuild/BuildModel enforce). +check('builds: cargo-bay-8 is gated on cargo/cargo_bay_8 (authoritative side)', JSON.stringify(builds.builds['cargo-bay-8']?.requires ?? []) === JSON.stringify(['cargo/cargo_bay_8'])); +check('builds: cargo-bay-12 is gated on cargo/cargo_bay_12 (authoritative side)', JSON.stringify(builds.builds['cargo-bay-12']?.requires ?? []) === JSON.stringify(['cargo/cargo_bay_12'])); +check('builds: cargo-bay-16 is gated on cargo/cargo_bay_16 (authoritative side)', JSON.stringify(builds.builds['cargo-bay-16']?.requires ?? []) === JSON.stringify(['cargo/cargo_bay_16'])); + // ---------------------------------------------------------------------- // 5. Builds — the build console (data/builds.json → js/ui/BuildWindow.js) // ---------------------------------------------------------------------- diff --git a/js/research/ResearchModel.js b/js/research/ResearchModel.js index 43c0ad9..0947039 100644 --- a/js/research/ResearchModel.js +++ b/js/research/ResearchModel.js @@ -8,6 +8,7 @@ * starting: ["root-id"] already-owned nodes (fresh run) * nodes: { id: { label, description, icon, duration, requires: [ids], * quest?: , + * build?: , * unlocks: { builds, research }, effects } } * Layout: level(id) = 0 for roots, else 1 + max(level(requires)). Columns: * leaves take sequential slots in DFS order (data order of roots, then @@ -23,7 +24,14 @@ * researchable. The model stays pure: attachQuestGates(tree, isQuestClaimed) * wires the caller's live predicate into those same hooks (the scene reads * its quest ledger — the ledger's claimed set; today: mining/premium_arm + - * mining/advanced_storage wait on 'special_delivery'). + * mining/advanced_storage + cargo/cargo_bay_8 wait on 'special_delivery'). + * Node's optional `build` field (data/builds.json id): a BUILD-COMPLETION + * gate on top of `requires` — the named build must be BUILT before the + * node is researchable. Same pure seam: attachBuildGates(tree, + * isBuildCompleted) wires the caller's live predicate (the scene reads its + * BuildState — the built set; today: cargo/cargo_bay_12 waits on the + * Improved Mining Storage build, cargo/cargo_bay_16 on the Advanced one). + * Both gates compose over each other and over a dynamic tree's own hooks. */ import { config } from '../config/Config.js'; import { questDef } from '../quests/QuestState.js'; @@ -235,6 +243,50 @@ export function attachQuestGates(tree, isQuestClaimed) { return tree; } +/** + * Wire a node's BUILD-COMPLETION gate (optional `build` field, a + * data/builds.json id) into the tree's available/lockNote hooks — the same + * seam the SYSTEM category's chart gate and the STORY-QUEST gate use + * (isAvailable consults `available`; the console's LOCKED readout + * consults `lockNote`). The named build must be BUILT before the node is + * researchable — for a ship upgrade (targets: ["ship"]) that means built + * anywhere (the caller's predicate decides — the scene reads its BuildState + * built set). Pure: `isBuildCompleted(buildId)` is the caller's LIVE + * predicate; absent it, the tree is returned untouched and the gate is not + * enforced (the pure tree has no run state to consult). + * + * Gating semantics: a node with `build` is unavailable while + * !isBuildCompleted(build) — regardless of its `requires` — and its lock + * reason reads " BUILD REQUIRED" (the build id, upper-cased, + * when the label is unknown). Idempotent: an already-wired hook composes + * (this gate is checked first). Returns the same tree (mutated) or null for + * no tree. + */ +export function attachBuildGates(tree, isBuildCompleted) { + if (!tree || typeof tree !== 'object' || !Array.isArray(tree.order)) return tree; + const buildOf = (id) => { + const b = tree.nodes[id]?.build; + return typeof b === 'string' && b !== '' ? b : null; + }; + if (typeof isBuildCompleted !== 'function' || !tree.order.some((id) => buildOf(id))) return tree; + const prevAvailable = tree.available; + const prevNote = tree.lockNote; + tree.available = (state, id) => { + const b = buildOf(id); + if (b !== null && !isBuildCompleted(b)) return false; + return typeof prevAvailable === 'function' ? prevAvailable(state, id) : true; + }; + tree.lockNote = (state, id) => { + const b = buildOf(id); + if (b !== null && !isBuildCompleted(b)) { + const label = buildDefs()[b]?.label ?? b; + return `${String(label).toUpperCase()} BUILD REQUIRED`; + } + return typeof prevNote === 'function' ? prevNote(state, id) : null; + }; + return tree; +} + /** * Does this tree hold ANY tech ready to research right now — an * unresearched node whose requires are all met and whose extra gate diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index cbff2ce..241b248 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -58,7 +58,7 @@ import { navDiscoveryStats, resourceStats, systemChartSnapshot } from '../galaxy import { buildGalaxySnapshot, edgeKey } from '../galaxy/GalaxyChart.js'; import { planRoute } from '../galaxy/Route.js'; import { ResearchState } from '../research/ResearchState.js'; -import { categories, loadCategory, isAvailable, hasAvailable, buildDefs, attachQuestGates } from '../research/ResearchModel.js'; +import { categories, loadCategory, isAvailable, hasAvailable, buildDefs, attachQuestGates, attachBuildGates } from '../research/ResearchModel.js'; import { SYSTEM_CATEGORY, MAP_NODE, @@ -4937,15 +4937,20 @@ export class GameScene extends Phaser.Scene { /** The tree for a category: the SYSTEM category's live per-system tree, * or the static file tree (data/research/.json — null if missing). - * The static tree's nodes may carry the STORY-QUEST gate (node. - * `quest` — 'Special Delivery' reward CLAIMED before the gated tech is - * researchable): attachQuestGates wires this scene's live quest check - * into the tree's available/lockNote hooks, so beginResearch's - * isAvailable pass and the READY-RESEARCH cues see the gate. */ + * The static tree's nodes may carry gates on top of `requires`: a + * STORY-QUEST gate (node. `quest` — 'Special Delivery' reward CLAIMED; + * attachQuestGates wires this scene's live quest check) and a + * BUILD-COMPLETION gate (node. `build` — the named build BUILT; + * attachBuildGates wires this scene's BuildState built set — today the + * cargo 12/16 Bays techs wait on the Improved/Advanced Mining Storage + * builds). Both land in the tree's available/lockNote hooks, so + * beginResearch's isAvailable pass and the READY-RESEARCH cues see them. */ _treeFor(catId) { if (this.systemTree && catId === this.systemTree.id) return this.systemTree; const tree = loadCategory(catId); - return tree ? attachQuestGates(tree, (qid) => this.questClaimed(qid)) : tree; + if (!tree) return tree; + attachBuildGates(tree, (bid) => (this.buildState?.isBuiltAnywhere(bid) ?? false) === true); + return attachQuestGates(tree, (qid) => this.questClaimed(qid)); } /** diff --git a/js/ui/ResearchWindow.js b/js/ui/ResearchWindow.js index 7df8231..8e33e6a 100644 --- a/js/ui/ResearchWindow.js +++ b/js/ui/ResearchWindow.js @@ -51,6 +51,7 @@ import { unlocksOf, buildDefs, attachQuestGates, + attachBuildGates, } from '../research/ResearchModel.js'; import { gatesReady } from '../research/SystemCategory.js'; import { shimmerPhase, drawShimmer } from './ShimmerCue.js'; @@ -746,16 +747,22 @@ export class ResearchWindow extends Phaser.GameObjects.Container { // ── right: the tech trees ───────────────────────────────────────────────── /** A category's tree: the DYNAMIC one (SystemCategory) is the scene's * live per-system tree — no data file; the rest read data/research/.json. - * The static trees' STORY-QUEST gates (node. `quest` — the 'Special - * Delivery' reward-claim bar on the mining Tier-3 techs) are wired in - * here: attachQuestGates points the tree's available/lockNote hooks at - * the scene's live quest check (this.scene.questClaimed), so the node - * states, the LOCKED readout and the RESEARCH button all see the gate. */ + * The static trees' gates are wired in here: attachQuestGates points the + * tree's available/lockNote hooks at the scene's live quest check + * (node. `quest` — the 'Special Delivery' reward-claim bar on the mining + * Tier-3 techs + cargo 8 Bays), and attachBuildGates at the scene's + * BuildState built set (node. `build` — the named build must be BUILT; + * today: cargo 12 Bays on Improved Mining Storage, 16 Bays on Advanced). + * Node states, the LOCKED readout and the RESEARCH button all see both. */ _treeFor(catId) { if (this.systemTree && catId === this.systemTree.id) return this.systemTree; const tree = loadCategory(catId); - if (tree && typeof this.scene?.questClaimed === 'function') { - return attachQuestGates(tree, (qid) => this.scene.questClaimed(qid)); + if (!tree) return tree; + if (this.scene?.buildState && typeof this.scene.buildState.isBuiltAnywhere === 'function') { + attachBuildGates(tree, (bid) => this.scene.buildState.isBuiltAnywhere(bid)); + } + if (typeof this.scene?.questClaimed === 'function') { + attachQuestGates(tree, (qid) => this.scene.questClaimed(qid)); } return tree; }