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.
This commit is contained in:
Brian Fertig 2026-09-14 15:12:07 -06:00
parent 30b62fc57f
commit f0578f1a38
6 changed files with 148 additions and 11 deletions

View File

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

View File

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

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

View File

@ -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?: <quest 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
@ -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 "<QUEST TITLE> 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

View File

@ -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/<id>.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/<id>.json — null if missing). */
* 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. */
_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;
}
/**

View File

@ -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/<id>.json. */
* 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. */
_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() {