Add Cargo research category with quest and build-completion gates

- 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
This commit is contained in:
Brian Fertig 2026-09-14 19:13:56 -06:00
parent 8810ec1b3d
commit 138096cd2b
8 changed files with 222 additions and 21 deletions

View File

@ -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": {

View File

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

View File

@ -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/<id>.json — its tree is built at runtime per situation (js/research/SystemCategory.js: one tech pair per solar system, named after the system the player is in — its Map tech is granted on entry, its jumpgate tech researchable once every NAV point — planets, space stations, jump gates — is discovered, and activation flips the gates on, here and in the linked systems' return gates). Static categories keep the one-file-per-category rule; the tests exempt dynamic ones from the tree-file check.",
@ -42,5 +43,6 @@
},
"effects": { "tether": { "level": 2 } }
},
"_questGate": "Optional node field — `quest: <quest id>`: 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: <quest id>`: 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: <build id>`: 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 LABEL> 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)."
}

36
data/research/cargo.json Normal file
View File

@ -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/<node id>\"]` 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": {}
}
}
}

View File

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

View File

@ -8,6 +8,7 @@
* starting: ["root-id"] already-owned nodes (fresh run)
* nodes: { id: { label, description, icon, duration, requires: [ids],
* quest?: <quest id>,
* build?: <build id>,
* 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 LABEL> 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

View File

@ -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/<id>.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));
}
/**

View File

@ -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/<id>.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;
}