From 70f4954c69ed641b4b9467d4f4fced7075ddb511 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 5 Sep 2026 15:15:08 -0600 Subject: [PATCH] =?UTF-8?q?Wire=20research=E2=86=92build=20unlocks=20and?= =?UTF-8?q?=20add=20first=20tether-l2=20build=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Define the two-sided unlock contract: research nodes declare `unlocks.builds`/`unlocks.research`, builds declare `requires` as "category/node id" (authoritative gate); ResearchModel exposes `unlocksOf`, `buildDefs`, `unlockIssues`, `buildIssues` - Add `tether-l2` build entry (planet/station targets, credits+minerals cost, level-2 tether effect) and link it from `exploration/tether_l2` - Show an UNLOCKS line in the research dossier so the console surfaces what a tech opens - Extend dev/research-builds.test.mjs to validate both sides of the gate and the new model helpers - Update data/builds.json, research.json, exploration.json `_comment`s and PROJECT_NOTES to document the contract --- data/builds.json | 27 +++++++++-- data/research.json | 7 ++- data/research/exploration.json | 6 +-- dev/research-builds.test.mjs | 21 ++++++++- docs/PROJECT_NOTES.md | 46 +++++++++++++++--- js/research/ResearchModel.js | 86 ++++++++++++++++++++++++++++++++++ js/ui/ResearchWindow.js | 26 +++++++++- 7 files changed, 200 insertions(+), 19 deletions(-) diff --git a/data/builds.json b/data/builds.json index 094a7ec..1d4556a 100644 --- a/data/builds.json +++ b/data/builds.json @@ -1,5 +1,5 @@ { - "_comment": "BUILDABLE ITEMS — cost-based improvements. A build costs the `cost` below (in `resources`) and takes effect when purchased; it is only AVAILABLE once the research it `requires` is complete (data/research.json → `projects.`). `category` says where it applies: `ship` = improve the player's ship, `planet` = improve things on planets, `station` = improve space stations, `general` = new capabilities for the player. `repeatable` builds can be bought more than once (treat as false when absent). Add a build = one entry under `builds` (the key is the build's id). Keys starting with `_` (like `_template`) are documentation, not data. The build state/UI is not implemented yet — this file is the data layer that will drive it.", + "_comment": "BUILDABLE ITEMS — cost-based improvements. A build costs the `cost` below (in `resources`) and takes effect when purchased; it is AVAILABLE once every research id in its `requires` is complete — ids are \"/\" pointing at data/research/.json → nodes (e.g. \"exploration/tether_l2\"). The research side declares the same relationship in its node's `unlocks.builds`; dev/research-builds.test.mjs keeps the two in lock-step. `category` says where it applies: `ship` = improve the player's ship, `planet` = improve things on planets, `station` = improve space stations, `general` = new capabilities for the player; `targets` (optional) lists the surfaces a build can be placed on (defaults to `[category]` when absent). `repeatable` builds can be bought more than once (treat as false when absent). Add a build = one entry under `builds` (the key is the build's id). Keys starting with `_` (like `_template`) are documentation, not data. The build state/UI is not implemented yet — this file is the data layer that will drive it.", "resources": { "credits": { "label": "Credits", @@ -10,16 +10,37 @@ "description": "Raw materials — mined from resource worlds." } }, - "builds": {}, + "builds": { + "tether-l2": { + "_comment": "Placeholder entry — proves the research→build unlock wiring (exploration/tether_l2 `unlocks.builds` ↔ this `requires`). Cost/effect are first guesses: tune when the build panel lands.", + "label": "Tether Ring · Level 2", + "description": "Anchor a level-2 tether ring on a planet or space station you hold. Its 6,400 m field stitches into your home tether — open space where there was a wall.", + "category": "general", + "targets": ["planet", "station"], + "cost": { + "credits": 400, + "minerals": 60 + }, + "requires": ["exploration/tether_l2"], + "repeatable": false, + "effects": { + "tether": { "level": 2, "anchor": "target" } + }, + "theme": { + "color": "#00e5ff" + } + } + }, "_template": { "label": "Buildable Item", "description": "What this is and what it does.", "category": "ship", + "targets": ["ship"], "cost": { "credits": 250, "minerals": 40 }, - "requires": [], + "requires": ["exploration/"], "repeatable": false, "effects": {}, "theme": { diff --git a/data/research.json b/data/research.json index 1d66c24..aba1243 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. 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 the documented forward reference (builds.json ids + follow-on research) for the build system that comes later. `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. Keys starting with `_` are documentation, not data.", "enabled": true, "timeUnit": "seconds", "maxConcurrent": 1, @@ -23,7 +23,10 @@ "icon": "tether", "duration": 120, "requires": ["parent-tech-id"], - "unlocks": { "builds": [], "research": [] }, + "unlocks": { + "builds": ["build-id-in-builds-json"], + "research": ["child-tech-id"] + }, "effects": { "tether": { "level": 2 } } } } diff --git a/data/research/exploration.json b/data/research/exploration.json index 21bd410..ba1c0b0 100644 --- a/data/research/exploration.json +++ b/data/research/exploration.json @@ -1,5 +1,5 @@ { - "_comment": "EXPLORATION — the player's reach. Tether tech raises the home tether's level (effects.tether.level → TetherField.setLevel; radius = 5,120 × 1.25^(level−1), see data/tether.json and docs/PROJECT_NOTES.md). Tether Anchoring and Signal Amplification grant capability flags (seam) that future mechanics read. `starting` = already researched on a fresh run, so its children are available the first time the console opens. duration is in research.timeUnit (seconds); 0 = granted, never researched.", + "_comment": "EXPLORATION — the player's reach. Tether tech raises the home tether's level (effects.tether.level → TetherField.setLevel; radius = 5,120 × 1.25^(level−1), see data/tether.json and docs/PROJECT_NOTES.md). Tether Anchoring and Signal Amplification grant capability flags (seam) that future mechanics read. Node contract: `requires` = parents that must be RESEARCHED first (the DAG edges — authoritative); `unlocks` = what completing the tech opens: `builds` (ids in data/builds.json the tech makes available — the build's own `requires` is the authoritative gate, this list is the declaration, and the test keeps the two in lock-step) and `research` (a readable mirror of the children's `requires` edges); `effects` = what the scene applies on completion. `starting` = already researched on a fresh run, so its children are available the first time the console opens. duration is in research.timeUnit (seconds); 0 = granted, never researched.", "starting": ["tether_l1"], "nodes": { "tether_l1": { @@ -13,11 +13,11 @@ }, "tether_l2": { "label": "Tether Level 2", - "description": "A second stage of field power. The boundary ring pulls out to 6,400 m — the far reaches of a compact system drop inside your reach, and the keep-out wall moves with you.", + "description": "A second stage of field power. The boundary ring pulls out to 6,400 m — the far reaches of a compact system drop inside your reach, and the keep-out wall moves with you. Also unlocks anchoring level-2 tether rings on worlds and stations you hold (build: tether-l2).", "icon": "tether", "duration": 60, "requires": ["tether_l1"], - "unlocks": { "builds": [], "research": ["tether_l3", "tether_anchors"] }, + "unlocks": { "builds": ["tether-l2"], "research": ["tether_l3", "tether_anchors"] }, "effects": { "tether": { "level": 2 } } }, "tether_l3": { diff --git a/dev/research-builds.test.mjs b/dev/research-builds.test.mjs index 9ab1db4..f75b40f 100644 --- a/dev/research-builds.test.mjs +++ b/dev/research-builds.test.mjs @@ -136,6 +136,15 @@ 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: issues(tree) is clean', Array.isArray(issues(tree)) && issues(tree).length === 0); +// the UNLOCKS space — research declares what it opens (builds + follow-on +// research); the build side is the authoritative gate; the test locks the +// two declarations together. +const { unlocksOf, buildDefs, unlockIssues, buildIssues } = await import('../js/research/ResearchModel.js'); +check('model: unlocksOf normalizes {builds, research}', JSON.stringify(unlocksOf(tree, 'tether_l2')) === JSON.stringify({ builds: ['tether-l2'], research: ['tether_l3', 'tether_anchors'] })); +check('model: unlocksOf is empty for a node without unlocks', JSON.stringify(unlocksOf(tree, 'tether_l4')) === JSON.stringify({ builds: [], research: [] })); +check('model: unlockIssues(tree) is clean (mirrors + build wiring agree)', unlockIssues(tree).length === 0); +check('model: buildIssues() is clean (every build require resolves)', buildIssues().length === 0); + const layout = layoutTree(tree); check('model: layout rows = 4 (l1 → l2/anchor/signal → l3 → l4)', layout.rows === 4); check('model: layout levels — l1 root, l2/signal tier 1, l3/anchors tier 2, l4 tier 3', @@ -179,10 +188,18 @@ check('state: an old save without research restores as a fresh start', (() => { check('builds: credits resource defined', typeof builds.resources?.credits?.label === 'string'); check('builds: minerals resource defined', typeof builds.resources?.minerals?.label === 'string'); check('builds: builds is a map', !!builds.builds && typeof builds.builds === 'object' && !Array.isArray(builds.builds)); -check('builds: no builds yet (empty map)', Object.keys(builds.builds ?? {}).filter((k) => !k.startsWith('_')).length === 0); + +// the tether-l2 build — the first research→build unlock, wired both ways +const tl2 = builds.builds['tether-l2'] ?? {}; +check('builds: tether-l2 entry exists (the level-2 tether build)', !!tl2); +check('builds: tether-l2 is available on a planet OR a station', JSON.stringify(tl2.targets ?? []) === JSON.stringify(['planet', 'station'])); +check('builds: tether-l2 is gated on exploration/tether_l2 (authoritative side)', JSON.stringify(tl2.requires ?? []) === JSON.stringify(['exploration/tether_l2'])); +check('builds: tether-l2 is a one-off with a credits+minerals cost', tl2.repeatable === false && typeof tl2.cost?.credits === 'number' && typeof tl2.cost?.minerals === 'number'); +check('builds: tether_l2 declares the build in its unlocks (declaration side)', (nodes.tether_l2?.unlocks?.builds ?? []).includes('tether-l2')); +check('builds: tether-l2 effect raises a level-2 tether on the target', tl2.effects?.tether?.level === 2 && tl2.effects?.tether?.anchor === 'target'); const bt = builds._template ?? {}; -for (const k of ['label', 'description', 'category', 'cost', 'requires', 'repeatable', 'effects', 'theme']) { +for (const k of ['label', 'description', 'category', 'targets', 'cost', 'requires', 'repeatable', 'effects', 'theme']) { check(`builds._template documents "${k}"`, k in bt); } check('builds._template.category is a known kind', ['ship', 'planet', 'station', 'general'].includes(bt.category)); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 02bdc62..40dac57 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -275,17 +275,45 @@ only present when the tech is researchable and nothing is in progress. label, icon, accent), the `video` (file + aspect), and `fx` timing. - `data/research/.json` — the tree: a flat `nodes` map where each node has `label`, `description`, `duration` (in `timeUnit`), `requires` - (parent ids — a DAG), and optional `effects`. `starting` lists the - pre-unlocked roots. Section name = file basename + (parent ids — a DAG), `unlocks` (what the tech OPENS, below), and + optional `effects`. `starting` lists the pre-unlocked roots. Section name + = file basename (`research/exploration.json` → `config.section('exploration')`). **Add a category = one file + one line in `research.json → categories` + one line in `data/manifest.json`.** +**The UNLOCKS space (research → everything else):** a tech unlocks more +than follow-on tech. Each node's `unlocks` is the declaration side: +- `unlocks.research` — the readable mirror of the children's `requires` + edges (the DAG itself stays authoritative — `requires` only). +- `unlocks.builds` — ids in `data/builds.json` the tech makes available. + The **authoritative gate is the build's own `requires`** (a list of + `"/"` research ids — e.g. + `tether-l2.requires: ["exploration/tether_l2"]`): a build is available + once every id it names is researched. `dev/research-builds.test.mjs` + keeps both sides in lock-step (the test fails if one names the other and + the other doesn't name back, or an id dangles). +- Builds also carry `category` (ship / planet / station / general), + optional `targets` (the surfaces it can be placed on — defaults to + `[category]`; the tether ring targets `planet` + `station`), `cost` + (credits + minerals), `repeatable`, `effects`, `theme`. +- The first build entry, `tether-l2` (level-2 tether ring, anchored on a + world or station), is the placeholder proving the wiring — cost/effect + are first guesses; tune them when the build panel lands. +- The console's dossier shows the line (`UNLOCKS: TETHER LEVEL 3 · TETHER + ANCHORING · BUILD · TETHER RING · LEVEL 2`), read through + `ResearchModel.unlocksOf(tree, id)` + `buildDefs()` — the one read + point the build UI will use too. + **Code layering (same rules as the tether):** - `js/research/ResearchModel.js` — PURE (no Phaser): `roots`, `issues` (DAG validation), `levels` (longest-path level), `layoutTree` (deterministic column/row layout: DFS leaf-slot assignment, parent = - mean of children), `isAvailable`, `missingRequires`. Node-tested. + mean of children), `isAvailable`, `missingRequires`, and the unlocks + contract: `unlocksOf` (normalize `{builds, research}`), `buildDefs` + (builds.json → map, `_`-keys excluded), `unlockIssues(tree)` (mirror + + build wiring), `buildIssues()` (every build `requires` id resolves). + Node-tested. - `js/research/ResearchState.js` — PURE: `unlock`, `isUnlocked`, `getActive`, `start`, `progress(time)`, `tick(time)` (→ array of completions), `restoreActive`, `toJSON(now)`/`fromJSON`. Node-tested. @@ -318,9 +346,11 @@ keys). Open/close → `ui_window`/`ui_close`. **Verified:** `dev/research-builds.test.mjs` (manifest registration, research.json globals, the exploration tree's raw-JSON contract — DAG, node fields, effects — the real code path: ResearchModel layout/levels/determinism -+ ResearchState start/tick/complete/restore round-trip, builds.json, -actionbar.json). `dev/research-shot.html` + `dev/cdp-shot.mjs` open the -window and start a run through CDP for a screenshot. ++ the unlocks contract (unlocksOf/unlockIssues/buildIssues) + +ResearchState start/tick/complete/restore round-trip, builds.json (the +tether-l2 build, both sides of the gate, the template), actionbar.json). +`dev/research-shot.html` + `dev/cdp-shot.mjs` open the window and start a +run through CDP for a screenshot. ## Reputation — standing on planets & space stations (data layer; factions later) @@ -545,7 +575,9 @@ The player holds a REPUTATION (standing) on each planet and space station: - [ ] Tether progression (research side is done; the build side is next): anchor tethers on planets/stations and upgrade levels beyond the Exploration tree (the `add`/`setLevel`/`onChange` seams are in place; - the costs + build panel come next) + the build entry `tether-l2` + the research→build unlock wiring exist + in `data/builds.json` — the costs panel and the apply-effect seam + come next) - [ ] Factions & pirates: claim settlements (`owner`), flags, borders, and the player's place in a populated galaxy (the reputation layer already resolves standing through `owner` — `Reputation. diff --git a/js/research/ResearchModel.js b/js/research/ResearchModel.js index 71eaa2d..6cf0a96 100644 --- a/js/research/ResearchModel.js +++ b/js/research/ResearchModel.js @@ -177,3 +177,89 @@ export function missingRequires(tree, state, id) { (r) => !tree.nodes[r] || !state.isUnlocked(tree.id, r), ); } + +/** + * What completing this tech OPENS, normalized (both arrays always present, + * strings only). `unlocks` is optional on a node; absent = unlocks nothing. + * One read point for the console's UNLOCKS line and the build system: + * builds → ids in data/builds.json the tech makes available + * (the build's own `requires` is the authoritative gate) + * research → readable mirror of the children's `requires` edges + */ +export function unlocksOf(tree, id) { + const u = tree.nodes[id]?.unlocks ?? {}; + const str = (a) => (Array.isArray(a) ? a.filter((x) => typeof x === 'string') : []); + return { builds: str(u.builds), research: str(u.research) }; +} + +/** Map of build id → build definition (data/builds.json → builds, `_`-keys excluded). */ +export function buildDefs() { + const raw = config.get('builds.builds', {}) ?? {}; + const out = {}; + for (const [k, v] of Object.entries(raw)) { + if (!k.startsWith('_') && v && typeof v === 'object') out[k] = v; + } + return out; +} + +/** + * Cross-file unlock contract for one tree (empty array = clean): + * - unlocks.research entries name real nodes that require THIS node + * (the mirror of the DAG's child edges — kept honest by the test), + * - unlocks.builds entries exist in data/builds.json AND that build + * names this node ("/", bare id tolerated) in its `requires`. + */ +export function unlockIssues(tree) { + const out = []; + const defs = buildDefs(); + for (const id of tree.order) { + const { builds: ub, research: ur } = unlocksOf(tree, id); + for (const r of ur) { + if (!tree.nodes[r]) out.push(`${id} unlocks unknown research node "${r}"`); + else if (!(tree.nodes[r].requires ?? []).includes(id)) + out.push(`${id}.unlocks.research lists "${r}", but ${r}.requires does not name ${id}`); + } + for (const b of ub) { + const def = defs[b]; + if (!def) { + out.push(`${id} unlocks unknown build "${b}" (data/builds.json → builds)`); + continue; + } + const req = Array.isArray(def.requires) ? def.requires : []; + if (!req.includes(`${tree.id}/${id}`) && !req.includes(id)) + out.push(`build "${b}" does not require ${tree.id}/${id} (its requires: [${req.join(', ')}])`); + } + } + return out; +} + +/** + * Builds → research direction (empty array = clean): every entry of a + * build's `requires` must be a well-formed "category/node id" that + * resolves to a real node in a registered category's tree. + */ +export function buildIssues() { + const out = []; + for (const [bid, def] of Object.entries(buildDefs())) { + const req = def.requires ?? []; + if (!Array.isArray(req)) { + out.push(`build "${bid}".requires must be an array of "category/node id"`); + continue; + } + for (const rid of req) { + const m = typeof rid === 'string' ? rid.split('/') : null; + if (!m || m.length !== 2 || !m[0] || !m[1]) { + out.push(`build "${bid}" requires malformed research id "${rid}" (want "category/node id")`); + continue; + } + const catId = m[0]; + if (!categories().some((c) => c.id === catId)) { + out.push(`build "${bid}" requires unknown category "${catId}"`); + continue; + } + const t = loadCategory(catId); + if (!t || !t.nodes[m[1]]) out.push(`build "${bid}" requires unknown node "${m[1]}" in ${catId}`); + } + } + return out; +} diff --git a/js/ui/ResearchWindow.js b/js/ui/ResearchWindow.js index 5f675bc..03508da 100644 --- a/js/ui/ResearchWindow.js +++ b/js/ui/ResearchWindow.js @@ -47,6 +47,8 @@ import { layoutTree, isAvailable, missingRequires, + unlocksOf, + buildDefs, } from '../research/ResearchModel.js'; import { ensureIcon } from '../research/ResearchIcons.js'; @@ -824,15 +826,24 @@ export class ResearchWindow extends Phaser.GameObjects.Container { }) .setScrollFactor(0); cont.add(this.dMeta); + this.dUnlocks = s + .text(tx, detailY + 58, '', { + fontFamily: BODY, + fontSize: '9px', + color: toCss(C.faint), + letterSpacing: 1.5, + }) + .setScrollFactor(0); + cont.add(this.dUnlocks); const btnW = 152; const wrapW = Math.max(120, rightX + rightW - 12 - btnW - 18 - tx); this.dDesc = s - .text(tx, detailY + 64, '', { + .text(tx, detailY + 76, '', { fontFamily: BODY, fontSize: '12px', color: toCss(C.dim), letterSpacing: 0.4, - lineSpacing: 5, + lineSpacing: 4, wordWrap: { width: wrapW }, }) .setScrollFactor(0); @@ -1142,6 +1153,7 @@ export class ResearchWindow extends Phaser.GameObjects.Container { this.dIcon.setVisible(false); this.dLabel.setText(''); this.dMeta.setText(''); + this.dUnlocks.setText(''); this.dDesc.setText(''); this._paintDetailBtn(); return; @@ -1196,6 +1208,16 @@ export class ResearchWindow extends Phaser.GameObjects.Container { } this.dMeta.setText(meta); this.dMeta.setColor(toCss(metaColor)); + + // unlocks line: what completing this tech OPENS — follow-on research + // (mirror of the DAG edges) and builds (data/builds.json, gated by the + // build's own `requires`; this is the declaration side). + const { builds: ub, research: ur } = unlocksOf(entry.tree, sel.id); + const defs = buildDefs(); + const parts = []; + for (const r of ur) parts.push(String(entry.tree.nodes[r]?.label ?? r).toUpperCase()); + for (const b of ub) parts.push(`BUILD · ${String(defs[b]?.label ?? b).toUpperCase()}`); + this.dUnlocks.setText(`UNLOCKS: ${parts.length ? parts.join(' · ') : '—'}`); this._paintDetailBtn(); }