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:
parent
30b62fc57f
commit
f0578f1a38
|
|
@ -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)."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue