Changes to Research Areas

This commit is contained in:
Brian Fertig 2026-09-07 23:10:47 -06:00
parent 1c5767b095
commit b1ae21e95e
9 changed files with 21 additions and 41 deletions

View File

@ -119,8 +119,8 @@ node dev/server.mjs 8080
`maxConcurrent: 1`). A deck progress bar tracks the run; completion fires
the tech's `effects` (e.g. `tether: {level: N}` grows the home tether's
range) and a toast. Each category's tree is its own JSON file under
`data/research/` (**Exploration**: Tether Level 13, Tether Anchoring,
Signal Amplification; **Mining**: Improved Mining Arm, Advanced Mining
`data/research/` (**Exploration**: Tether Level 13;
**Mining**: Improved Mining Arm, Advanced Mining
Arm, Improved Mining Storage — all three unlock the matching build in
the console's MINING tab). Unlocks and in-progress work
persist in the save bank — and resume correctly on load

View File

@ -31,7 +31,7 @@
"smallBox": 26,
"smallHitSlack": 16,
"expandDelay": 300,
"collapseDelay": 400
"collapseDelay": 2000
}
}
}

View File

@ -1,5 +1,5 @@
{
"_comment": "EXPLORATION — the player's reach. Tether tech is the BLUEPRINT only (effects: {}) — the player installs it as a build on a world (data/builds.json, the build carries the effect): Tether Level 2 → a TETHER - LEVEL 2 build on a world holding a level-1 tether (→ tether-l2), Tether Level 3 → a TETHER - LEVEL 3 build on a world holding a level-2 tether (→ tether-l3). Radius = 5,120 × 2^(level1) (data/tether.json). 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.",
"_comment": "EXPLORATION — the player's reach. Tether tech is the BLUEPRINT only (effects: {}) — the player installs it as a build on a world (data/builds.json, the build carries the effect): Tether Level 2 → a TETHER - LEVEL 2 build on a world holding a level-1 tether (→ tether-l2), Tether Level 3 → a TETHER - LEVEL 3 build on a world holding a level-2 tether (→ tether-l3). Radius = 5,120 × 2^(level1) (data/tether.json). 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": {
@ -8,7 +8,7 @@
"icon": "tether",
"duration": 0,
"requires": [],
"unlocks": { "builds": [], "research": ["tether_l2", "signal_amp"] },
"unlocks": { "builds": [], "research": ["tether_l2"] },
"effects": {}
},
"tether_l2": {
@ -17,7 +17,7 @@
"icon": "tether",
"duration": 60,
"requires": ["tether_l1"],
"unlocks": { "builds": ["tether-l2"], "research": ["tether_l3", "tether_anchors"] },
"unlocks": { "builds": ["tether-l2"], "research": ["tether_l3"] },
"effects": {}
},
"tether_l3": {
@ -28,24 +28,6 @@
"requires": ["tether_l2"],
"unlocks": { "builds": ["tether-l3"], "research": [] },
"effects": {}
},
"tether_anchors": {
"label": "Tether Anchoring",
"description": "Tether relays that bolt to worlds and stations you hold. Their zones stitch into your own — the union of every ring you anchor becomes open space, with no wall between them.",
"icon": "anchor",
"duration": 120,
"requires": ["tether_l2"],
"unlocks": { "builds": [], "research": [] },
"effects": { "capability": "tether-anchors" }
},
"signal_amp": {
"label": "Signal Amplification",
"description": "Booster coils for the ship's long-range array. Faint emissions — distant beacons, far stations, half-dead transponders — resolve at range, and scans return cleaner hits.",
"icon": "signal",
"duration": 60,
"requires": ["tether_l1"],
"unlocks": { "builds": [], "research": [] },
"effects": { "capability": "signal-amplification" }
}
}
}

View File

