Add Mining research category and ship-scoped build upgrades
- New `data/research/mining.json` tree (Improved/Advanced Mining Arm, Improved Storage) as blueprints that unlock matching builds - Three new ship-scoped builds in the console's Mining tab: mining arm rate 1.5/2.0 minerals/s and hold cap 350; MAX-guarded effects applied to `ship.stats` - Tether Level 3 promoted from research-direct effect to a build (tether-l3) gated on L2 tether, consistent with L2 blueprint pattern - `BuildState.isBuiltAnywhere` + `isShipScoped` so ship upgrades count as built wherever the ship lands (no re-buy), records still key the planet - `GameScene._restoreMiningUpgrades` re-derives ship mining stats from build records on load; `_applyMiningUpgrade` handles rate/capacity with toasts and HUD refresh - New procedural icons: tether3, mining, mining2, storage - Updated tests (builds, research-builds), diagnostics (BuildDiag v4), docs, README, manifest
This commit is contained in:
parent
146dc207df
commit
546e5c9bf3
46
README.md
46
README.md
|
|
@ -111,8 +111,10 @@ 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/` (the first is **Exploration**: Tether Level 1–4,
|
||||
Tether Anchoring, Signal Amplification). Unlocks and in-progress work
|
||||
`data/research/` (**Exploration**: Tether Level 1–3, Tether Anchoring,
|
||||
Signal Amplification; **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
|
||||
- **The Build console** — the **BUILD** deck button (on a planet surface,
|
||||
where the deck's SHOP/BUILD/TAKE OFF live) opens a full-screen window:
|
||||
|
|
@ -158,7 +160,8 @@ orbit/
|
|||
│ ├── 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
|
||||
│ │ ├── exploration.json# the Exploration tree: tether levels, anchoring, signal amp
|
||||
│ │ └── mining.json # the Mining tree: improved/advanced mining arm, improved storage (blueprints)
|
||||
│ ├── builds.json # BUILDING: the build console's categories/resources + the buildable items
|
||||
│ ├── actionbar.json # the command deck: 6 slots (Research, Scan, Ship, ·, ·, Menu)
|
||||
│ └── naming.json # names: star/galaxy syllable pools + the curated PLANET & STATION name banks
|
||||
|
|
@ -208,21 +211,30 @@ Beyond flying, Orbit is built around two progression verbs:
|
|||
effect kinds plug in without touching the tree data.
|
||||
- **Building** (`data/builds.json`) — *cost-based*, paid in **minerals**
|
||||
(and credits, when they land; `resources`). A build is a **one-off
|
||||
install on a planet** (`category: planet | cargo`): it names its `cost`,
|
||||
its `duration` (seconds), the research that unlocks it (`requires`,
|
||||
mirroring the research tree's `unlocks.builds`), optional world-state
|
||||
gates (`planetRequires`, e.g. the tether level on that world), and its
|
||||
install** (`category: planet | cargo | mining`): planet builds land on
|
||||
the world's surface (`targets: ["planet"]`); the Mining tab's builds are
|
||||
**ship upgrades** (`targets: ["ship"]` — the improved/advanced mining
|
||||
arm set the ship's mining rate to 1.5 / 2.0 minerals per second, the
|
||||
improved storage grows the hold to 350): built from any planet's
|
||||
console, the effect is ship-wide, and it counts as built wherever the
|
||||
ship lands (no re-buy). Each build names its `cost`, its `duration`
|
||||
(seconds), the research that unlocks it (`requires`, mirroring the
|
||||
research tree's `unlocks.builds`), optional world-state gates
|
||||
(`planetRequires`, e.g. the tether level on that world), and its
|
||||
`effects` (e.g. `tether: {level: 2}` → the world's tether field
|
||||
strengthens). The BUILD deck button (on a planet surface) opens the
|
||||
console window (`js/ui/BuildWindow.js`): looping build feed, category
|
||||
tabs, the build LIST (locked items grayed with their missing gates),
|
||||
per-build dossier + highlighted cost + BUILD button. While a build runs
|
||||
the deck is locked (one at a time) and the state ticks on the game-loop
|
||||
clock — so a build started on the surface keeps running if the player
|
||||
takes off. The rules are pure (`js/build/BuildModel.js`, `BuildState.js`
|
||||
— Node-tested in `dev/builds.test.mjs`); the window is a passive view
|
||||
that asks `GameScene.beginBuild()` to run the gates, charge the cost,
|
||||
and start the clock, and the scene applies `effects` on completion.
|
||||
strengthens; `mining: {rate: 2.0}` / `mining: {capacity: 350}` → the
|
||||
ship's stats, MAX wins). The BUILD deck button (on a planet surface)
|
||||
opens the console window (`js/ui/BuildWindow.js`): looping build feed,
|
||||
category tabs, the build LIST (locked items grayed with their missing
|
||||
gates), per-build dossier + highlighted cost + BUILD button. While a
|
||||
build runs the deck is locked (one at a time) and the state ticks on
|
||||
the game-loop clock — so a build started on the surface keeps running
|
||||
if the player takes off. The rules are pure (`js/build/BuildModel.js`,
|
||||
`BuildState.js` — Node-tested in `dev/builds.test.mjs`); the window is
|
||||
a passive view that asks `GameScene.beginBuild()` to run the gates,
|
||||
charge the cost, and start the clock, and the scene applies `effects`
|
||||
on completion (ship upgrades re-derive from the build records on load,
|
||||
`GameScene._restoreMiningUpgrades`).
|
||||
- **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, Scan, Ship, ·, ·, Menu**
|
||||
|
|
|
|||
Binary file not shown.
103
data/builds.json
103
data/builds.json
|
|
@ -16,6 +16,11 @@
|
|||
"id": "cargo",
|
||||
"label": "Cargo",
|
||||
"accent": "#7ce8a4"
|
||||
},
|
||||
{
|
||||
"id": "mining",
|
||||
"label": "Mining",
|
||||
"accent": "#ff8a3d"
|
||||
}
|
||||
],
|
||||
"defaultCategory": "planet",
|
||||
|
|
@ -84,6 +89,94 @@
|
|||
"theme": {
|
||||
"color": "#00e5ff"
|
||||
}
|
||||
},
|
||||
"tether-l3": {
|
||||
"label": "Tether - Level 3",
|
||||
"description": "Extends the planet's tether to level 3: the field's range grows from 10240 px to 20480 px around the world. Requires the level-3 tether research and a planet already holding a level-2 tether. Installed in 30 seconds for 300 minerals.",
|
||||
"icon": "tether3",
|
||||
"category": "planet",
|
||||
"targets": ["planet"],
|
||||
"cost": {
|
||||
"minerals": 300
|
||||
},
|
||||
"duration": 30,
|
||||
"requires": ["exploration/tether_l3"],
|
||||
"planetRequires": {
|
||||
"tetherLevel": 2
|
||||
},
|
||||
"repeatable": false,
|
||||
"effects": {
|
||||
"tether": {
|
||||
"level": 3,
|
||||
"anchor": "target"
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"color": "#00e5ff"
|
||||
}
|
||||
},
|
||||
"mining-arm-improved": {
|
||||
"label": "Improved Mining Arm",
|
||||
"description": "A faster mining arm for the ship: it mines asteroids at 1.5 minerals per second (the base arm does 1.0). A ship upgrade — installed from any planet's console, it counts as built wherever the ship lands and is never re-bought.",
|
||||
"icon": "mining",
|
||||
"category": "mining",
|
||||
"targets": ["ship"],
|
||||
"cost": {
|
||||
"minerals": 200
|
||||
},
|
||||
"duration": 20,
|
||||
"requires": ["mining/improved_arm"],
|
||||
"repeatable": false,
|
||||
"effects": {
|
||||
"mining": {
|
||||
"rate": 1.5
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"color": "#ff8a3d"
|
||||
}
|
||||
},
|
||||
"mining-arm-advanced": {
|
||||
"label": "Advanced Mining Arm",
|
||||
"description": "The ship's fastest arm: it mines asteroids at 2.0 minerals per second — twice the base arm. Supersedes the Improved Mining Arm (the faster arm always wins). A ship upgrade: built from any planet's console, counted wherever the ship lands.",
|
||||
"icon": "mining2",
|
||||
"category": "mining",
|
||||
"targets": ["ship"],
|
||||
"cost": {
|
||||
"minerals": 300
|
||||
},
|
||||
"duration": 20,
|
||||
"requires": ["mining/advanced_arm"],
|
||||
"repeatable": false,
|
||||
"effects": {
|
||||
"mining": {
|
||||
"rate": 2.0
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"color": "#ff8a3d"
|
||||
}
|
||||
},
|
||||
"mining-storage-improved": {
|
||||
"label": "Improved Mining Storage",
|
||||
"description": "Expands the ship's mineral hold to 350 (100 more than the base 250). A ship upgrade: built from any planet's console, counted wherever the ship lands.",
|
||||
"icon": "storage",
|
||||
"category": "mining",
|
||||
"targets": ["ship"],
|
||||
"cost": {
|
||||
"minerals": 200
|
||||
},
|
||||
"duration": 20,
|
||||
"requires": ["mining/improved_storage"],
|
||||
"repeatable": false,
|
||||
"effects": {
|
||||
"mining": {
|
||||
"capacity": 350
|
||||
}
|
||||
},
|
||||
"theme": {
|
||||
"color": "#ff8a3d"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -91,9 +184,10 @@
|
|||
"_comment": "Shape of one entry in `builds` (the _ keys are documentation, never builds).",
|
||||
"label": "string — shown in the list and the detail readout",
|
||||
"description": "string — detail readout text",
|
||||
"icon": "tether | tether2 | anchor | signal | diamond — procedural glyph (js/research/ResearchIcons.js)",
|
||||
"category": "planet | cargo — which tab of the console (registry above)",
|
||||
"icon": "tether | tether2 | tether3 | anchor | signal | mining | mining2 | storage | diamond — procedural glyph (js/research/ResearchIcons.js)",
|
||||
"category": "planet | cargo | mining — which tab of the console (registry above)",
|
||||
"targets": ["planet"],
|
||||
"_targets": "[\"planet\"] = installed on the planet's surface; [\"ship\"] = a SHIP upgrade (mining arms/storage): built from any planet's console, the effect is ship-wide and it counts as BUILT wherever the ship lands (never re-bought)",
|
||||
"cost": {
|
||||
"credits": 0,
|
||||
"minerals": 200
|
||||
|
|
@ -109,8 +203,13 @@
|
|||
"tether": {
|
||||
"level": 2,
|
||||
"anchor": "target"
|
||||
},
|
||||
"mining": {
|
||||
"rate": 1.5,
|
||||
"capacity": 350
|
||||
}
|
||||
},
|
||||
"_effects": "applied on completion, data-driven (GameScene._applyBuildEffects): tether.level = the target world's tether field (anchor target = the built-from planet, home = the home world); mining.rate = the ship's mining speed (minerals/s, MAX wins — never downgrades); mining.capacity = the ship's mineral hold cap (MAX wins)",
|
||||
"theme": {
|
||||
"color": "#00e5ff"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"naming.json",
|
||||
"research.json",
|
||||
"research/exploration.json",
|
||||
"research/mining.json",
|
||||
"builds.json",
|
||||
"actionbar.json",
|
||||
"landing.json",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"defaultCategory": "exploration",
|
||||
"categories": [
|
||||
{ "id": "exploration", "label": "Exploration", "accent": "#00e5ff" },
|
||||
{ "id": "mining", "label": "Mining", "accent": "#ffc94d" },
|
||||
{ "id": "system", "label": "System", "accent": "#5fd4ff", "dynamic": true }
|
||||
],
|
||||
"_dynamic": "A category flagged `dynamic: true` has NO data/research/<id>.json — its tree is built at runtime per situation (js/research/SystemCategory.js: one tech pair per solar system, named after the system the player is in — its Map tech is granted on entry, its jumpgate tech researchable once every NAV point — planets, space stations, jump gates — is discovered, and activation flips the gates on, here and in the linked systems' return gates). Static categories keep the one-file-per-category rule; the tests exempt dynamic ones from the tree-file check.",
|
||||
|
|
|
|||
|
|
@ -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 × 2^(level−1), see data/tether.json and docs/PROJECT_NOTES.md) — except Tether Level 2, which is the BLUEPRINT only (effects: {}): the player installs it as a TETHER - LEVEL 2 build on a world holding a level-1 tether (data/builds.json → tether-l2; the build carries the effect). 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^(level−1) (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.",
|
||||
"starting": ["tether_l1"],
|
||||
"nodes": {
|
||||
"tether_l1": {
|
||||
|
|
@ -22,12 +22,12 @@
|
|||
},
|
||||
"tether_l3": {
|
||||
"label": "Tether Level 3",
|
||||
"description": "Third stage: 20,480 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",
|
||||
"description": "A third stage of field power. The blueprint for the strongest tether: install it with a TETHER - LEVEL 3 build on a world that holds a level-2 tether (300 minerals, 30 s — data/builds.json) and that world's boundary ring pulls out to 20,480 m — the outer belt of an average system sits comfortably inside it.",
|
||||
"icon": "tether3",
|
||||
"duration": 150,
|
||||
"requires": ["tether_l2"],
|
||||
"unlocks": { "builds": [], "research": [] },
|
||||
"effects": { "tether": { "level": 3 } }
|
||||
"unlocks": { "builds": ["tether-l3"], "research": [] },
|
||||
"effects": {}
|
||||
},
|
||||
"tether_anchors": {
|
||||
"label": "Tether Anchoring",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"_comment": "MINING — the player's ability to extract. All three 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). 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": {
|
||||
"label": "Improved Mining Arm",
|
||||
"description": "Once built, an Improved Mining Arm can mine asteroids at 50% increased speed.",
|
||||
"icon": "mining",
|
||||
"duration": 60,
|
||||
"requires": [],
|
||||
"unlocks": { "builds": ["mining-arm-improved"], "research": ["advanced_arm", "improved_storage"] },
|
||||
"effects": {}
|
||||
},
|
||||
"advanced_arm": {
|
||||
"label": "Advanced Mining Arm",
|
||||
"description": "Once built, an Advanced Mining Arm is twice as fast as a standard mining arm",
|
||||
"icon": "mining2",
|
||||
"duration": 60,
|
||||
"requires": ["improved_arm"],
|
||||
"unlocks": { "builds": ["mining-arm-advanced"], "research": [] },
|
||||
"effects": {}
|
||||
},
|
||||
"improved_storage": {
|
||||
"label": "Improved Mining Storage",
|
||||
"description": "Once built, your ship will be able to store 100 more minerals (350 total)",
|
||||
"icon": "storage",
|
||||
"duration": 60,
|
||||
"requires": ["improved_arm"],
|
||||
"unlocks": { "builds": ["mining-storage-improved"], "research": [] },
|
||||
"effects": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ 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 mining = read('research/mining.json');
|
||||
const builds = read('builds.json');
|
||||
const actionbar = read('actionbar.json');
|
||||
|
||||
|
|
@ -41,7 +42,7 @@ const check = (label, cond) => {
|
|||
// The pure modules (no Phaser — Node-safe)
|
||||
// ----------------------------------------------------------------------
|
||||
const { config } = await import('../js/config/Config.js');
|
||||
config.init({ research, exploration, builds, actionbar });
|
||||
config.init({ research, exploration, mining, builds, actionbar });
|
||||
const {
|
||||
categories,
|
||||
loadBuilds,
|
||||
|
|
@ -51,6 +52,7 @@ const {
|
|||
rowState,
|
||||
costLines,
|
||||
canAfford,
|
||||
isShipScoped,
|
||||
} = await import('../js/build/BuildModel.js');
|
||||
const { BuildState } = await import('../js/build/BuildState.js');
|
||||
|
||||
|
|
@ -58,7 +60,9 @@ const { BuildState } = await import('../js/build/BuildState.js');
|
|||
// fresh run — nothing researched beyond the starting set, home holds a
|
||||
// level-1 tether, nothing built anywhere.
|
||||
const ctx = (over = {}) => ({
|
||||
isResearchUnlocked: (c, n) => c === 'exploration' && (n === 'tether_l1' || (over.research ?? []).includes(n)),
|
||||
isResearchUnlocked: (c, n) =>
|
||||
(c === 'exploration' && (n === 'tether_l1' || (over.research ?? []).includes(n))) ||
|
||||
(c === 'mining' && (over.miningResearch ?? []).includes(n)),
|
||||
tetherLevel: () => over.tetherLevel ?? 1,
|
||||
isBuilt: typeof over.isBuilt === 'function' ? over.isBuilt : () => false,
|
||||
});
|
||||
|
|
@ -67,13 +71,16 @@ const ctx = (over = {}) => ({
|
|||
// 1. The category registry + per-category build lists
|
||||
// ----------------------------------------------------------------------
|
||||
check('model: categories() is the console tab registry', categories().every((c) => c.id && c.label));
|
||||
check('model: the console has the Planet + Cargo tabs', ['planet', 'cargo'].every((id) => categories().some((c) => c.id === id)));
|
||||
check('model: the console has the Planet + Cargo + Mining tabs', ['planet', 'cargo', 'mining'].every((id) => categories().some((c) => c.id === id)));
|
||||
|
||||
const planet = loadBuilds('planet');
|
||||
const cargo = loadBuilds('cargo');
|
||||
check('model: loadBuilds("planet") holds both tether builds', !!planet.builds['tether-l1'] && !!planet.builds['tether-l2']);
|
||||
const miningCat = loadBuilds('mining');
|
||||
check('model: loadBuilds("planet") holds the three tether builds', !!planet.builds['tether-l1'] && !!planet.builds['tether-l2'] && !!planet.builds['tether-l3']);
|
||||
check('model: loadBuilds("planet") keeps registry entry (label/accent)', planet.label === 'Planet' && typeof planet.accent === 'string');
|
||||
check('model: loadBuilds("cargo") is empty today (no cargo modules yet)', Object.keys(cargo.builds).length === 0);
|
||||
check('model: loadBuilds("mining") holds the three ship-upgrade builds', !!miningCat.builds['mining-arm-improved'] && !!miningCat.builds['mining-arm-advanced'] && !!miningCat.builds['mining-storage-improved']);
|
||||
check('model: loadBuilds("mining") keeps registry entry (label/accent)', miningCat.label === 'Mining' && typeof miningCat.accent === 'string');
|
||||
check('model: an unknown category falls back to the default', loadBuilds('nope').id === builds.defaultCategory);
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
@ -120,6 +127,57 @@ check('model: a stronger existing tether satisfies the gate', isAvailable(tl2, c
|
|||
// already built on the planet → never offered again (one-off)
|
||||
check('model: an installed build is not available again', isAvailable(tl2, ctx({ research: ['tether_l2'], isBuilt: () => true })) === false);
|
||||
|
||||
// the tether-l3 build — same two gates, one level up (L2 research + a
|
||||
// level-2 tether on the planet)
|
||||
const tl3 = builds.builds['tether-l3'];
|
||||
check('model: tether-l3 is LOCKED before the research', isAvailable(tl3, ctx()) === false);
|
||||
check('model: tether-l3 is READY once researched (planet holds L2)', isAvailable(tl3, ctx({ research: ['tether_l3'], tetherLevel: 2 })) === true);
|
||||
check('model: tether-l3 stays LOCKED without a level-2 tether on the planet', isAvailable(tl3, ctx({ research: ['tether_l3'], tetherLevel: 1 })) === false);
|
||||
check('model: a level-3 tether satisfies the level-2 gate', isAvailable(tl3, ctx({ research: ['tether_l3'], tetherLevel: 3 })) === true);
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 3b. The Mining builds — ship upgrades (research gate only, no planet
|
||||
// gate; the effect is the ship's arm rate / hold capacity)
|
||||
// ----------------------------------------------------------------------
|
||||
const armImproved = builds.builds['mining-arm-improved'];
|
||||
const armAdvanced = builds.builds['mining-arm-advanced'];
|
||||
const storageImproved = builds.builds['mining-storage-improved'];
|
||||
|
||||
check('model: the mining builds target the SHIP (targets: ["ship"])', isShipScoped(armImproved) && isShipScoped(armAdvanced) && isShipScoped(storageImproved));
|
||||
check('model: isShipScoped is false for planet builds', isShipScoped(tl2) === false);
|
||||
check('model: the improved arm effect is 1.5 minerals/s', armImproved.effects?.mining?.rate === 1.5);
|
||||
check('model: the advanced arm effect is 2.0 minerals/s', armAdvanced.effects?.mining?.rate === 2.0);
|
||||
check('model: the improved storage effect is a 350 hold', storageImproved.effects?.mining?.capacity === 350);
|
||||
|
||||
check('model: improved arm is LOCKED before the research', isAvailable(armImproved, ctx()) === false);
|
||||
check('model: improved arm is READY once researched (NO planet gate — the effect is the ship)', isAvailable(armImproved, ctx({ miningResearch: ['improved_arm'] })) === true);
|
||||
check('model: advanced arm is LOCKED before the research', isAvailable(armAdvanced, ctx()) === false);
|
||||
check('model: advanced arm is READY once researched', isAvailable(armAdvanced, ctx({ miningResearch: ['advanced_arm'] })) === true);
|
||||
check('model: improved storage is LOCKED before the research', isAvailable(storageImproved, ctx()) === false);
|
||||
check('model: improved storage is READY once researched', isAvailable(storageImproved, ctx({ miningResearch: ['improved_storage'] })) === true);
|
||||
check('model: a mining build installed on ANY planet reads built here (ctx.isBuilt = isBuiltAnywhere)', isAvailable(armAdvanced, ctx({ miningResearch: ['advanced_arm'], isBuilt: () => true })) === false);
|
||||
check('model: each mining build is gated by its OWN research (improved arm research does not unlock the advanced arm)', isAvailable(armAdvanced, ctx({ miningResearch: ['improved_arm'] })) === false);
|
||||
|
||||
// SHIP-SCOPED build records: built on one planet ⇒ built on the ship,
|
||||
// wherever it lands (BuildState.isBuiltAnywhere — the console + the
|
||||
// beginBuild gate both read it for targets:["ship"] builds).
|
||||
const shipS = new BuildState();
|
||||
shipS.markBuilt('Kepler-186f', 'mining-arm-improved');
|
||||
check('state: isBuiltAnywhere sees a ship-scoped build installed on ANY planet', shipS.isBuiltAnywhere('mining-arm-improved') === true);
|
||||
check('state: isBuiltAnywhere is false for an uninstalled build', shipS.isBuiltAnywhere('mining-arm-advanced') === false);
|
||||
check('state: the per-planet isBuilt is unchanged (the record still keys the planet)', shipS.isBuilt('Kepler-186f', 'mining-arm-improved') === true && shipS.isBuilt('Home World', 'mining-arm-improved') === false);
|
||||
|
||||
check('model: rowState reads built for a mining build (ship-scoped ctx)', rowState(armImproved, ctx({ miningResearch: ['improved_arm'], isBuilt: () => true }), null, 'mining-arm-improved') === 'built');
|
||||
check('model: rowState reads available for a researched mining build', rowState(armImproved, ctx({ miningResearch: ['improved_arm'] }), null, 'mining-arm-improved') === 'available');
|
||||
check('model: rowState reads locked before the research', rowState(armImproved, ctx(), null, 'mining-arm-improved') === 'locked');
|
||||
|
||||
// the costs (200 / 300 / 200 minerals) + the build time (20 s each)
|
||||
check('model: improved arm costs 200 minerals, 20 s', costLines(armImproved)[0] !== undefined && costLines(armImproved).every((l) => l.res === 'minerals' && l.amount === 200) && armImproved.duration === 20);
|
||||
check('model: advanced arm costs 300 minerals, 20 s', costLines(armAdvanced).every((l) => l.res === 'minerals' && l.amount === 300) && armAdvanced.duration === 20);
|
||||
check('model: improved storage costs 200 minerals, 20 s', costLines(storageImproved).every((l) => l.res === 'minerals' && l.amount === 200) && storageImproved.duration === 20);
|
||||
check('model: mining builds are unaffordable below their cost', canAfford(armImproved, 199) === false && canAfford(armAdvanced, 299) === false && canAfford(storageImproved, 199) === false);
|
||||
check('model: mining builds are affordable at their cost', canAfford(armImproved, 200) === true && canAfford(armAdvanced, 300) === true && canAfford(storageImproved, 200) === true);
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 4. Row states — the list's paint source
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
@ -136,6 +194,9 @@ const lines = costLines(tl2);
|
|||
check('model: costLines(tether-l2) = 200 minerals', lines.length === 1 && lines[0].res === 'minerals' && lines[0].amount === 200);
|
||||
check('model: canAfford is false below the cost', canAfford(tl2, 199) === false);
|
||||
check('model: canAfford is true at/above the cost', canAfford(tl2, 200) === true && canAfford(tl2, 5000) === true);
|
||||
const lines3 = costLines(builds.builds['tether-l3']);
|
||||
check('model: costLines(tether-l3) = 300 minerals', lines3.length === 1 && lines3[0].res === 'minerals' && lines3[0].amount === 300);
|
||||
check('model: tether-l3 is unaffordable below 300 minerals', canAfford(builds.builds['tether-l3'], 299) === false && canAfford(builds.builds['tether-l3'], 300) === true);
|
||||
check('model: a free build has no cost lines', costLines(builds.builds['tether-l1']).length === 0);
|
||||
check('model: a free build is always affordable', canAfford(builds.builds['tether-l1'], 0) === true);
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ 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 mining = read('research/mining.json');
|
||||
const builds = read('builds.json');
|
||||
const actionbar = read('actionbar.json');
|
||||
|
||||
|
|
@ -40,7 +41,7 @@ const hex = /^#[0-9a-fA-F]{6}$/;
|
|||
// ----------------------------------------------------------------------
|
||||
// 1. Manifest registration (section name = file basename)
|
||||
// ----------------------------------------------------------------------
|
||||
for (const f of ['research.json', 'research/exploration.json', 'builds.json', 'actionbar.json']) {
|
||||
for (const f of ['research.json', 'research/exploration.json', 'research/mining.json', 'builds.json', 'actionbar.json']) {
|
||||
check(`manifest registers ${f}`, manifest.files.includes(f));
|
||||
}
|
||||
|
||||
|
|
@ -78,7 +79,7 @@ check('exploration: nodes is a non-empty map', typeof nodes === 'object' && Obje
|
|||
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', 'tether2', 'anchor', 'signal', 'diamond', 'map', 'gate']; // mirrors js/research/ResearchIcons.js
|
||||
const ICON_NAMES = ['tether', 'tether2', 'tether3', 'mining', 'mining2', 'storage', 'anchor', 'signal', 'diamond', 'map', 'gate']; // mirrors js/research/ResearchIcons.js
|
||||
let nodeFieldsOk = true;
|
||||
let requiresOk = true;
|
||||
let unlocksOk = true;
|
||||
|
|
@ -122,23 +123,41 @@ for (const id of Object.keys(nodes)) {
|
|||
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 (3 levels total): L2 is a BUILD (research unlocks
|
||||
// it, the build applies it — data/builds.json → tether-l2), so it carries
|
||||
// no immediate effect; L3 still applies its tether raise on research
|
||||
// completion.
|
||||
const l2NoEffect = !nodes.tether_l2 || !nodes.tether_l2.effects?.tether;
|
||||
const levelsOk = [3].every((lvl) => {
|
||||
const n = nodes[`tether_l${lvl}`];
|
||||
return n && n.effects?.tether?.level === lvl;
|
||||
});
|
||||
check('exploration: tether_l3 raises the tether to level 3', levelsOk);
|
||||
check('exploration: tether_l2 carries no immediate tether effect (the build applies it)', l2NoEffect);
|
||||
// tether level chain (3 levels total): L2 and L3 are BLUEPRINTS (research
|
||||
// unlocks their build, the build applies the raise — data/builds.json →
|
||||
// tether-l2 / tether-l3), so neither carries an immediate tether effect.
|
||||
const blueprints = [2, 3].every((lvl) => !nodes[`tether_l${lvl}`]?.effects?.tether);
|
||||
check('exploration: tether_l2 + tether_l3 carry no immediate tether effect (their builds apply it)', blueprints);
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 3b. The mining tree — RAW JSON contract (data/research/mining.json)
|
||||
// ----------------------------------------------------------------------
|
||||
const mnodes = mining.nodes ?? {};
|
||||
check('mining: nodes is a non-empty map (3 techs)', Object.keys(mnodes).length === 3);
|
||||
check('mining: nothing is starting (all three must be researched)', (mining.starting ?? []).length === 0);
|
||||
let mfieldsOk = true;
|
||||
for (const [id, n] of Object.entries(mnodes)) {
|
||||
if (!n || typeof n !== 'object') { mfieldsOk = false; continue; }
|
||||
if (typeof n.label !== 'string' || typeof n.description !== 'string' || !ICON_NAMES.includes(n.icon ?? 'diamond') || typeof n.duration !== 'number' || n.duration < 0) mfieldsOk = false;
|
||||
if (!Array.isArray(n.requires) || !n.requires.every((r) => typeof r === 'string' && mnodes[r])) mfieldsOk = false;
|
||||
if (!Array.isArray(n.unlocks?.builds) || !Array.isArray(n.unlocks?.research)) mfieldsOk = false;
|
||||
}
|
||||
check('mining: every node has label/description/icon/duration/requires/unlocks', mfieldsOk);
|
||||
check('mining: labels match the brief', mnodes.improved_arm?.label === 'Improved Mining Arm' && mnodes.advanced_arm?.label === 'Advanced Mining Arm' && mnodes.improved_storage?.label === 'Improved Mining Storage');
|
||||
check('mining: all three take 60 s (research.timeUnit)', Object.values(mnodes).every((n) => n.duration === 60));
|
||||
check('mining: improved_arm is the Tier-1 root', !!mnodes.improved_arm && (mnodes.improved_arm.requires ?? []).length === 0);
|
||||
check('mining: both Tier-2 projects require the Tier-1 root', (mnodes.advanced_arm?.requires ?? []).includes('improved_arm') && (mnodes.improved_storage?.requires ?? []).includes('improved_arm'));
|
||||
check('mining: Tier 1 declares its two children in unlocks.research', JSON.stringify(mnodes.improved_arm?.unlocks?.research ?? []) === JSON.stringify(['advanced_arm', 'improved_storage']));
|
||||
check('mining: improved_arm declares its build (mining-arm-improved)', JSON.stringify(mnodes.improved_arm?.unlocks?.builds ?? []) === JSON.stringify(['mining-arm-improved']));
|
||||
check('mining: advanced_arm declares its build (mining-arm-advanced)', JSON.stringify(mnodes.advanced_arm?.unlocks?.builds ?? []) === JSON.stringify(['mining-arm-advanced']));
|
||||
check('mining: improved_storage declares its build (mining-storage-improved)', JSON.stringify(mnodes.improved_storage?.unlocks?.builds ?? []) === JSON.stringify(['mining-storage-improved']));
|
||||
check('mining: all three are blueprints (no effects — the effect lives on the build)', Object.values(mnodes).every((n) => Object.keys(n.effects ?? {}).length === 0));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 4. The REAL code path — ResearchModel + ResearchState (pure modules)
|
||||
// ----------------------------------------------------------------------
|
||||
const { config } = await import('../js/config/Config.js');
|
||||
config.init({ research, exploration, builds, actionbar });
|
||||
config.init({ research, exploration, mining, builds, actionbar });
|
||||
const { categories, loadCategory, issues, layoutTree, isAvailable, missingRequires } = await import('../js/research/ResearchModel.js');
|
||||
const { ResearchState } = await import('../js/research/ResearchState.js');
|
||||
|
||||
|
|
@ -152,6 +171,7 @@ check('model: issues(tree) is clean', Array.isArray(issues(tree)) && issues(tree
|
|||
// 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(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: unlockIssues(tree) is clean (mirrors + build wiring agree)', unlockIssues(tree).length === 0);
|
||||
check('model: buildIssues() is clean (every build require resolves)', buildIssues().length === 0);
|
||||
|
|
@ -193,6 +213,27 @@ check('state: restore keeps the project in flight', restored.getActive() !== nul
|
|||
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; })());
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 4b. The mining tree — the REAL code path (ResearchModel + ResearchState)
|
||||
// ----------------------------------------------------------------------
|
||||
const mtree = loadCategory('mining');
|
||||
check('model: categories() registers Mining', categories().some((c) => c.id === 'mining'));
|
||||
check('model: loadCategory returns the mining tree', !!mtree && !!mtree.nodes.improved_arm && mtree.order.length === 3);
|
||||
check('model: mining: issues(tree) is clean', Array.isArray(issues(mtree)) && issues(mtree).length === 0);
|
||||
check('model: mining: unlockIssues is clean (mirror + build wiring agree)', unlockIssues(mtree).length === 0);
|
||||
const mlayout = layoutTree(mtree);
|
||||
check('model: mining layout — 2 rows (Tier 1 → two Tier-2 children)', mlayout.rows === 2 && mlayout.level.improved_arm === 0 && mlayout.level.advanced_arm === 1 && mlayout.level.improved_storage === 1);
|
||||
|
||||
const mstate = new ResearchState();
|
||||
check('mining: Tier 1 is the first thing to research (available on a fresh run)', isAvailable(mtree, mstate, 'improved_arm'));
|
||||
check('mining: both Tier-2 projects wait on Tier 1 (locked)', !isAvailable(mtree, mstate, 'advanced_arm') && !isAvailable(mtree, mstate, 'improved_storage'));
|
||||
check('mining: missingRequires names the parent (Tier 2 ← Tier 1)', JSON.stringify(missingRequires(mtree, mstate, 'advanced_arm')) === JSON.stringify(['improved_arm']));
|
||||
check('mining: start() claims the slot', mstate.start('mining', 'improved_arm', 60_000, 0) === true);
|
||||
check('mining: a second start() is refused while one runs', mstate.start('mining', 'advanced_arm', 60_000, 0) === false);
|
||||
check('mining: tick() reports completion at the deadline', mstate.tick(59_999).length === 0 && mstate.tick(60_000).length === 1);
|
||||
check('mining: Tier 1 is researched after completion', mstate.isUnlocked('mining', 'improved_arm'));
|
||||
check('mining: both Tier-2 projects open up after Tier 1', isAvailable(mtree, mstate, 'advanced_arm') && isAvailable(mtree, mstate, 'improved_storage'));
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 5. Builds — the build console (data/builds.json → js/ui/BuildWindow.js)
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
@ -218,6 +259,17 @@ check('builds: tether-l2 costs 200 minerals, 20 s, one-off', tl2.repeatable ===
|
|||
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');
|
||||
|
||||
// the tether-l3 build — same pattern, one level up: the research is the
|
||||
// blueprint, the build installs the level-3 ring (300 minerals).
|
||||
const tl3 = builds.builds['tether-l3'] ?? {};
|
||||
check('builds: tether-l3 entry exists (the level-3 tether build)', !!tl3);
|
||||
check('builds: tether-l3 is a planet build', JSON.stringify(tl3.targets ?? []) === JSON.stringify(['planet']));
|
||||
check('builds: tether-l3 is gated on exploration/tether_l3 (authoritative side)', JSON.stringify(tl3.requires ?? []) === JSON.stringify(['exploration/tether_l3']));
|
||||
check('builds: tether-l3 needs a level-2 tether on the planet', tl3.planetRequires?.tetherLevel === 2);
|
||||
check('builds: tether-l3 costs 300 minerals, 30 s, one-off', tl3.repeatable === false && tl3.cost?.minerals === 300 && tl3.duration === 30);
|
||||
check('builds: tether_l3 declares the build in its unlocks (declaration side)', (nodes.tether_l3?.unlocks?.builds ?? []).includes('tether-l3'));
|
||||
check('builds: tether-l3 effect raises a level-3 tether on the target', tl3.effects?.tether?.level === 3 && tl3.effects?.tether?.anchor === 'target');
|
||||
|
||||
// the tether-l1 build — the home world's starting install
|
||||
const tl1 = builds.builds['tether-l1'] ?? {};
|
||||
check('builds: tether-l1 entry exists (the starting install)', !!tl1);
|
||||
|
|
|
|||
|
|
@ -500,6 +500,16 @@ than follow-on tech. Each node's `unlocks` is the declaration side:
|
|||
ANCHORING · BUILD · TETHER - LEVEL 2`), read through
|
||||
`ResearchModel.unlocksOf(tree, id)` + `buildDefs()` — the one read
|
||||
point the build UI uses too.
|
||||
- The second static category is **Mining** (`data/research/mining.json`):
|
||||
`improved_arm` (Tier 1, root) → `advanced_arm` + `improved_storage`
|
||||
(Tier 2, both require the Tier-1). All three are BLUEPRINTS — 60 s
|
||||
each, `effects: {}` — and each declares its matching SHIP-UPGRADE build
|
||||
in `data/builds.json` (`mining-arm-improved` / `mining-arm-advanced` /
|
||||
`mining-storage-improved`, the console's Mining tab) — the build's
|
||||
`requires: ["mining/<node id>"]` is the authoritative gate, the node's
|
||||
`unlocks.builds` the declaration (the test keeps both in lock-step). New
|
||||
glyph set for the tab: `mining`, `mining2`, `storage`
|
||||
(js/research/ResearchIcons.js) — the build rows reuse the same glyphs.
|
||||
|
||||
**Code layering (same rules as the tether):**
|
||||
- `js/research/ResearchModel.js` — PURE (no Phaser): `roots`, `issues`
|
||||
|
|
@ -611,15 +621,17 @@ button appears only when the build is available AND affordable.
|
|||
|
||||
**Division of labor (the Research split, exactly):**
|
||||
- `js/build/BuildModel.js` — PURE (no Phaser): `categories`, `loadBuilds`
|
||||
(category → its builds), `startingPairs` (pre-installed on fresh runs —
|
||||
`tether-l1.starting: ["home"]`), `missingRequirements` (research +
|
||||
`planetRequires.tetherLevel`), `isAvailable`, `rowState` (built /
|
||||
active / available / locked), `costLines`, `canAfford`. Node-tested by
|
||||
(category → its builds), `defById`, `startingPairs` (pre-installed on
|
||||
fresh runs — `tether-l1.starting: ["home"]`), `missingRequirements`
|
||||
(research + `planetRequires.tetherLevel`), `isAvailable`, `rowState`
|
||||
(built / active / available / locked), `costLines`, `canAfford`,
|
||||
`isShipScoped` (`targets: ["ship"]` — the ship upgrades). Node-tested by
|
||||
`dev/builds.test.mjs`.
|
||||
- `js/build/BuildState.js` — PURE: the run's build records (`built`:
|
||||
planet → set of buildIds) + the single in-progress build (`active`,
|
||||
`maxConcurrent: 1`); `start/progress/tick/restoreActive`,
|
||||
`toJSON(now)/fromJSON`. Node-tested.
|
||||
`toJSON(now)/fromJSON`, and `isBuiltAnywhere(build)` (ship-scoped
|
||||
builds count as built if installed on ANY planet). Node-tested.
|
||||
- `js/ui/BuildWindow.js` — the scene-facing window (depth 80, above the
|
||||
save panel). A **passive view**: it asks via `onBuild(buildId)`;
|
||||
`SurfaceScene.beginSurfaceBuild` → `GameScene.beginBuild` enforces the
|
||||
|
|
@ -627,13 +639,19 @@ button appears only when the build is available AND affordable.
|
|||
mineral cost — the full amount paid up front), then `BuildState.start`
|
||||
runs the clock. The window re-renders from `BuildState` + `BuildModel`
|
||||
only.
|
||||
- `GameScene` — `beginBuild`, `completeBuild`, `_applyBuildEffects` (the
|
||||
**effects seam**: `tether {level: N}` → the world's tether strengthens
|
||||
to at least N via `TetherField.setLevel` — never a downgrade — + toast;
|
||||
`capability "flag"` → `scene.researchCapabilities`; unknown shapes log
|
||||
and no-op). The state lives on the GameScene (`buildState`), so the
|
||||
records + the in-progress build outlive the surface stay AND save with
|
||||
the run (`record.builds`).
|
||||
- `GameScene` — `beginBuild` (the ship-scoped gate:
|
||||
`isBuiltAnywhere` for `targets: ["ship"]` builds), `completeBuild`,
|
||||
`_applyBuildEffects` (the **effects seam**: `tether {level: N}` → the
|
||||
world's tether strengthens to at least N via `TetherField.setLevel` —
|
||||
never a downgrade — + toast; `capability "flag"` →
|
||||
`scene.researchCapabilities`; `mining {rate, capacity}` →
|
||||
`_applyMiningUpgrade` — the ship's stats, MAX wins; unknown shapes log
|
||||
and no-op). `_restoreMiningUpgrades()` re-derives the ship's mining
|
||||
stats from the build records in `create()` (the save carries the
|
||||
records, not the stats — same seam as `_rematerializeBuiltTethers`;
|
||||
idempotent, a fresh run has none). The state lives on the GameScene
|
||||
(`buildState`), so the records + the in-progress build outlive the
|
||||
surface stay AND save with the run (`record.builds`).
|
||||
|
||||
**The clock (important — different from research):** the build's time
|
||||
base is the **game-loop clock** (`game.loop.now` — the engine's global
|
||||
|
|
@ -669,6 +687,22 @@ must not un-install the home world's level-1 tether. A player resuming a
|
|||
run always has Tether - Level 1 built on home (pinned in
|
||||
`dev/builds.test.mjs`).
|
||||
|
||||
**Ship-scoped builds (the Mining tab) — one-off installs, ship-wide
|
||||
effect:** `targets: ["ship"]` (the mining arms + storage, data/builds.json
|
||||
→ the Mining category) are installed from any planet's console but the
|
||||
effect is the SHIP's: `effects.mining.rate` → `ship.stats.miningSpeed`
|
||||
(minerals/s — `Mining._extract` multiplies the base economy rate by it,
|
||||
read live per tick) and `effects.mining.capacity` →
|
||||
`ship.stats.mineralStorage` (the hold cap — `Ship.storageRoom` /
|
||||
`addMinerals` read it live, so the corner HUD's `n / CAP` follows). Both
|
||||
are MAX-guarded (the faster arm / bigger hold always wins — building the
|
||||
advanced arm then the improved keeps 2.0/s). "Built" is SHIP-SCOPED:
|
||||
`BuildState.isBuiltAnywhere` (installed on any planet ⇒ built wherever
|
||||
the ship lands — `BuildWindow.ctxFor` + `_detailStatus` + the
|
||||
`GameScene.beginBuild` gate all read it for these builds, so no re-buy on
|
||||
the next world). The records still key the planet they were built from
|
||||
(the save format is unchanged).
|
||||
|
||||
**Name casing (a real bug this caught):** world state is keyed by the
|
||||
world's CANONICAL name (its discovery name — build records, tether
|
||||
labels). The comms panel DISPLAYS names uppercased — and used to carry
|
||||
|
|
@ -690,8 +724,9 @@ normalizes the name against the known worlds before launch
|
|||
`js/main.js`) is a read-only observer — it never mutates state.
|
||||
- Opening the Build console logs a one-line `[orbit-diag v…]` summary:
|
||||
planet, home, `isHome`, the `built` map, the planet's tether level, the
|
||||
L1/L2 row states (with missing gates), a stale-JS probe
|
||||
(`jsSeed=true|false`), and the loaded `tether-l1.starting` field.
|
||||
L1/L2/L3 row states (with missing gates), the ship's mining stats
|
||||
(`mining={rate, hold, cap}`), a stale-JS probe (`jsSeed=true|false`),
|
||||
and the loaded `tether-l1.starting` field.
|
||||
- `orbitDiag()` in DevTools → Console prints the full report: env, JS
|
||||
probes, the `data/builds.json` contract as loaded, home/current
|
||||
planet, built records, tether objects, research state, the save bank's
|
||||
|
|
@ -953,18 +988,21 @@ 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 1–3, Tether Anchoring, Signal Amplification); add a category =
|
||||
Level 1–3, Tether Anchoring, Signal Amplification); 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 =
|
||||
one JSON file + one line in the registry + one line in the manifest
|
||||
(`js/ui/ResearchWindow.js`, `js/research/*`, `data/research.json`,
|
||||
`data/research/exploration.json`, dev/research-builds.test.mjs)
|
||||
- [x] Tether progression: level 2 is a **build** — research
|
||||
`tether_l2` (blueprint, no effect) → BUILD slot on a world holding a
|
||||
level-1 tether → 200 minerals / 20 s → the world's tether strengthens
|
||||
to level 2 (10240 px), effect and all, outliving the stay. Level 3
|
||||
is still research-direct (`effects.tether.level` on the home
|
||||
tether); anchoring extra rings on planets/stations remains
|
||||
- [x] Tether progression: levels 2 and 3 are **builds** — research
|
||||
`tether_l2` / `tether_l3` (blueprint, no effect) → BUILD slot on a
|
||||
world holding a level-1 / level-2 tether → 200 minerals / 20 s (L2)
|
||||
or 300 minerals / 30 s (L3) → the world's tether strengthens to
|
||||
level 2 (10240 px) or level 3 (20480 px), effect and all, outliving
|
||||
the stay. Anchoring extra rings on planets/stations remains
|
||||
(the `add`/`setLevel`/`onChange` seams are in place)
|
||||
(`data/builds.json → tether-l2`, `js/build/*`,
|
||||
(`data/builds.json → tether-l2 / tether-l3`, `js/build/*`,
|
||||
`js/ui/BuildWindow.js`, `dev/builds.test.mjs`)
|
||||
- [ ] Factions & pirates: claim settlements (`owner`), flags, borders,
|
||||
and the player's place in a populated galaxy (the reputation layer
|
||||
|
|
@ -1007,6 +1045,9 @@ The player holds a REPUTATION (standing) on each planet and space station:
|
|||
their remaining time
|
||||
(`js/ui/BuildWindow.js`, `js/build/BuildModel.js`,
|
||||
`js/build/BuildState.js`, `data/builds.json`, dev/builds.test.mjs)
|
||||
— incl. the Mining tab: three ship-scoped upgrades (the improved /
|
||||
advanced mining arm → 1.5 / 2.0 minerals per second, the improved
|
||||
storage → 350 hold), 200 / 300 / 200 minerals, 20 s each
|
||||
- [x] Ship base stats: `data/ship.json → stats` (hullIntegrity 100,
|
||||
shields 0, cargoHold 100, mineralStorage 250), exposed as
|
||||
`ship.stats` — combat/trading/mining systems will read them as
|
||||
|
|
|
|||
|
|
@ -68,6 +68,17 @@ export function defById(id) {
|
|||
return Object.prototype.hasOwnProperty.call(defs, id) ? defs[id] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SHIP-SCOPED build (data/builds.json → targets: ["ship"]): the effect
|
||||
* is on the ship (the mining arms + storage), not the planet — installed
|
||||
* from any planet's console, counted as BUILT wherever the ship lands
|
||||
* (BuildState.isBuiltAnywhere), never re-bought. Planet-scoped builds
|
||||
* (targets: ["planet"]) are the ordinary one-off surface installs.
|
||||
*/
|
||||
export function isShipScoped(def) {
|
||||
return Array.isArray(def?.targets) && def.targets.includes('ship');
|
||||
}
|
||||
|
||||
/**
|
||||
* [planet, buildId] pairs pre-installed on a fresh run — the home world
|
||||
* starts with its level-1 tether (data/builds.json → tether-l1.starting).
|
||||
|
|
|
|||
|
|
@ -45,6 +45,19 @@ export class BuildState {
|
|||
return !!(set && build && set.has(build));
|
||||
}
|
||||
|
||||
/**
|
||||
* SHIP-SCOPED builds (data/builds.json → targets: ["ship"], e.g. the
|
||||
* mining arms/storage): the effect is on the ship, not the planet — so
|
||||
* "already built" is true if it was installed on ANY planet. The
|
||||
* console shows such a build BUILT wherever the ship lands (no
|
||||
* re-buy), and the scene's beginBuild gate mirrors this check.
|
||||
*/
|
||||
isBuiltAnywhere(build) {
|
||||
if (!build) return false;
|
||||
for (const set of this.built.values()) if (set.has(build)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
buildsOn(planet) {
|
||||
return [...(this.built.get(planet) ?? [])];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@
|
|||
* check again. The version marker below changes with each revision.
|
||||
*/
|
||||
import { config } from '../config/Config.js';
|
||||
import { defById, rowState, missingRequirements } from '../build/BuildModel.js';
|
||||
import { defById, rowState, missingRequirements, isShipScoped } from '../build/BuildModel.js';
|
||||
|
||||
export const DIAG_V = 3;
|
||||
export const DIAG_V = 4;
|
||||
|
||||
const _errors = [];
|
||||
const _errCap = (msg) => {
|
||||
|
|
@ -58,16 +58,22 @@ function tetherFor(gs, name) {
|
|||
|
||||
/**
|
||||
* The exact row state the Build window paints for one build id, using
|
||||
* the same pure functions and context as js/ui/BuildWindow.js.
|
||||
* the same pure functions and context as js/ui/BuildWindow.js (SHIP-
|
||||
* scoped builds — the mining arms/storage — read isBuiltAnywhere, the
|
||||
* window's ctx does too).
|
||||
*/
|
||||
function rowFor(gs, ss, id) {
|
||||
const planet = ss?.planetName ?? homeNameOf(gs);
|
||||
const def = defById(id);
|
||||
if (!def || !planet) return 'NO DEF/PLANET';
|
||||
const shipScoped = isShipScoped(def);
|
||||
const ctx = {
|
||||
isResearchUnlocked: (c, n) => gs?.researchState?.isUnlocked?.(c, n) ?? false,
|
||||
tetherLevel: () => gs?.tetherLevelFor?.(planet) ?? 0,
|
||||
isBuilt: () => gs?.buildState?.isBuilt?.(planet, id) ?? false,
|
||||
isBuilt: () =>
|
||||
shipScoped
|
||||
? gs?.buildState?.isBuiltAnywhere?.(id) ?? false
|
||||
: gs?.buildState?.isBuilt?.(planet, id) ?? false,
|
||||
};
|
||||
const active = gs?.buildState?.getActive?.();
|
||||
const activeOnPlanet = active && active.planet === planet ? active : null;
|
||||
|
|
@ -119,9 +125,23 @@ function dataFacts() {
|
|||
planetRequires: l2.planetRequires ?? null,
|
||||
}
|
||||
: 'NOT IN DATA (stale data/builds.json?)';
|
||||
for (const id of ['mining-arm-improved', 'mining-arm-advanced', 'mining-storage-improved']) {
|
||||
const d = defById(id);
|
||||
out[id] = d
|
||||
? { cost: d.cost ?? null, duration: d.duration ?? null, requires: d.requires ?? null, targets: d.targets ?? null, effects: d.effects ?? null }
|
||||
: 'NOT IN DATA (stale data/builds.json?)';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The ship's derived mining stats (the build effects land here). */
|
||||
function miningStatsOf(gs) {
|
||||
const st = gs?.ship?.stats;
|
||||
return st
|
||||
? { rate: st.miningSpeed ?? null, hold: gs?.ship?.minerals ?? null, cap: st.mineralStorage ?? null }
|
||||
: null;
|
||||
}
|
||||
|
||||
/** One-line summary — the same facts a support request needs. */
|
||||
export function brief() {
|
||||
const gs = sceneOf('GameScene');
|
||||
|
|
@ -137,6 +157,8 @@ export function brief() {
|
|||
`builtMap=${JSON.stringify(bm ?? null)} ` +
|
||||
`tether=${t ? `${t.id}:L${t.level}` : 'NONE'} ` +
|
||||
`L1=${rowFor(gs, ss, 'tether-l1')} L2=${rowFor(gs, ss, 'tether-l2')} ` +
|
||||
`L3=${rowFor(gs, ss, 'tether-l3')} ` +
|
||||
`mining=${JSON.stringify(miningStatsOf(gs) ?? null)} ` +
|
||||
`jsSeed=${typeof gs?._seedStartingBuilds === 'function'} ` +
|
||||
`dataL1starting=${JSON.stringify(defById('tether-l1')?.starting ?? null)} ` +
|
||||
`errors=${_errors.length}`;
|
||||
|
|
@ -167,10 +189,21 @@ export function full() {
|
|||
`[state] builtMap=${JSON.stringify(builtMapOf(gs) ?? null)}`,
|
||||
` homeTether=${JSON.stringify(tetherFor(gs, home) ?? null)} ` +
|
||||
`planetTether=${JSON.stringify(tetherFor(gs, planet) ?? null)}`,
|
||||
` miningStats=${JSON.stringify(miningStatsOf(gs) ?? null)}`,
|
||||
` researchTetherL2=${gs?.researchState?.isUnlocked?.('exploration', 'tether_l2') ?? 'n/a'} ` +
|
||||
`researchTetherL3=${gs?.researchState?.isUnlocked?.('exploration', 'tether_l3') ?? 'n/a'} ` +
|
||||
`researchMining=${JSON.stringify({
|
||||
improved_arm: gs?.researchState?.isUnlocked?.('mining', 'improved_arm') ?? 'n/a',
|
||||
advanced_arm: gs?.researchState?.isUnlocked?.('mining', 'advanced_arm') ?? 'n/a',
|
||||
improved_storage: gs?.researchState?.isUnlocked?.('mining', 'improved_storage') ?? 'n/a',
|
||||
})} ` +
|
||||
`activeBuild=${JSON.stringify(gs?.buildState?.getActive?.() ?? null)}`,
|
||||
`[rows] L1=${rowFor(gs, ss, 'tether-l1')}`,
|
||||
` L2=${rowFor(gs, ss, 'tether-l2')}`,
|
||||
` L3=${rowFor(gs, ss, 'tether-l3')}`,
|
||||
` ARM-I=${rowFor(gs, ss, 'mining-arm-improved')}`,
|
||||
` ARM-A=${rowFor(gs, ss, 'mining-arm-advanced')}`,
|
||||
` STORE=${rowFor(gs, ss, 'mining-storage-improved')}`,
|
||||
`[saves] ${JSON.stringify(saveBank())}`,
|
||||
`[scenes] ${JSON.stringify(scenes)}`,
|
||||
`[errors since boot] ${_errors.length ? _errors.join(' | ') : 'none'}`,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
import { toColor } from '../utils/Color.js';
|
||||
|
||||
export const ICON_NAMES = ['tether', 'tether2', 'anchor', 'signal', 'diamond', 'map', 'gate'];
|
||||
export const ICON_NAMES = ['tether', 'tether2', 'tether3', 'mining', 'mining2', 'storage', 'anchor', 'signal', 'diamond', 'map', 'gate'];
|
||||
|
||||
export function iconKey(name, color = 0x00e5ff) {
|
||||
const n = ICON_NAMES.includes(name) ? name : 'diamond';
|
||||
|
|
@ -96,6 +96,111 @@ export function ensureIcon(scene, name, color = 0x00e5ff) {
|
|||
}
|
||||
diamond(cx + 52 * Math.cos(-Math.PI / 3), cy + 52 * Math.sin(-Math.PI / 3), 8);
|
||||
diamond(cx + 52 * Math.cos(Math.PI / 3), cy + 52 * Math.sin(Math.PI / 3), 8);
|
||||
} else if (n === 'tether3') {
|
||||
// Level-3 tether: two bright concentric level rings + core + tick
|
||||
// marks + THREE satellites riding the outer ring (L1 one, L2 two).
|
||||
const rings = [
|
||||
[52, 1.0],
|
||||
[34, 0.85],
|
||||
];
|
||||
for (const [r, a] of rings) {
|
||||
ctx.globalAlpha = a;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
for (const a of [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2]) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + Math.cos(a) * 55, cy + Math.sin(a) * 55);
|
||||
ctx.lineTo(cx + Math.cos(a) * 61, cy + Math.sin(a) * 61);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (const a of [-Math.PI / 2, Math.PI / 6, (5 * Math.PI) / 6]) {
|
||||
diamond(cx + 52 * Math.cos(a), cy + 52 * Math.sin(a), 8);
|
||||
}
|
||||
} else if (n === 'mining') {
|
||||
// Improved Mining Arm: a drill bit (shaft + diamond tip) striking an
|
||||
// asteroid, chips flying.
|
||||
ctx.globalAlpha = 0.9;
|
||||
ctx.beginPath();
|
||||
ctx.arc(74, 74, 26, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(82, 66, 5, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.arc(68, 84, 4, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(24, 24);
|
||||
ctx.lineTo(52, 52);
|
||||
ctx.stroke();
|
||||
diamond(58, 58, 9);
|
||||
diamond(88, 42, 5);
|
||||
diamond(44, 90, 4);
|
||||
} else if (n === 'mining2') {
|
||||
// Advanced Mining Arm: the same drill, plus speed lines behind the
|
||||
// shaft (twice as fast) and one more chip flying.
|
||||
ctx.globalAlpha = 0.55;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(32, 14);
|
||||
ctx.lineTo(50, 32);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(14, 32);
|
||||
ctx.lineTo(32, 50);
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 0.9;
|
||||
ctx.beginPath();
|
||||
ctx.arc(74, 74, 26, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(82, 66, 5, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.arc(68, 84, 4, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(24, 24);
|
||||
ctx.lineTo(50, 50);
|
||||
ctx.stroke();
|
||||
diamond(57, 57, 10);
|
||||
diamond(88, 42, 5);
|
||||
diamond(44, 90, 4);
|
||||
diamond(96, 62, 4);
|
||||
} else if (n === 'storage') {
|
||||
// Improved Mining Storage: a hex container with a stack of mineral
|
||||
// diamonds inside + a growth arrow breaking out the top (more room).
|
||||
ctx.globalAlpha = 0.95;
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = -Math.PI / 2 + (i * Math.PI) / 3;
|
||||
const x = 64 + 38 * Math.cos(a);
|
||||
const y = 70 + 38 * Math.sin(a);
|
||||
if (i) ctx.lineTo(x, y);
|
||||
else ctx.moveTo(x, y);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(64, 44);
|
||||
ctx.lineTo(64, 20);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(56, 28);
|
||||
ctx.lineTo(64, 20);
|
||||
ctx.lineTo(72, 28);
|
||||
ctx.stroke();
|
||||
diamond(64, 74, 11);
|
||||
diamond(48, 88, 6);
|
||||
diamond(80, 88, 6);
|
||||
} else if (n === 'anchor') {
|
||||
// Anchor ring + stock + crossbar + flukes (the relay bolted to a body).
|
||||
ctx.globalAlpha = 0.9;
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ import {
|
|||
} from '../research/SystemCategory.js';
|
||||
import { BuildWindow } from '../ui/BuildWindow.js';
|
||||
import { BuildState } from '../build/BuildState.js';
|
||||
import { startingPairs, defById } from '../build/BuildModel.js';
|
||||
import { startingPairs, defById, isShipScoped } from '../build/BuildModel.js';
|
||||
|
||||
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
||||
const HEADER_FONT = () => fontStack('header', FONT_FALLBACK);
|
||||
|
|
@ -693,6 +693,11 @@ export class GameScene extends Phaser.Scene {
|
|||
// a jump cut / scene restart rebuilds the field to the native set
|
||||
// only (idempotent: existing tethers are only ever strengthened).
|
||||
this._rematerializeBuiltTethers();
|
||||
// The ship's MINING upgrades are derived the same way (build records
|
||||
// are the source of truth — the save carries them, not the stats):
|
||||
// a load / jump-cut re-asserts the arm rate + hold cap the player
|
||||
// installed (idempotent; a fresh run has none).
|
||||
this._restoreMiningUpgrades();
|
||||
|
||||
// Entering a system charts it: the SYSTEM category's '{System} Map'
|
||||
// tech (duration 0) is owned on arrival — a new run gets the starting
|
||||
|
|
@ -2559,7 +2564,14 @@ export class GameScene extends Phaser.Scene {
|
|||
const def = buildDefs()[buildId];
|
||||
if (!state || !def) return { ok: false, reason: 'BUILD SYSTEM OFFLINE' };
|
||||
if (state.getActive()) return { ok: false, reason: 'A BUILD IS ALREADY IN PROGRESS' };
|
||||
if (state.isBuilt(planetName, buildId)) {
|
||||
// SHIP-scoped builds (targets: ["ship"] — the mining arms/storage) are
|
||||
// installed ON THE SHIP: already built on ANY planet counts (the
|
||||
// console shows them BUILT everywhere — no re-buy).
|
||||
if (isShipScoped(def)) {
|
||||
if (state.isBuiltAnywhere(buildId)) {
|
||||
return { ok: false, reason: 'ALREADY INSTALLED ON THE SHIP' };
|
||||
}
|
||||
} else if (state.isBuilt(planetName, buildId)) {
|
||||
return { ok: false, reason: 'ALREADY INSTALLED ON THIS WORLD' };
|
||||
}
|
||||
// research gate — the blueprint must be researched (the authoritative
|
||||
|
|
@ -2651,12 +2663,78 @@ export class GameScene extends Phaser.Scene {
|
|||
} else if (type === 'capability' && typeof spec === 'string') {
|
||||
this.researchCapabilities = this.researchCapabilities ?? new Set();
|
||||
this.researchCapabilities.add(spec);
|
||||
} else if (type === 'mining' && spec && typeof spec === 'object') {
|
||||
// SHIP upgrades (the mining arms/storage): effects.mining.rate =
|
||||
// the ship's mining speed (minerals/s), effects.mining.capacity =
|
||||
// the mineral hold's cap. MAX wins — never a downgrade.
|
||||
this._applyMiningUpgrade(spec);
|
||||
} else {
|
||||
console.warn(`[orbit] build: unknown effect ${type}`, spec);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a mining SHIP upgrade (a build's effects.mining, data/builds.json):
|
||||
* `rate` → stats.miningSpeed (minerals/s — Mining multiplies the base
|
||||
* economy rate by it), `capacity` → stats.mineralStorage (the hold's
|
||||
* cap — ship.storageRoom / the corner HUD read n / cap). MAX wins: a
|
||||
* slower arm or a smaller hold is never installed over a better one
|
||||
* (build the advanced arm, then the improved — the ship keeps 2.0/s).
|
||||
*/
|
||||
_applyMiningUpgrade(spec) {
|
||||
const ship = this.ship;
|
||||
if (!ship?.stats) return;
|
||||
if (Number.isFinite(Number(spec.rate))) {
|
||||
const rate = Math.max(0, Number(spec.rate));
|
||||
if (rate > (Number(ship.stats.miningSpeed) || 0)) {
|
||||
ship.stats.miningSpeed = rate;
|
||||
this.consoleToast(`MINING ARM UPGRADED — ${rate} PER SECOND`, {
|
||||
glyph: '▲',
|
||||
glyphColor: toCss(themeColor('amber', 0xffc94d)),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (Number.isFinite(Number(spec.capacity))) {
|
||||
const cap = Math.max(0, Math.round(Number(spec.capacity)));
|
||||
if (cap > (Number(ship.stats.mineralStorage) || 0)) {
|
||||
ship.stats.mineralStorage = cap;
|
||||
this.refreshMineralHud(); // the corner readout is n / CAP — push the new cap
|
||||
this.consoleToast(`MINERAL STORAGE EXPANDED — ${cap} CAPACITY`, {
|
||||
glyph: '▲',
|
||||
glyphColor: toCss(themeColor('amber', 0xffc94d)),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive the ship's mining upgrades from the build RECORDS (the
|
||||
* save carries the records, not the ship's derived stats): the MAX
|
||||
* effects.mining.rate / capacity across every installed build (any
|
||||
* planet). Idempotent + MAX-guarded — a fresh run has no mining
|
||||
* records and keeps its base stats; a load / jump-cut restore re-asserts
|
||||
* them. Runs in create(), right after the built tethers are
|
||||
* re-anchored (same seam: world/ship state re-derived from the
|
||||
* outliving build records).
|
||||
*/
|
||||
_restoreMiningUpgrades() {
|
||||
if (!this.buildState || !this.ship?.stats) return;
|
||||
let rate = 0;
|
||||
let capacity = 0;
|
||||
for (const ids of this.buildState.built.values()) {
|
||||
for (const id of ids) {
|
||||
const spec = defById(id)?.effects?.mining;
|
||||
if (!spec) continue;
|
||||
if (Number.isFinite(Number(spec.rate))) rate = Math.max(rate, Number(spec.rate));
|
||||
if (Number.isFinite(Number(spec.capacity))) capacity = Math.max(capacity, Number(spec.capacity));
|
||||
}
|
||||
}
|
||||
if (rate > (Number(this.ship.stats.miningSpeed) || 0)) this.ship.stats.miningSpeed = rate;
|
||||
if (capacity > (Number(this.ship.stats.mineralStorage) || 0)) this.ship.stats.mineralStorage = capacity;
|
||||
if (rate > 0 || capacity > 0) this.refreshMineralHud(); // the HUD was pushed with the FRESH hold earlier in create()
|
||||
}
|
||||
|
||||
/** The RESEARCH slot's progress bar (update() calls it every frame). */
|
||||
_deckResearchBar(time) {
|
||||
const bar = this._researchDeckBar;
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import {
|
|||
costLines,
|
||||
canAfford,
|
||||
defById,
|
||||
isShipScoped,
|
||||
} from '../build/BuildModel.js';
|
||||
import { ensureIcon } from '../research/ResearchIcons.js';
|
||||
|
||||
|
|
@ -185,12 +186,20 @@ export class BuildWindow extends Phaser.GameObjects.Container {
|
|||
playSfxOn(this.scene, name);
|
||||
}
|
||||
|
||||
/** The caller's view of the world for one build (BuildModel ctx). */
|
||||
/** The caller's view of the world for one build (BuildModel ctx).
|
||||
* SHIP-scoped builds (targets: ["ship"] — the mining arms/storage)
|
||||
* count as built wherever the ship landed (BuildState.isBuiltAnywhere)
|
||||
* — the effect is on the ship, so "built on this planet" would let a
|
||||
* re-buy happen on every other world. */
|
||||
ctxFor(buildId) {
|
||||
const shipScoped = isShipScoped(defById(buildId));
|
||||
return {
|
||||
isResearchUnlocked: (c, n) => this.researchUnlocked(c, n),
|
||||
tetherLevel: () => this.tetherLevel(),
|
||||
isBuilt: () => this.state?.isBuilt(this.planetName, buildId) ?? false,
|
||||
isBuilt: () =>
|
||||
shipScoped
|
||||
? this.state?.isBuiltAnywhere(buildId) ?? false
|
||||
: this.state?.isBuilt(this.planetName, buildId) ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1075,8 +1084,8 @@ export class BuildWindow extends Phaser.GameObjects.Container {
|
|||
if (active.planet === this.planetName && active.build === sel.id) return 'active';
|
||||
return 'busy';
|
||||
}
|
||||
if (state.isBuilt(this.planetName, sel.id)) return 'built';
|
||||
const def = this._selDef();
|
||||
if (isShipScoped(def) ? state.isBuiltAnywhere(sel.id) : state.isBuilt(this.planetName, sel.id)) return 'built';
|
||||
if (def && isAvailable(def, this.ctxFor(sel.id))) {
|
||||
return canAfford(def, this.minerals()) ? 'build' : 'unaffordable';
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue