From f0578f1a3868250db16089ffa572b51dcc432524 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Mon, 14 Sep 2026 15:12:07 -0600 Subject: [PATCH] Gate mining Tier-3 research on Special Delivery quest claim - Add an optional node quest field so research techs can require a named quest's reward to be claimed before becoming available. - Wire the scene's live questClaimed predicate into ResearchModel.attachQuestGates and the research tree loaders so node states, the RESEARCH button, and LOCKED readouts all honor the gate. - Gate mining premium_arm and advanced_storage on the Special Delivery quest, and add data/test coverage for the quest-gate behavior. --- data/research.json | 5 ++-- data/research/mining.json | 4 ++- dev/research-builds.test.mjs | 51 +++++++++++++++++++++++++++++++- js/research/ResearchModel.js | 57 ++++++++++++++++++++++++++++++++++-- js/scenes/GameScene.js | 28 ++++++++++++++++-- js/ui/ResearchWindow.js | 14 +++++++-- 6 files changed, 148 insertions(+), 11 deletions(-) diff --git a/data/research.json b/data/research.json index 2493780..e2de9aa 100644 --- a/data/research.json +++ b/data/research.json @@ -1,5 +1,5 @@ { - "_comment": "RESEARCH — time-based tech, one project at a time (maxConcurrent). The command deck's RESEARCH button opens the full-screen research console (js/ui/ResearchWindow.js). Categories are the tab row; each category's TECH TREE lives in its own file under data/research/ — the file basename is the config section AND the category id (data/research/exploration.json → section 'exploration'). A tree is a branching DAG drawn top→down: `nodes` is a flat map (data order matters — roots/children lay out left-to-right in this order), each node's `requires` names the parents that must be RESEARCHED before it is (the DAG edges — authoritative). Research rules: start an available node → its `duration` (timeUnit) runs; when it completes the node is unlocked, its `effects` apply (the scene applies them — the seam), and its children become available. `unlocks` is what the tech OPENS beyond follow-on tech: `builds` = ids in data/builds.json the tech makes available (the build's own `requires` — \"category/node id\" — is the authoritative gate; the declaration here is what the console's UNLOCKS readout and the test validate), `research` = readable mirror of the children's `requires` edges. `starting` names nodes a fresh run already owns. Keys starting with `_` are documentation, not data.", + "_comment": "RESEARCH — time-based tech, one project at a time (maxConcurrent). The command deck's RESEARCH button opens the full-screen research console (js/ui/ResearchWindow.js). Categories are the tab row; each category's TECH TREE lives in its own file under data/research/ — the file basename is the config section AND the category id (data/research/exploration.json → section 'exploration'). A tree is a branching DAG drawn top→down: `nodes` is a flat map (data order matters — roots/children lay out left-to-right in this order), each node's `requires` names the parents that must be RESEARCHED before it is (the DAG edges — authoritative). Research rules: start an available node → its `duration` (timeUnit) runs; when it completes the node is unlocked, its `effects` apply (the scene applies them — the seam), and its children become available. `unlocks` is what the tech OPENS beyond follow-on tech: `builds` = ids in data/builds.json the tech makes available (the build's own `requires` — \"category/node id\" — is the authoritative gate; the declaration here is what the console's UNLOCKS readout and the test validate), `research` = readable mirror of the children's `requires` edges. `starting` names nodes a fresh run already owns. A node may additionally carry `quest` (see _questGate) — an optional STORY-QUEST reward-claim gate layered on `requires`. Keys starting with `_` are documentation, not data.", "enabled": true, "timeUnit": "seconds", "maxConcurrent": 1, @@ -41,5 +41,6 @@ "research": ["child-tech-id"] }, "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)." } diff --git a/data/research/mining.json b/data/research/mining.json index 8f97f3a..d1716fa 100644 --- a/data/research/mining.json +++ b/data/research/mining.json @@ -1,5 +1,5 @@ { - "_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).", + "_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). On top of the DAG, BOTH Tier-3 techs carry `quest: \"special_delivery\"` — the STORY-QUEST gate (data/quests.json → special_delivery, 'Special Delivery'): the quest's reward must be CLAIMED (the dossier's CLAIM action — the same paid-out witness the run's chain uses to grant the next quest) before either is researchable. The field is optional on a node and the model stays pure: the scene wires the live predicate into the tree's available/lockNote hooks (js/research/ResearchModel.js → attachQuestGates; isAvailable consults it like any extra gate, and the console's LOCKED readout shows \"SPECIAL DELIVERY REQUIRED\" via lockNote). 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": { @@ -26,6 +26,7 @@ "icon": "mining2", "duration": 60, "requires": ["advanced_arm"], + "quest": "special_delivery", "unlocks": { "builds": ["mining-arm-premium"], "research": [] }, "effects": {} }, @@ -44,6 +45,7 @@ "icon": "storage", "duration": 60, "requires": ["improved_storage"], + "quest": "special_delivery", "unlocks": { "builds": ["mining-storage-advanced"], "research": [] }, "effects": {} } diff --git a/dev/research-builds.test.mjs b/dev/research-builds.test.mjs index b4402f2..52d7858 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 quests = read('quests.json'); const builds = read('builds.json'); const actionbar = read('actionbar.json'); @@ -159,17 +160,31 @@ check('mining: improved_storage declares its build (mining-storage-improved)', J 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)); +// The STORY-QUEST gate (node.quest — data/quests.json): both Tier-3 techs +// wait on the 'Special Delivery' quest's reward being CLAIMED (the scene +// wires the live predicate via attachQuestGates — the behavior is pinned +// below in 4b and the quest's well-formedness in dev/quests.test.mjs). +check('mining: premium_arm + advanced_storage gate on the special_delivery quest', + mnodes.premium_arm?.quest === 'special_delivery' && mnodes.advanced_storage?.quest === 'special_delivery'); +check('mining: the Tier-1 + Tier-2 techs carry no quest gate', + !('quest' in (mnodes.improved_arm ?? {})) && !('quest' in (mnodes.advanced_arm ?? {})) && !('quest' in (mnodes.improved_storage ?? {}))); + // ---------------------------------------------------------------------- // 4. The REAL code path — ResearchModel + ResearchState (pure modules) // ---------------------------------------------------------------------- const { config } = await import('../js/config/Config.js'); -config.init({ research, exploration, mining, builds, actionbar }); +config.init({ research, exploration, mining, 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'); check('model: categories() resolves the registry', categories().some((c) => c.id === 'exploration')); const tree = loadCategory('exploration'); check('model: loadCategory returns the exploration tree', !!tree && tree.nodes.tether_l1 && tree.order.length === Object.keys(nodes).length); +check('model: the quest gate names a DEFINED quest (a typo would be a gate that never lifts)', + ['premium_arm', 'advanced_storage'].every((id) => mnodes[id]?.quest === 'special_delivery') && questDef('special_delivery') !== null); +check('model: the gated quest is the THIRD quest in the run (Special Delivery, main story)', + (() => { const q = questDef('special_delivery'); return q?.category === 'main' && (quests.quests ?? []).indexOf(q) === 2; })()); check('model: issues(tree) is clean', Array.isArray(issues(tree)) && issues(tree).length === 0); // the UNLOCKS space — research declares what it opens (builds + follow-on @@ -245,6 +260,40 @@ 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')); +// The STORY-QUEST gate — the REAL wiring (attachQuestGates; the scene's +// _treeFor does this with its live quest check): with 'Special Delivery' +// NOT CLAIMED the Tier-3 techs stay locked even with every parent +// researched; CLAIMED, they open. The ungated techs are untouched. +const { attachQuestGates } = await import('../js/research/ResearchModel.js'); +{ + const gateNo = attachQuestGates(loadCategory('mining'), (qid) => qid !== 'special_delivery'); + check('quest-gate: NOT CLAIMED — premium_arm + advanced_storage stay locked (all parents researched)', + !isAvailable(gateNo, mstate, 'premium_arm') && !isAvailable(gateNo, mstate, 'advanced_storage')); + check('quest-gate: NOT CLAIMED — the Tier-2 techs are untouched by the wiring (fresh state, only Tier-1 researched)', + (() => { + const st = new ResearchState(); + st.unlock('mining', 'improved_arm'); + return isAvailable(gateNo, st, 'advanced_arm') && isAvailable(gateNo, st, 'improved_storage') && + !isAvailable(gateNo, st, 'premium_arm') && !isAvailable(gateNo, st, 'advanced_storage'); + })()); + check('quest-gate: NOT CLAIMED — lockNote reads the quest title (the console LOCKED line)', + gateNo.lockNote(mstate, 'premium_arm') === 'SPECIAL DELIVERY REQUIRED' && + gateNo.lockNote(mstate, 'advanced_storage') === 'SPECIAL DELIVERY REQUIRED' && + gateNo.lockNote(mstate, 'advanced_arm') === null); + const gateYes = attachQuestGates(loadCategory('mining'), (qid) => qid === 'special_delivery'); + check('quest-gate: CLAIMED — premium_arm + advanced_storage open (all parents researched)', + isAvailable(gateYes, mstate, 'premium_arm') && isAvailable(gateYes, mstate, 'advanced_storage')); + check('quest-gate: a tree with no gated nodes is returned untouched (no hooks added)', + (() => { const t = loadCategory('exploration'); const r = attachQuestGates(t, () => false); return r === t && typeof t.available !== 'function'; })()); + check('quest-gate: wiring composes over a pre-existing available hook (the SYSTEM seam)', + (() => { + const t = loadCategory('exploration'); + t.available = (s, id) => id !== 'tether_l2'; // a pre-existing gate + attachQuestGates(t, () => false); + return t.available({}, 'tether_l2') === false && t.available({}, 'tether_l3') === true; + })()); +} + // ---------------------------------------------------------------------- // 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 f43b974..43c0ad9 100644 --- a/js/research/ResearchModel.js +++ b/js/research/ResearchModel.js @@ -7,6 +7,7 @@ * Tree contract (one JSON per category, section named after the category id): * starting: ["root-id"] already-owned nodes (fresh run) * nodes: { id: { label, description, icon, duration, requires: [ids], + * quest?: , * 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 @@ -16,8 +17,16 @@ * object may carry `available(state, id)` (extra availability gate, * consulted by isAvailable) and `lockNote(state, id)` (the window's * LOCKED reason when the node is gated but its requires are met). + * + * Node's optional `quest` field (data/quests.json id): a STORY-QUEST gate on + * top of `requires` — the quest's reward must be CLAIMED before the node is + * 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'). */ import { config } from '../config/Config.js'; +import { questDef } from '../quests/QuestState.js'; /** The category registry (data/research.json → categories[]). */ export function categories() { @@ -170,8 +179,9 @@ export function layoutTree(tree) { * * A tree may also carry an optional `available(state, id)` predicate — * an EXTRA availability gate evaluated after the requires check (the - * SYSTEM category uses it for its chart-completion gate; static trees - * have none). Pure: the predicate is data the caller supplies. + * SYSTEM category uses it for its chart-completion gate; attachQuestGates + * uses it for a node's story-quest gate; static trees have none). + * Pure: the predicate is data the caller supplies. */ export function isAvailable(tree, state, id) { const n = tree.nodes[id]; @@ -182,6 +192,49 @@ export function isAvailable(tree, state, id) { return true; } +/** + * Wire a node's STORY-QUEST gate (optional `quest` field, a data/quests.json + * id) into the tree's available/lockNote hooks — the same seam the + * SYSTEM category's chart gate uses (isAvailable consults `available`; the + * console's LOCKED readout consults `lockNote`). The model stays pure: + * `isQuestClaimed(questId)` is the caller's LIVE predicate (the scene reads + * its quest ledger — the ledger's claimed/paid-out set); 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 `quest` is unavailable while + * !isQuestClaimed(quest) — regardless of its `requires` — and its lock + * reason reads " REQUIRED" (the quest id, upper-cased, when + * the title is unknown). Claim (reward collected) is the bar — not + * checklist completion alone; the tech opens on the CLAIM press. + * Idempotent: an already-wired hook composes (the quest gate is checked + * first). Returns the same tree (mutated) or null for no tree. + */ +export function attachQuestGates(tree, isQuestClaimed) { + if (!tree || typeof tree !== 'object' || !Array.isArray(tree.order)) return tree; + const questOf = (id) => { + const q = tree.nodes[id]?.quest; + return typeof q === 'string' && q !== '' ? q : null; + }; + if (typeof isQuestClaimed !== 'function' || !tree.order.some((id) => questOf(id))) return tree; + const prevAvailable = tree.available; + const prevNote = tree.lockNote; + tree.available = (state, id) => { + const q = questOf(id); + if (q !== null && !isQuestClaimed(q)) return false; + return typeof prevAvailable === 'function' ? prevAvailable(state, id) : true; + }; + tree.lockNote = (state, id) => { + const q = questOf(id); + if (q !== null && !isQuestClaimed(q)) { + const title = questDef(q)?.title ?? q; + return `${String(title).toUpperCase()} 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 3103e40..0dc28fa 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -57,7 +57,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 } from '../research/ResearchModel.js'; +import { categories, loadCategory, isAvailable, hasAvailable, buildDefs, attachQuestGates } from '../research/ResearchModel.js'; import { SYSTEM_CATEGORY, MAP_NODE, @@ -3706,6 +3706,22 @@ export class GameScene extends Phaser.Scene { // ------------------------------------------------------------ quests + /** + * Has the named quest's reward been CLAIMED — the ledger's paid-out set + * (the same witness the run's chain uses to grant the NEXT quest, + * QuestState.grantUnlocked)? The research trees' STORY-QUEST gate + * (data/research/.json → node. `quest`; today mining/premium_arm + + * mining/advanced_storage wait on 'special_delivery') asks this through + * attachQuestGates' live predicate. Claim (reward collected) is the bar + * — not checklist completion alone: the tech opens the moment the player + * presses CLAIM on the dossier and stays closed until then. + * @param {string} id a quest id (data/quests.json → quests[].id) + * @returns {boolean} + */ + questClaimed(id) { + return !!this.questState && this.questState.isClaimed(id); + } + /** * The live dossier snapshot (QuestWindow polls it while open): * the categories (tabs) + every HELD quest with its checklist @@ -4888,10 +4904,16 @@ 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). */ + * 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. */ _treeFor(catId) { if (this.systemTree && catId === this.systemTree.id) return this.systemTree; - return loadCategory(catId); + const tree = loadCategory(catId); + return tree ? attachQuestGates(tree, (qid) => this.questClaimed(qid)) : tree; } /** diff --git a/js/ui/ResearchWindow.js b/js/ui/ResearchWindow.js index 9963d78..7df8231 100644 --- a/js/ui/ResearchWindow.js +++ b/js/ui/ResearchWindow.js @@ -50,6 +50,7 @@ import { missingRequires, unlocksOf, buildDefs, + attachQuestGates, } from '../research/ResearchModel.js'; import { gatesReady } from '../research/SystemCategory.js'; import { shimmerPhase, drawShimmer } from './ShimmerCue.js'; @@ -744,10 +745,19 @@ 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. */ + * 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. */ _treeFor(catId) { if (this.systemTree && catId === this.systemTree.id) return this.systemTree; - return loadCategory(catId); + const tree = loadCategory(catId); + if (tree && typeof this.scene?.questClaimed === 'function') { + return attachQuestGates(tree, (qid) => this.scene.questClaimed(qid)); + } + return tree; } _buildTrees() {