diff --git a/README.md b/README.md index 3356a32..a02bf59 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,20 @@ python3 -m http.server 8080 your range is where the ship parks — on the line — until a tether grows. Upgrades and extra anchors (planets/stations) plug into `TetherField.add/setLevel` (tuning in `data/tether.json`) +- **The Research console** — the **RESEARCH** deck button opens a full-screen + window: the left pane loops the muted `assets/videos/research-computer.mp4` + archive feed (scanlines, sweep band, REC pulse, glitch bursts), the right + pane holds the category tabs and the **branching tech tree** for the + selected category — it starts at the top and unlocks downward, one branch + at a time. Selecting a tech shows its dossier (icon + description + + duration) and a **RESEARCH** button, which appears only when the tech is + researchable and nothing else is in progress (one project at a time, + `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/` (the first is **Exploration**: Tether Level 1–4, + Tether Anchoring, Signal Amplification). Unlocks and in-progress work + persist in the save bank — and resume correctly on load - Camera gently trails the ship; the **parallax starfield** streams past while it flies and the view slowly recenters (≈1.5 s) once the ship comes to rest @@ -99,9 +113,12 @@ orbit/ │ ├── galaxy.json # galaxy scale & shape (count, radius, spiral…) │ ├── systems.json # system archetypes: theme, attributes, distribution │ ├── settlements.json # the lived-in layer: settlement kinds & populations -│ ├── research.json # RESEARCH: time-based, one at a time, gates builds/research +│ ├── research.json # RESEARCH: global rules (time unit, one at a time) + category registry +│ │ # trees live one-per-file below (section name = file basename) +│ ├── research/ +│ │ └── exploration.json# the Exploration tree: tether levels, anchoring, signal amp │ ├── builds.json # BUILDING: credits + minerals, ship/planet/station upgrades -│ ├── actionbar.json # the command deck: 6 slots (Research, Build, Ship, ·, ·, Menu) +│ ├── actionbar.json # the command deck: 6 slots (Research, Scan, Ship, ·, ·, Menu) │ └── naming.json # names: star/galaxy syllable pools + the curated PLANET & STATION name banks ├── assets/images/ # art: planets.png (1024×1024 frames), ships-player.png (256×256 frames) ├── assets/fonts/ # UI typefaces: Ethnocentric (headers), Centauri (body) @@ -113,7 +130,8 @@ orbit/ │ ├── entities/ # Ship (own behavior), Planet (home world, solid) │ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport │ ├── tether/ # Tether (pure range math) + TetherField (constraint + barrier line) -│ ├── ui/ # MenuButton, GlitchText, CyberShape, ActionBar, DiscoveryCompass (reusable) +│ ├── research/ # ResearchModel (pure tree rules/layout), ResearchState (unlocks + active run), ResearchIcons +│ ├── ui/ # MenuButton, GlitchText, CyberShape, ActionBar, DiscoveryCompass (reusable), ResearchWindow │ ├── visuals/ # Starfield, CyberOverlay (CRT/glitch, shared) │ ├── utils/ # small pure helpers (Color, Rng, NameGenerator) │ └── vendor/ # shim to the vendored Phaser @@ -130,22 +148,29 @@ orbit/ ## The player's loop: research + building -Beyond flying, Orbit is built around two progression verbs — both are data -layers now, with the rules and panels to come next: +Beyond flying, Orbit is built around two progression verbs: -- **Research** (`data/research.json`) — *time-based*. A project takes a fixed - `duration`; the player researches **one thing at a time** - (`maxConcurrent: 1`). Research is the gate: it unlocks **builds** and - **further research**. `projects` is an empty map for now; the `_template` - entry documents the shape each project must have. +- **Research** (`data/research.json` + `data/research/.json`) — + *time-based*. A project takes a fixed `duration` (`timeUnit: seconds`); + the player researches **one thing at a time** (`maxConcurrent: 1`). + Research is the gate: it unlocks **builds** and **further research** + (each category tree is a DAG — a node's `requires` must be unlocked + first). The RESEARCH deck button opens the console window + (`js/ui/ResearchWindow.js`): looping archive feed, category tabs, the + branching tree, per-tech dossier + RESEARCH button, and a deck progress + bar. The rules are pure (`js/research/ResearchModel.js`, + `ResearchState.js` — Node-testable); the window is a passive view that + asks `GameScene` to start a run. `effects` on a finished node carry the + payload (e.g. `{ tether: { level: 2 } }` → `TetherField.setLevel`), so new + effect kinds plug in without touching the tree data. - **Building** (`data/builds.json`) — *cost-based*, paid in **credits and minerals** (`resources`). A build improves the **ship**, a **planet**, or a **space station** (`category`). `builds` is an empty map; the `_template` entry documents the shape (including `repeatable`, for things like extra - mining rigs). + mining rigs). The build UI is next — the research side is live now. - **The command deck** (`data/actionbar.json`, rendered by `js/ui/ActionBar.js`) — the cyberpunk bar across the bottom of the screen. - Six evenly spaced slots: **Research, Build, Ship, ·, ·, Menu**. The slots + Six evenly spaced slots: **Research, Scan, Ship, ·, ·, Menu**. The slots fire `onAction` and are otherwise inert; two slots are reserved. Dressed with the menu's CRT language — scanlines over the strip and the GlitchText RGB pull-apart on labels and the panel outline during bursts. All @@ -171,7 +196,15 @@ node dev/music.test.mjs # the shared music voice: guards + music.json ``` `dev/test-game.html` boots straight into the GameScene (no menu click), -handy for manual testing of the flight feel. +handy for manual testing of the flight feel. `dev/research-shot.html` +does the same but opens the Research console and starts a run, and +`dev/cdp-shot.mjs` waits for a readiness expression before screenshotting +through CDP (any Chromium-CDP browser on `127.0.0.1:9333`): + +```sh +node dev/cdp-shot.mjs "http://127.0.0.1:8081/dev/research-shot.html" shot.png \ + "window.__RESEARCH_SHOT && window.__RESEARCH_SHOT.ready" 90000 +``` ## Phaser diff --git a/assets/images/originals/gasgiant-planet-03.png b/assets/images/originals/gasgiant-planet-03.png new file mode 100644 index 0000000..bb1764a Binary files /dev/null and b/assets/images/originals/gasgiant-planet-03.png differ diff --git a/assets/images/planets.png b/assets/images/planets.png index 637a86c..c968194 100644 Binary files a/assets/images/planets.png and b/assets/images/planets.png differ diff --git a/assets/images/planets.psd b/assets/images/planets.psd index 171b58a..e3b339d 100644 Binary files a/assets/images/planets.psd and b/assets/images/planets.psd differ diff --git a/assets/videos/gasgiant-land-03.mp4 b/assets/videos/gasgiant-land-03.mp4 new file mode 100644 index 0000000..965572f Binary files /dev/null and b/assets/videos/gasgiant-land-03.mp4 differ diff --git a/assets/videos/gasgiant-takeoff-03.mp4 b/assets/videos/gasgiant-takeoff-03.mp4 new file mode 100644 index 0000000..84f3d8f Binary files /dev/null and b/assets/videos/gasgiant-takeoff-03.mp4 differ diff --git a/assets/videos/research-computer.mp4 b/assets/videos/research-computer.mp4 new file mode 100644 index 0000000..01f4a75 Binary files /dev/null and b/assets/videos/research-computer.mp4 differ diff --git a/data/manifest.json b/data/manifest.json index 381e2d8..29366c3 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -14,6 +14,7 @@ "reputation.json", "naming.json", "research.json", + "research/exploration.json", "builds.json", "actionbar.json", "landing.json", diff --git a/data/research.json b/data/research.json index eb9ee9a..1d66c24 100644 --- a/data/research.json +++ b/data/research.json @@ -1,20 +1,29 @@ { - "_comment": "RESEARCH — time-based tech. The player runs at most `maxConcurrent` projects at a time (= 1: one thing at a time). Starting a project starts its clock; when `duration` (in `timeUnit`) has elapsed the project is complete and its `effects` apply. Research is the unlock gate: a project's `requires` lists the projects that must be complete first, and `unlocks` names what it opens up — buildable items (data/builds.json → `builds.`) and follow-on research (this file → `projects.`). Add a project = one entry under `projects` (the key is the project's id). Keys starting with `_` (like `_template`) are documentation, not data. The research state/UI is not implemented yet — this file is the data layer that will drive it.", + "_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.", + "enabled": true, "timeUnit": "seconds", "maxConcurrent": 1, - "projects": {}, - "_template": { - "label": "Project Name", - "description": "What this research is and why it matters.", - "duration": 300, - "requires": [], - "unlocks": { - "builds": [], - "research": [] - }, - "effects": {}, - "theme": { - "color": "#00e5ff" - } + "defaultCategory": "exploration", + "categories": [ + { "id": "exploration", "label": "Exploration", "accent": "#00e5ff" } + ], + "video": { + "_comment": "The console's left-panel feed: loops muted while the window is open. aspect is [w, h] — the file is 544×800 (a 2:3 portrait).", + "file": "assets/videos/research-computer.mp4", + "aspect": [2, 3] + }, + "fx": { + "_comment": "Window dressing. glitch = occasional RGB-split slice bursts; sweep = the scan band that crawls down the video feed.", + "glitch": { "enabled": true, "intervalMs": [6000, 13000], "durationMs": [220, 420] }, + "sweep": { "enabled": true, "everyMs": [4200, 8600], "durationMs": 1500 } + }, + "_nodeTemplate": { + "label": "Tech name", + "description": "What this tech is and what it does — shown in the detail readout at the bottom of the console.", + "icon": "tether", + "duration": 120, + "requires": ["parent-tech-id"], + "unlocks": { "builds": [], "research": [] }, + "effects": { "tether": { "level": 2 } } } } diff --git a/data/research/exploration.json b/data/research/exploration.json new file mode 100644 index 0000000..21bd410 --- /dev/null +++ b/data/research/exploration.json @@ -0,0 +1,60 @@ +{ + "_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.", + "starting": ["tether_l1"], + "nodes": { + "tether_l1": { + "label": "Tether Level 1", + "description": "Your first circle of open space: a level-1 tether anchored on the home world, a 5,120 m ring of flyable void. Everything inside the line is yours to work; everything beyond it is a wall. This is the tether you launched with.", + "icon": "tether", + "duration": 0, + "requires": [], + "unlocks": { "builds": [], "research": ["tether_l2", "signal_amp"] }, + "effects": {} + }, + "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.", + "icon": "tether", + "duration": 60, + "requires": ["tether_l1"], + "unlocks": { "builds": [], "research": ["tether_l3", "tether_anchors"] }, + "effects": { "tether": { "level": 2 } } + }, + "tether_l3": { + "label": "Tether Level 3", + "description": "Third stage: 8,000 m of tether. The outer belt of an average system sits comfortably inside the ring — less time parked on the line, more time working it.", + "icon": "tether", + "duration": 150, + "requires": ["tether_l2"], + "unlocks": { "builds": [], "research": ["tether_l4"] }, + "effects": { "tether": { "level": 3 } } + }, + "tether_l4": { + "label": "Tether Level 4", + "description": "Fourth stage: 10,000 m. At this level every object of a ten-body system — all the worlds and stations — lies inside one ring around your anchor, and the barrier stops meaning anything out here.", + "icon": "tether", + "duration": 300, + "requires": ["tether_l3"], + "unlocks": { "builds": [], "research": [] }, + "effects": { "tether": { "level": 4 } } + }, + "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" } + } + } +} diff --git a/dev/cdp-shot.mjs b/dev/cdp-shot.mjs new file mode 100644 index 0000000..c2658b9 --- /dev/null +++ b/dev/cdp-shot.mjs @@ -0,0 +1,98 @@ +/** + * Dev-only CDP screenshot harness (any Chromium-CDP browser: Chrome, + * Brave, Edge). Unlike `--headless --screenshot` (which fires at first + * paint), this waits for the page to signal readiness before capturing. + * + * node dev/cdp-shot.mjs [readyExpr] [timeoutMs] [beforeCaptureExpr] + * + * readyExpr is a JS expression evaluated repeatedly in the page; the shot + * is taken when it returns a truthy value (default: none → capture + * shortly after load). The expression's truthy value is printed. + * beforeCaptureExpr (optional) runs once right before the capture (e.g. + * to hide dev overlays). + * + * Requires a headless browser exposing CDP on 127.0.0.1:9333: + * /opt/brave.com/brave/brave --headless --disable-gpu \ + * --remote-debugging-port=9333 about:blank + * (or point CDP_PORT at another one.) + */ +const [url, out, readyExpr, timeoutMs, beforeCaptureExpr] = process.argv.slice(2); +if (!url || !out) { + console.error('usage: node dev/cdp-shot.mjs [readyExpr] [timeoutMs]'); + process.exit(2); +} +const CDP = process.env.CDP_PORT ? `127.0.0.1:${process.env.CDP_PORT}` : '127.0.0.1:9333'; +const deadline = Date.now() + Number(timeoutMs ?? 60000); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function main() { + // Pick the first page target (or open one). + let target = await (await fetch(`http://${CDP}/json/list`)).json() + .then((ts) => ts.find((t) => t.type === 'page')); + if (!target) { + target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json(); + } + const ws = new WebSocket(target.webSocketDebuggerUrl); + let id = 0; + const pending = new Map(); + const send = (method, params = {}) => new Promise((res, rej) => { + const mid = ++id; + pending.set(mid, { res, rej }); + ws.send(JSON.stringify({ id: mid, method, params })); + }); + const events = []; + await new Promise((res, rej) => { + ws.onopen = res; + ws.onerror = (e) => rej(new Error('ws error: ' + (e?.message ?? '?'))); + }); + ws.onmessage = (m) => { + const msg = JSON.parse(m.data); + if (msg.id && pending.has(msg.id)) { + const { res, rej } = pending.get(msg.id); + pending.delete(msg.id); + msg.error ? rej(new Error(msg.error.message)) : res(msg.result); + } else if (msg.method) { + events.push(msg); + } + }; + + await send('Page.enable'); + await send('Runtime.enable'); + await send('Page.navigate', { url }); + const loaded = new Promise((res) => { + const t = setInterval(() => { + if (events.some((e) => e.method === 'Page.loadEventFired')) { clearInterval(t); res(); } + }, 50); + setTimeout(() => { clearInterval(t); res(); }, 20000); + }); + await loaded; + + const evaluate = async (expr) => { + const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); + if (r.exceptionDetails) throw new Error('page: ' + JSON.stringify(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text)); + return r.result?.value; + }; + + let ready = null; + if (readyExpr) { + while (Date.now() < deadline) { + ready = await evaluate(readyExpr).catch(() => null); + if (ready) break; + await sleep(200); + } + } else { + await sleep(1000); + } + if (beforeCaptureExpr) await evaluate(beforeCaptureExpr).catch(() => null); + // One more beat so any final paint lands. + await sleep(250); + + const shot = await send('Page.captureScreenshot', { format: 'png' }); + const buf = Buffer.from(shot.data, 'base64'); + await (await import('node:fs/promises')).writeFile(out, buf); + ws.close(); + console.log(`screenshot: ${out} (${buf.length} bytes)` + (ready ? ` · ready=${JSON.stringify(ready).slice(0, 400)}` : '')); +} + +main().catch((err) => { console.error('cdp-shot failed:', err.message); process.exit(1); }); diff --git a/dev/research-builds.test.mjs b/dev/research-builds.test.mjs index 02b3e0a..9ab1db4 100644 --- a/dev/research-builds.test.mjs +++ b/dev/research-builds.test.mjs @@ -1,28 +1,32 @@ /** - * Research & build data-layer test (dev tool, run with Node — no browser): + * Research data-layer test (dev tool, run with Node — no browser): * * node dev/research-builds.test.mjs * - * The player's progression loop has two halves — research (time-based, - * one project at a time) and building (credits + minerals) — and the - * command deck that will host them. The RULES are not implemented yet; - * this test pins the data contract the future code will lean on: - * - the three config files are registered in data/manifest.json; - * - the rule knobs exist (timeUnit, maxConcurrent = 1, resources); - * - projects/builds are typed, empty maps (no content yet); - * - the `_template` entries document every required field; - * - the deck has exactly six slots in the right order. + * Pins the contract behind the research console (the deck's RESEARCH + * button → js/ui/ResearchWindow.js) and the build system that follows it: + * - the config files are registered in data/manifest.json; + * - research.json carries the rules (time-based, one project at a time), + * the category registry, the video feed and the fx knobs; + * - each category's tech tree (data/research/.json) is a well-formed + * DAG with typed nodes — checked both as RAW JSON and through the real + * code (ResearchModel's loadCategory/issues/layoutTree/isAvailable); + * - the player-progress rules (ResearchState) behave: starting techs + * pre-unlocked, availability, one at a time, save/restore round-trip; + * - builds.json + actionbar.json keep their contracts. */ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import fs from 'node:fs'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const dataDir = join(__dirname, '../data'); +const root = join(__dirname, '..'); +const dataDir = join(root, 'data'); const read = (name) => JSON.parse(fs.readFileSync(join(dataDir, name), 'utf8')); const manifest = read('manifest.json'); const research = read('research.json'); +const exploration = read('research/exploration.json'); const builds = read('builds.json'); const actionbar = read('actionbar.json'); @@ -31,32 +35,146 @@ const check = (label, cond) => { console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`); if (!cond) failures++; }; +const hex = /^#[0-9a-fA-F]{6}$/; // ---------------------------------------------------------------------- -// 1. Manifest registration +// 1. Manifest registration (section name = file basename) // ---------------------------------------------------------------------- -for (const f of ['research.json', 'builds.json', 'actionbar.json']) { +for (const f of ['research.json', 'research/exploration.json', 'builds.json', 'actionbar.json']) { check(`manifest registers ${f}`, manifest.files.includes(f)); } // ---------------------------------------------------------------------- -// 2. Research — time-based, one at a time +// 2. research.json — the rules + the registry // ---------------------------------------------------------------------- -check('research: player researches one thing at a time (maxConcurrent === 1)', research.maxConcurrent === 1); -check('research: timeUnit is a named unit', typeof research.timeUnit === 'string' && research.timeUnit.length > 0); -check('research: projects is a map', !!research.projects && typeof research.projects === 'object' && !Array.isArray(research.projects)); -check('research: no projects yet (empty map)', Object.keys(research.projects ?? {}).filter((k) => !k.startsWith('_')).length === 0); +check('research: master switch present', typeof research.enabled === 'boolean'); +check('research: time-based (timeUnit named)', typeof research.timeUnit === 'string' && research.timeUnit.length > 0); +check('research: one project at a time (maxConcurrent === 1)', research.maxConcurrent === 1); +check('research: categories is a non-empty array', Array.isArray(research.categories) && research.categories.length > 0); +check('research: every category has id/label/accent', research.categories.every((c) => typeof c.id === 'string' && typeof c.label === 'string' && hex.test(c.accent ?? ''))); +check('research: defaultCategory names a real category', (research.categories ?? []).some((c) => c.id === research.defaultCategory)); -const rt = research._template ?? {}; -for (const k of ['label', 'description', 'duration', 'requires', 'unlocks', 'effects', 'theme']) { - check(`research._template documents "${k}"`, k in rt); +// every category in the registry has its own tree file in the manifest +const missing = research.categories.filter((c) => !manifest.files.includes(`research/${c.id}.json`)); +check('research: every registered category has a tree file', missing.length === 0); + +// the video feed (a 2:3 portrait loop) +check('research: video file configured', typeof research.video?.file === 'string' && research.video.file.length > 0); +check('research: video file exists on disk', fs.existsSync(join(root, research.video?.file ?? ''))); +check('research: video aspect is [w, h] > 0', Array.isArray(research.video?.aspect) && research.video.aspect.every((n) => typeof n === 'number' && n > 0)); +check('research: fx knobs present', !!research.fx?.glitch && !!research.fx?.sweep); + +// ---------------------------------------------------------------------- +// 3. The exploration tree — RAW JSON contract +// ---------------------------------------------------------------------- +const nodes = exploration.nodes ?? {}; +check('exploration: nodes is a non-empty map', typeof nodes === 'object' && Object.keys(nodes).length > 0); +check('exploration: starting ⊆ nodes', Array.isArray(exploration.starting) && exploration.starting.every((id) => nodes[id])); +check('exploration: starts with Tether Level 1 (the first thing to research)', (exploration.starting ?? []).includes('tether_l1')); + +const ICON_NAMES = ['tether', 'anchor', 'signal', 'diamond']; // mirrors js/research/ResearchIcons.js +let nodeFieldsOk = true; +let requiresOk = true; +let unlocksOk = true; +let iconOk = true; +for (const [id, n] of Object.entries(nodes)) { + if (!n || typeof n !== 'object') { nodeFieldsOk = false; continue; } + if (typeof n.label !== 'string' || typeof n.description !== 'string' || !ICON_NAMES.includes(n.icon ?? 'diamond') || typeof n.duration !== 'number' || n.duration < 0) nodeFieldsOk = false; + if (!Array.isArray(n.requires) || !n.requires.every((r) => typeof r === 'string' && nodes[r])) requiresOk = false; + if (!Array.isArray(n.unlocks?.builds) || !Array.isArray(n.unlocks?.research)) unlocksOk = false; + if (!ICON_NAMES.includes(n.icon)) iconOk = false; } -check('research._template.duration is a positive number', typeof rt.duration === 'number' && rt.duration > 0); -check('research._template.requires is an array', Array.isArray(rt.requires)); -check('research._template.unlocks names builds[] + research[]', Array.isArray(rt.unlocks?.builds) && Array.isArray(rt.unlocks?.research)); +check('exploration: every node has label/description/icon/duration', nodeFieldsOk); +check('exploration: every `requires` names a node in the same tree', requiresOk); +check('exploration: every node documents unlocks {builds, research}', unlocksOk); +check('exploration: icons use the procedural glyph set', iconOk); + +// no cycles (iterative DFS, three-color) +const color = new Map(); +let cyclic = false; +for (const id of Object.keys(nodes)) { + if (color.has(id)) continue; + const stack = [[id, (nodes[id].requires ?? []).filter((r) => nodes[r])]]; + color.set(id, 1); + while (stack.length) { + const [cur, kids] = stack[stack.length - 1]; + if (kids.length) { + const next = kids[0]; + stack[stack.length - 1][1] = kids.slice(1); + if (color.get(next) === 1) { cyclic = true; break; } + if (!color.has(next)) { + color.set(next, 1); + stack.push([next, (nodes[next].requires ?? []).filter((r) => nodes[r])]); + } + } else { + color.set(cur, 2); + stack.pop(); + } + } + if (cyclic) break; +} +check('exploration: the tree is a DAG (no cycles)', !cyclic); +check('exploration: has at least one root (a node with no requires)', Object.values(nodes).some((n) => !(n.requires ?? []).length)); + +// tether level chain: each level's effect names the next level up +const levelsOk = [2, 3, 4].every((lvl) => { + const n = nodes[`tether_l${lvl}`]; + return n && n.effects?.tether?.level === lvl; +}); +check('exploration: tether_l2/l3/l4 each raise the tether to their level', levelsOk); // ---------------------------------------------------------------------- -// 3. Builds — credits + minerals +// 4. The REAL code path — ResearchModel + ResearchState (pure modules) +// ---------------------------------------------------------------------- +const { config } = await import('../js/config/Config.js'); +config.init({ research, exploration, builds, actionbar }); +const { categories, loadCategory, issues, layoutTree, isAvailable, missingRequires } = await import('../js/research/ResearchModel.js'); +const { ResearchState } = await import('../js/research/ResearchState.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: issues(tree) is clean', Array.isArray(issues(tree)) && issues(tree).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', + 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 && layout.level.tether_l4 === 3); +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 +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: 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')); + +// save/restore round-trip — the in-flight project keeps its remaining time +const state2 = new ResearchState(); +for (const id of tree.starting) state2.unlock(tree.id, id); +state2.start('exploration', 'tether_l2', 60_000, 1_000); +const saved = state2.toJSON(10_000); // saved 9s into a 60s project +const restored = new ResearchState(); +for (const k of saved.unlocked) restored.unlock(...k.split('::')); +restored.restoreActive(saved.active, 500_000); // reloaded an hour later +check('state: save carries the remaining time (60s − 9s = 51s)', saved.active?.remainingMs === 51_000); +check('state: restore keeps the project in flight', restored.getActive() !== null && restored.getActive().id === 'tether_l2'); +check('state: restored project finishes 51s after the load', restored.progress(500_000).fraction < 0.2 && restored.progress(551_000).fraction >= 1); +check('state: an old save without research restores as a fresh start', (() => { const s = new ResearchState(); s.restoreActive(null, 0); return s.getActive() === null; })()); + +// ---------------------------------------------------------------------- +// 5. Builds — credits + minerals (the next deck feature) // ---------------------------------------------------------------------- check('builds: credits resource defined', typeof builds.resources?.credits?.label === 'string'); check('builds: minerals resource defined', typeof builds.resources?.minerals?.label === 'string'); @@ -73,13 +191,12 @@ check('builds._template.repeatable is a boolean', typeof bt.repeatable === 'bool check('builds._template.requires is an array', Array.isArray(bt.requires)); // ---------------------------------------------------------------------- -// 4. Command deck — six evenly spaced slots, right order +// 6. Command deck — six evenly spaced slots, right order // ---------------------------------------------------------------------- const slots = actionbar.buttons ?? []; check('actionbar: exactly six slots', slots.length === 6); check('actionbar: slot ids in order (Research, Scan, Ship, ·, ·, Menu)', JSON.stringify(slots.map((s) => s.id)) === JSON.stringify(['research', 'scan', 'ship', null, null, 'menu'])); check('actionbar: labels (Research / Scan / Ship / · / · / Menu)', JSON.stringify(slots.map((s) => s.label)) === JSON.stringify(['Research', 'Scan', 'Ship', null, null, 'Menu'])); -const hex = /^#[0-9a-fA-F]{6}$/; check('actionbar: live slots carry hex accents', slots.filter((s) => s.id).every((s) => hex.test(s.accent ?? ''))); check('actionbar: reserved slots stay null', slots.filter((s) => s.id === null).every((s) => s.label === null)); check('actionbar: CRT scanlines configured (pitch + alpha)', typeof actionbar.scanline?.pitch === 'number' && typeof actionbar.scanline?.alpha === 'number'); diff --git a/dev/research-shot.html b/dev/research-shot.html new file mode 100644 index 0000000..eaab9fb --- /dev/null +++ b/dev/research-shot.html @@ -0,0 +1,22 @@ + + + + + + Orbit — Research console (dev shot) + + + + +
+ + + + + diff --git a/dev/research-shot.mjs b/dev/research-shot.mjs new file mode 100644 index 0000000..3ebcc93 --- /dev/null +++ b/dev/research-shot.mjs @@ -0,0 +1,96 @@ +/** + * 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 top→down tech tree (Tether Level 1 owned → + * Level 2 + Signal Amplification 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 + * firefox --headless --screenshot RESEARCH_SHOT.png \ + * --window-size=1280,720 \ + * "http://127.0.0.1:8081/dev/research-shot.html" + * + * The page carries a defer'd