@ -156,8 +156,8 @@ const run = async () => {
scene.compass.onChipOut(e); // the chip's pointerout handler
check('hover OUT: a stray exit doesn\u2019t yank it down (grace armed)',
e.mode === 'full' && e.collapseAt != null);
// Grace (400 ms) + the fold fade + headroom.
await wait(900);
// Grace (2000 ms) + the fold fade + headroom.
await wait(2600);
check('hover OUT (grace runs out): folds back to the compact display (still far)',
e.mode === 'small' && e.w === cfg.smallBox && e.typeText.alpha < 0.1);
@ -171,7 +171,7 @@ const run = async () => {
(() => { scene.compass.onChipOver(e); return e.collapseAt === null && e.mode === 'full'; })());
// Leave it for good: folded back to the small box before the guard tests.
scene.compass.onChipOut(e);
await wait(900);
await wait(2600);
check('final leave: back to the compact display', e.mode === 'small' && e.w === cfg.smallBox);
// --- The scene's click guard around the small box --------------------

View File

@ -281,7 +281,7 @@ check('lerpAngle eases the short way (3→3, no long-way sweep)', Math.abs(le
check(
'far display is configured (farDistance 5120 / arrow 0.6 / smallBox 26 / intent delays)',
fc.farDistance === 5120 && fc.farArrowScale === 0.6 && fc.smallBox === 26 && fc.smallHitSlack === 16
&& fc.expandDelay === 300 && fc.collapseDelay === 400,
&& fc.expandDelay === 300 && fc.collapseDelay === 2000,
);
check('isFarTarget: beyond farDistance ⇒ compact', isFarTarget({ x: 6000, y: 0 }, { x: 0, y: 0 }, 5120) === true);
check('isFarTarget: exactly at farDistance ⇒ full', isFarTarget({ x: 5120, y: 0 }, { x: 0, y: 0 }, 5120) === false);
@ -299,7 +299,7 @@ check('lerpAngle eases the short way (3→3, no long-way sweep)', Math.abs(le
const compass = new DiscoveryCompass(fakeScene);
check('compass far defaults match the config (incl. intent delays)',
compass.farDistance === 5120 && compass.smallBox === 26 && compass.farArrowScale === 0.6
&& compass.expandDelay === 300 && compass.collapseDelay === 400);
&& compass.expandDelay === 300 && compass.collapseDelay === 2000);
const noop = () => {};
const entry = {
mode: 'full',

View File

@ -170,17 +170,16 @@ check('model: issues(tree) is clean', Array.isArray(issues(tree)) && issues(tree
// 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 normalizes {builds, research}', JSON.stringify(unlocksOf(tree, 'tether_l2')) === JSON.stringify({ builds: ['tether-l2'], research: ['tether_l3'] }));
check('model: unlocksOf(tether_l3) declares the level-3 build', JSON.stringify(unlocksOf(tree, 'tether_l3')) === JSON.stringify({ builds: ['tether-l3'], research: [] }));
check('model: unlocksOf is empty for a node without unlocks', JSON.stringify(unlocksOf(tree, 'signal_amp')) === JSON.stringify({ builds: [], research: [] }));
check('model: unlocksOf normalizes a node with no builds', JSON.stringify(unlocksOf(tree, 'tether_l1')) === JSON.stringify({ builds: [], research: ['tether_l2'] }));
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 = 3 (l1 → l2/anchor/signal → l3)', layout.rows === 3);
check('model: layout levels — l1 root, l2/signal tier 1, l3/anchors tier 2',
layout.level.tether_l1 === 0 && layout.level.tether_l2 === 1 && layout.level.signal_amp === 1 &&
layout.level.tether_l3 === 2 && layout.level.tether_anchors === 2);
check('model: layout rows = 3 (l1 → l2 → l3)', layout.rows === 3);
check('model: layout levels — l1 root, l2 tier 1, l3 tier 2',
layout.level.tether_l1 === 0 && layout.level.tether_l2 === 1 && layout.level.tether_l3 === 2);
check('model: layout is deterministic (same tree → same columns)', JSON.stringify(layout.col) === JSON.stringify(layoutTree(tree).col));
// a fresh run: starting techs pre-unlocked, the rest follow the rules
@ -188,17 +187,16 @@ const state = new ResearchState();
for (const id of tree.starting) state.unlock(tree.id, id);
check('state: a fresh run already owns Tether Level 1', state.isUnlocked('exploration', 'tether_l1'));
check('state: Tether Level 2 is the FIRST thing to research', isAvailable(tree, state, 'tether_l2'));
check('state: Signal Amplification is also available (requires only L1)', isAvailable(tree, state, 'signal_amp'));
check('state: Tether Level 3 waits on L2 (locked)', !isAvailable(tree, state, 'tether_l3'));
check('state: missingRequires names the parents (L3 ← L2)', JSON.stringify(missingRequires(tree, state, 'tether_l3')) === JSON.stringify(['tether_l2']));
// one project at a time — the in-flight slot
check('state: start() claims the slot', state.start('exploration', 'tether_l2', 60_000, 0) === true);
check('state: a second start() is refused while one runs', state.start('exploration', 'signal_amp', 60_000, 0) === false);
check('state: a second start() is refused while one runs', state.start('exploration', 'tether_l3', 60_000, 0) === false);
check('state: progress runs 0→1 over the duration', state.progress(0).fraction === 0 && state.progress(60_000).fraction === 1);
check('state: tick() reports completion at the deadline', state.tick(59_999).length === 0 && state.tick(60_000).length === 1);
check('state: L2 is researched after completion', state.isUnlocked('exploration', 'tether_l2'));
check('state: L3 + Tether Anchoring open up next', isAvailable(tree, state, 'tether_l3') && isAvailable(tree, state, 'tether_anchors'));
check('state: L3 opens up next', isAvailable(tree, state, 'tether_l3'));
// save/restore round-trip — the in-flight project keeps its remaining time
const state2 = new ResearchState();

View File

@ -2,7 +2,7 @@
* Dev-only: boot GameScene and open the RESEARCH console (depth 80) so a
* headless screenshot shows the whole feature left video feed (muted
* loop), category tabs, the topdown tech tree (Tether Level 1 owned
* Level 2 + Signal Amplification available), the detail readout with the
* Level 2 available), the detail readout with the
* RESEARCH button, and a project started in flight (deck bar + status).
*
* node dev/slow-server.mjs 8081 # serves / + /sleep?ms=N

View File

@ -1183,7 +1183,7 @@ The player holds a REPUTATION (standing) on each planet and space station:
Pure rules/state in `js/research/` (Node-tested), the window is a
passive view, `effects` carry the payload (e.g. `tether.level` grows
the home tether). First category **Exploration** is live (Tether
Level 13, Tether Anchoring, Signal Amplification); second category
Level 13); second category
**Mining** is live (Improved Mining Arm → Advanced Mining Arm +
Improved Mining Storage — all 60 s blueprints, each unlocking its
ship-upgrade build in the console's Mining tab); add a category =

View File

@ -82,7 +82,7 @@ export class DiscoveryCompass extends Phaser.GameObjects.Container {
// after pointer-enter, the fold-back waits collapseDelay after
// pointer-leave (hover back within the grace and it stays expanded).
this.expandDelay = Math.max(0, cfg.expandDelay ?? 300);
this.collapseDelay = Math.max(0, cfg.collapseDelay ?? 400);
this.collapseDelay = Math.max(0, cfg.collapseDelay ?? 2000);
/** @type {Map<string, object>} target id → entry (arrow, chip, angle…) */
this.entries = new Map();
// Autopilot seam: clicking a chip calls this with the target's id —