Add jump gate network and in-system gate placement

- Introduce data/gates.json + js/galaxy/JumpNetwork.js: a strongly-connected, degree-limited spanning tree over the nearest-star graph (plus local shortcuts) so every system holds 1–3 gates, all jumps are local, and no system is closed or trapped
- Place each gate on the tether circle of an anchor (planet / space station / home world), facing its destination star; enforce radius band, clearance from anchors, and gap between gates in SystemGenerator.layoutGates
- Add the JumpGate entity (procedural twin-pylon portal) with the same solid contract as Planet/Station, discoverable in cyan, listed in the HUD dossier; wire into GameScene solids + discovery
- Guarantee every system holds at least one gate anchor (barren systems get a deep-space station); cap the home system at 3 objects so it fits the tighter band
- Rework solar-system layout to a full pairwise band [minSpacing, maxSpacing] with rotation tuned toward gate bearings; update tests and docs accordingly
This commit is contained in:
Brian Fertig 2026-09-06 11:56:26 -06:00
parent ed890a35c3
commit a1aa25b5ba
21 changed files with 1559 additions and 198 deletions

View File

@ -67,7 +67,10 @@ node dev/server.mjs 8080
planet and a station never with another station. planet and a station never with another station.
- Game screen with a top-down ship (real spritesheet art — frame 0 of - Game screen with a top-down ship (real spritesheet art — frame 0 of
`assets/images/ships-player.png`, see `data/ship.json`): **click anywhere to fly there** `assets/images/ships-player.png`, see `data/ship.json`): **click anywhere to fly there**
in the current system's open space (system boundaries/jumps come next). in the current system's open space. Every system also holds 13 JUMP
GATES — solid, discoverable exits that face their destination star —
wired into a strongly-connected gate network (data/gates.json);
the jump drive itself comes next.
Discovered worlds get a screen-edge arrow + a name tag showing the Discovered worlds get a screen-edge arrow + a name tag showing the
world's **name** (e.g. `HOME WORLD · ESHKAELURA`); **clicking the world's **name** (e.g. `HOME WORLD · ESHKAELURA`); **clicking the
name tag autopilots the ship there** (it arrives on the keep-out rim, name tag autopilots the ship there** (it arrives on the keep-out rim,
@ -146,6 +149,7 @@ orbit/
│ ├── galaxy.json # galaxy scale & shape (count, radius, spiral…) │ ├── galaxy.json # galaxy scale & shape (count, radius, spiral…)
│ ├── systems.json # system archetypes: theme, attributes, distribution │ ├── systems.json # system archetypes: theme, attributes, distribution
│ ├── settlements.json # the lived-in layer: settlement kinds & populations │ ├── settlements.json # the lived-in layer: settlement kinds & populations
│ ├── gates.json # JUMP GATES: network (13 gates, local jumps, strong connectivity) + placement (tether anchor, facing, radii, gaps)
│ ├── research.json # RESEARCH: global rules (time unit, one at a time) + category registry │ ├── research.json # RESEARCH: global rules (time unit, one at a time) + category registry
│ │ # trees live one-per-file below (section name = file basename) │ │ # trees live one-per-file below (section name = file basename)
│ ├── research/ │ ├── research/
@ -160,8 +164,8 @@ orbit/
│ ├── main.js # entry point: load config → boot Phaser │ ├── main.js # entry point: load config → boot Phaser
│ ├── config/ # Config singleton, ConfigLoader, game config │ ├── config/ # Config singleton, ConfigLoader, game config
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration) │ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
│ ├── entities/ # Ship (own behavior), Planet (home world, solid) │ ├── entities/ # Ship (own behavior), Planet (home world, solid), Station, JumpGate (solids)
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport │ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport, JumpNetwork (the gate network)
│ ├── tether/ # Tether (pure range math) + TetherField (constraint + barrier line) │ ├── tether/ # Tether (pure range math) + TetherField (constraint + barrier line)
│ ├── research/ # ResearchModel (pure tree rules/layout), ResearchState (unlocks + active run), ResearchIcons │ ├── research/ # ResearchModel (pure tree rules/layout), ResearchState (unlocks + active run), ResearchIcons
│ ├── build/ # BuildModel (pure build rules), BuildState (installed + in-progress records) │ ├── build/ # BuildModel (pure build rules), BuildState (installed + in-progress records)
@ -234,7 +238,9 @@ runtime data — loaders and tests ignore them.
```sh ```sh
node dev/ship-behavior.test.mjs # runs the real Ship.update() loop in Node node dev/ship-behavior.test.mjs # runs the real Ship.update() loop in Node
node dev/starfield.test.mjs # runs the real Starfield.create() in Node node dev/starfield.test.mjs # runs the real Starfield.create() in Node
node dev/galaxy.test.mjs # galaxy determinism, distribution, lazy vs eager node dev/galaxy.test.mjs # galaxy determinism, distribution, lazy vs eager, gate anchors + report
node dev/jumps.test.mjs # the gate network: 13 gates, locality, strong connectivity + placement invariants
node dev/jumpgate.test.mjs # the JumpGate entity: discovery fields + the solid contract (Node)
node dev/discovery.test.mjs # discovery rules + compass geometry + chip hit test node dev/discovery.test.mjs # discovery rules + compass geometry + chip hit test
node dev/tether.test.mjs # tether range math: union, clamp, visible arcs (no line in overlaps) node dev/tether.test.mjs # tether range math: union, clamp, visible arcs (no line in overlaps)
node dev/research-builds.test.mjs # data contract: research/builds/actionbar shapes + manifest node dev/research-builds.test.mjs # data contract: research/builds/actionbar shapes + manifest

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

17
data/gates.json Normal file
View File

@ -0,0 +1,17 @@
{
"_comment": "JUMP GATES — the galaxy's highway layer (js/galaxy/JumpNetwork.js for the network, js/galaxy/SystemGenerator.js → layoutGates for the in-system placement). NETWORK: every system holds between minGates and maxGates jump gates; every gate jumps to one of the system's `neighborPool` nearest stars on the 2-D map (the roster's x/y plane). The network is a degree-limited spanning tree of that neighbor graph (tree edges run BOTH ways) plus optional one-way `shortcuts` bought with the spare gate budget — so the directed graph is strongly connected (from any star you can reach any other: no closed systems, no trapped sets), every jump is to a nearby star, and no system ever holds more than maxGates gates. PLACEMENT: each gate sits within `anchorTetherLevel` tether range of an ANCHOR — a planet, a free-space station, or the home world in the starting system — EXACTLY that range from it (the radius comes from data/tether.json — level 1 = 5120 px), so the player can reach it on a starting tether from that world; the tether rule is hard by construction. Candidates are tried in facing quality (ray-circle intersection → circle point aimed at the star → ±75° scan) so each gate is on the target side of its anchor (within 90° of the system→star bearing) — the direction rule is soft, so a gate reads as 'facing that star' without the exact ray always being free. A gate keeps `size` + `clearance` (px, center-to-center) from any anchor disc, 2·size + `gateGap` from any other gate, and stays between `minRadius` and `maxRadius` from the star. size = the gate's keepout radius (px). shipClearance = the ship's keepout from the gate (the solid rule, like planets). theme.color = the HUD/compass color. typeLabel = the discovery/compass label.",
"enabled": true,
"minGates": 1,
"maxGates": 3,
"neighborPool": 8,
"shortcuts": true,
"size": 96,
"shipClearance": 50,
"clearance": 256,
"gateGap": 192,
"minRadius": 2048,
"maxRadius": 20480,
"anchorTetherLevel": 1,
"typeLabel": "Jump Gate",
"theme": { "color": "#5fd4ff" }
}

View File

@ -43,8 +43,11 @@
{ "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-01.mp4", "shop": "terran-shop-01.mp4" }, { "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "terran-land-02.mp4", "surface": "terran-surface-02.mp4", "takeoff": "terran-takeoff-02.mp4", "shop": "terran-shop-01.mp4" }, { "land": "terran-land-02.mp4", "surface": "terran-surface-02.mp4", "takeoff": "terran-takeoff-02.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-01.mp4", "shop": "terran-shop-01.mp4" }, { "land": "terran-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "terran-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "gasgiant-land-01.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" }, { "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "gasgiant-land-02.mp4", "surface": "terran-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "terran-shop-01.mp4" }, { "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "gasgiant-land-03.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "terran-shop-01.mp4" } { "land": "gasgiant-land-03.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "rocky-land-03.mp4", "surface": "terran-surface-01.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "terran-shop-01.mp4" }
] ]
} }

View File

@ -11,6 +11,7 @@
"systems.json", "systems.json",
"settlements.json", "settlements.json",
"stations.json", "stations.json",
"gates.json",
"reputation.json", "reputation.json",
"naming.json", "naming.json",
"research.json", "research.json",

View File

@ -1,5 +1,5 @@
{ {
"_comment": "Planet visuals + home-planet rules + the solar system's layout. texture is a spritesheet of frameWidth×frameHeight frames (frame 0 = top-left); frames maps a planet kind to the sheet frames it may be drawn as — the generator picks one per planet (seed-deterministic). homePlanet is the player's world — always present at the origin of the system they start in. scale = world pixels per sheet pixel (1.0 = 1:1, so a Terran world is 1024 px across on screen). A planet is a solid disc: the ship may approach to shipClearance px (edge-to-edge) from its rim but can never cross it. spawnDistanceFromEdge = how far (edge-to-edge) the ship starts from the home world's rim. classScale = size multiplier per planet class (1.0 = 1024 px across); classTint = optional canvas tint per class (multiplicative — the terran art stands in for every kind until real art lands). solarSystem = the top-down layout: the system's other objects (planets + free-space stations) sit on one or two orbits (rings) around the origin — center-to-center at least minSpacing px from every other object (incl. the origin's home world), and, whenever the system holds more than one object, every object within maxNeighbor px of at least one other object. minSpacing/maxNeighbor are center-to-center px (the same unit as the 1024 px world disc).", "_comment": "Planet visuals + home-planet rules + the solar system's layout. texture is a spritesheet of frameWidth×frameHeight frames (frame 0 = top-left); frames maps a planet kind to the sheet frames it may be drawn as — the generator picks one per planet (seed-deterministic). homePlanet is the player's world — always present at the origin of the system they start in. scale = world pixels per sheet pixel (1.0 = 1:1, so a Terran world is 1024 px across on screen). A planet is a solid disc: the ship may approach to shipClearance px (edge-to-edge) from its rim but can never cross it. spawnDistanceFromEdge = how far (edge-to-edge) the ship starts from the home world's rim. classScale = size multiplier per planet class (1.0 = 1024 px across); classTint = optional canvas tint per class (multiplicative — the terran art stands in for every kind until real art lands). solarSystem = the top-down LAYOUT BAND (js/galaxy/SystemGenerator.js → layoutSystem): every PAIR of layout objects — planets, free-space stations, and the central body (the star, or the home world in the starting system — at the local origin) — sits at least minSpacing and at most the maximum apart, center to center. Normal systems: [minSpacing, maxSpacing] = 6400..15360 px. The home system: [minSpacing, homeMaxSpacing] = 6400..10240 px, and it is capped at 3 objects — 5 points (home world + 4) cannot sit 6400..10240 px apart: the tightest 5-point spacing needs a max/min ratio ≥ φ ≈ 1.618 > 1.6. Normal systems lay out as a regular N-gon ring; the home system as a regular (N+1)-polygon with the home world as one vertex. The rotation biases toward the jump-gate directions (data/gates.json). minSpacing/maxSpacing/homeMaxSpacing are center-to-center px (the same unit as the 1024 px world disc).",
"texture": "assets/images/planets.png", "texture": "assets/images/planets.png",
"frameWidth": 1024, "frameWidth": 1024,
"frameHeight": 1024, "frameHeight": 1024,
@ -37,7 +37,8 @@
"spawnDistanceFromEdge": 150, "spawnDistanceFromEdge": 150,
"solarSystem": { "solarSystem": {
"enabled": true, "enabled": true,
"minSpacing": 6144, "minSpacing": 6400,
"maxNeighbor": 10240 "maxSpacing": 15360,
"homeMaxSpacing": 10240
} }
} }

View File

@ -7,13 +7,15 @@
* - the DISCOVERY RULE (data/game.json discovery.distance): within that * - the DISCOVERY RULE (data/game.json discovery.distance): within that
* many px of an object's EDGE discovered, exactly once, per system; * many px of an object's EDGE discovered, exactly once, per system;
* state round-trips through toJSON/fromJSON (saves-ready); * state round-trips through toJSON/fromJSON (saves-ready);
* - the SOLAR SYSTEM LAYOUT (data/planets.json solarSystem): the * - the SOLAR SYSTEM LAYOUT (data/planets.json solarSystem): every
* system's planets + free-space stations (+ the origin's home world) * pair of layout objects the system's planets + free-space
* sit on orbits around the origin, no pair closer than minSpacing * stations + the central body (the origin's home world, or the star
* (center-to-center) and whenever a system holds more than one * elsewhere) sits in the band [minSpacing, maxSpacing] px
* object every object within maxNeighbor of at least one other, * center-to-center (the home system's band tightens to
* planets scaled by class deterministically (same seed same * [minSpacing, homeMaxSpacing] and it holds 3 objects 5 points
* layout, different seed different); * cannot sit 6400..10240 px apart), planets scaled by class
* deterministically (same seed same layout, different seed
* different);
* - the COMPASS geometry (js/ui/DiscoveryCompass.js): edgeAnchor lands on * - the COMPASS geometry (js/ui/DiscoveryCompass.js): edgeAnchor lands on
* the screen-edge rect (edges AND corners), circleInView is exact, * the screen-edge rect (edges AND corners), circleInView is exact,
* lerpAngle always takes the short arc; * lerpAngle always takes the short arc;
@ -109,16 +111,20 @@ check('toJSON/fromJSON round-trips (saves-ready)', restored.distance === D && re
const band = config.get('planets.solarSystem'); const band = config.get('planets.solarSystem');
const MIN_SEP = band.minSpacing; const MIN_SEP = band.minSpacing;
const MAX_NBR = band.maxNeighbor; const MAX_SP = band.maxSpacing; // normal systems: the band's maximum
const HOME_MAX = band.homeMaxSpacing; // the home system's tighter maximum
const g = Galaxy.create('discovery-layout-test'); const g = Galaxy.create('discovery-layout-test');
let layoutOk = true; let layoutOk = true;
let layoutWhy = ''; let layoutWhy = '';
let sawMaxObjects = false; // the N = 6 maximum (4 planets + 2 stations) let sawMaxObjects = false; // the N = 6 maximum (4 planets + 2 stations)
let maxObjectsSeen = 0; let maxObjectsSeen = 0;
let homeOk = true;
const probe = (systems) => { const probe = (systems) => {
for (const rec of systems) { for (const rec of systems) {
const c = g.ensureContent(rec.id); const c = g.ensureContent(rec.id);
const isHome = rec.id === g.currentSystemId;
const MAX = isHome ? HOME_MAX : MAX_SP;
for (const p of c.planets) { for (const p of c.planets) {
if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.scale)) { if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.scale)) {
layoutOk = false; layoutOk = false;
@ -131,7 +137,8 @@ const probe = (systems) => {
layoutWhy = `${rec.id}: ${p.class} world scaled ${p.scale} ≠ classScale ${want}`; layoutWhy = `${rec.id}: ${p.class} world scaled ${p.scale} ≠ classScale ${want}`;
} }
} }
// Layout objects: the home world (origin) + planets + free-space // Layout objects: the central body (origin — the home world in the
// starting system, the star elsewhere) + planets + free-space
// stations (planet-bound settlements sit ON their planet — not // stations (planet-bound settlements sit ON their planet — not
// layout objects of their own). // layout objects of their own).
const objs = [{ x: 0, y: 0 }]; const objs = [{ x: 0, y: 0 }];
@ -148,27 +155,24 @@ const probe = (systems) => {
const nObjects = objs.length - 1; const nObjects = objs.length - 1;
if (nObjects > maxObjectsSeen) maxObjectsSeen = nObjects; if (nObjects > maxObjectsSeen) maxObjectsSeen = nObjects;
if (nObjects === 6) sawMaxObjects = true; if (nObjects === 6) sawMaxObjects = true;
// 1) No two objects closer than minSpacing (center to center). // The home system holds at most 3 objects (its 2 fixed planets + at
// most one free-space station) — 5 points cannot sit 6400..10240 px
// apart (the tightest 5-point spacing needs ratio ≥ φ > 1.6).
if (isHome) {
if (nObjects > 3 || c.planets.length !== 2) {
homeOk = false;
if (layoutOk) layoutWhy = `${rec.id}: home system holds ${nObjects} objects`;
}
}
// The SOLAR SYSTEM BAND: every pair of layout objects — incl. the
// central body — sits in [minSpacing, the system's maximum],
// center to center.
for (let i = 0; i < objs.length && layoutOk; i++) { for (let i = 0; i < objs.length && layoutOk; i++) {
for (let j = i + 1; j < objs.length; j++) { for (let j = i + 1; j < objs.length; j++) {
const d = Math.hypot(objs[i].x - objs[j].x, objs[i].y - objs[j].y); const d = Math.hypot(objs[i].x - objs[j].x, objs[i].y - objs[j].y);
if (d < MIN_SEP - 1e-6) { if (d < MIN_SEP - 1e-6 || d > MAX + 1e-6) {
layoutOk = false; layoutOk = false;
layoutWhy = `${rec.id}: two objects ${Math.round(MIN_SEP - d)} px closer than minSpacing ${MIN_SEP}`; layoutWhy = `${rec.id}: a pair ${Math.round(d)} px outside the band [${MIN_SEP}, ${MAX}]`;
}
}
}
// 2) Every object has a neighbor within maxNeighbor (the home world is
// always present, so "more than one object" ⇔ objs.length ≥ 2).
if (layoutOk && objs.length >= 2) {
for (let i = 0; i < objs.length; i++) {
const near = objs.some(
(o, j) => j !== i && Math.hypot(objs[i].x - o.x, objs[i].y - o.y) <= MAX_NBR + 1e-6,
);
if (!near) {
layoutOk = false;
layoutWhy = `${rec.id}: an object with no neighbor within maxNeighbor ${MAX_NBR}`;
break;
} }
} }
} }
@ -176,8 +180,8 @@ const probe = (systems) => {
}; };
probe(g.records.slice(0, 5000)); probe(g.records.slice(0, 5000));
check( check(
`layout: 5000 systems obey minSpacing (no pair <${MIN_SEP}px center-to-center) & maxNeighbor (every object ≤${MAX_NBR}px from a neighbor)${layoutOk ? '' : ' — ' + layoutWhy}`, `layout: 5000 systems obey the band — every pair (incl. the central body) in [${MIN_SEP}, ${MAX_SP}] px, home in [${MIN_SEP}, ${HOME_MAX}] px${layoutOk ? '' : ' — ' + layoutWhy}`,
layoutOk, layoutOk && homeOk,
); );
check(`layout: no system exceeds the 6-object maximum (4 planets + 2 stations) — max in sample: ${maxObjectsSeen}`, maxObjectsSeen <= 6); check(`layout: no system exceeds the 6-object maximum (4 planets + 2 stations) — max in sample: ${maxObjectsSeen}`, maxObjectsSeen <= 6);
check('layout: the 6-object maximum (4 planets + 2 stations) occurs in the sample', sawMaxObjects); check('layout: the 6-object maximum (4 planets + 2 stations) occurs in the sample', sawMaxObjects);

View File

@ -236,7 +236,7 @@ let big;
const g = Galaxy.create('content-shape'); const g = Galaxy.create('content-shape');
const sys = g.currentSystem(); const sys = g.currentSystem();
const c = g.ensureContent(sys.id); const c = g.ensureContent(sys.id);
check('content has star/planets/settlements/belt/hazard', !!c.star && Array.isArray(c.planets) && Array.isArray(c.settlements) && !!c.belt && typeof c.hazard === 'boolean'); check('content has star/planets/settlements/jumps/belt/hazard', !!c.star && Array.isArray(c.planets) && Array.isArray(c.settlements) && Array.isArray(c.jumps) && !!c.belt && typeof c.hazard === 'boolean');
check('planets have name/ordinal/class/moons/habitable', c.planets.every((p) => p.name && p.ordinal >= 1 && p.class && Number.isInteger(p.moons) && typeof p.habitable === 'boolean')); check('planets have name/ordinal/class/moons/habitable', c.planets.every((p) => p.name && p.ordinal >= 1 && p.class && Number.isInteger(p.moons) && typeof p.habitable === 'boolean'));
check('type themes are defined (UI hook)', typeIds.every((id) => typeof types[id].theme?.color === 'string')); check('type themes are defined (UI hook)', typeIds.every((id) => typeof types[id].theme?.color === 'string'));
check('galaxy name is deterministic per seed', Galaxy.create('content-shape').name === g.name); check('galaxy name is deterministic per seed', Galaxy.create('content-shape').name === g.name);
@ -308,10 +308,16 @@ let big;
} }
check('every planet is settled with a class-fitting kind (colonies only on habitable rocky worlds)', allSettledOk); check('every planet is settled with a class-fitting kind (colonies only on habitable rocky worlds)', allSettledOk);
// Not everything is inhabited — some systems stay unclaimed. // JUMP-GATE ANCHOR GUARANTEE: every system holds at least one planet or
let unclaimed = 0; // free-space station — the jump gates sit within level-1 tether of an
for (const r of sample) if ((big2.ensureContent(r.id).settlements).length === 0) unclaimed++; // anchor, so a system with neither would be closed (unreachable).
check(`some systems are charted-but-unclaimed (${unclaimed}/${sample.length} in sample)`, unclaimed > 0); let anchored = 0;
for (const r of sample) {
const c = big2.ensureContent(r.id);
if (c.planets.length > 0 || c.settlements.some((s) => s.anchor?.type === 'space')) anchored++;
}
check(`every system holds a gate anchor — a planet or space station (${anchored}/${sample.length} in sample)`, anchored === sample.length);
check('no system is left unclaimed (the anchor guarantee settled the barren ones)', sample.every((r) => big2.ensureContent(r.id).settlements.length > 0));
// Core→rim gradient: the settled heart is denser than the wilder rim. // Core→rim gradient: the settled heart is denser than the wilder rim.
const withCount = sample.map((r) => ({ rNorm: r.rNorm, n: big2.ensureContent(r.id).settlements.length })); const withCount = sample.map((r) => ({ rNorm: r.rNorm, n: big2.ensureContent(r.id).settlements.length }));
@ -337,24 +343,32 @@ let big;
); );
const anySys = big2.currentSystem(); const anySys = big2.currentSystem();
const report = formatSystemReport(big2.ensureContent(anySys.id)); const report = formatSystemReport(big2.ensureContent(anySys.id));
check('report has title/subtitle/settlements/summary', !!report.title && !!report.subtitle && Array.isArray(report.settlements) && typeof report.summary === 'string'); check('report has title/subtitle/settlements/gates/summary', !!report.title && !!report.subtitle && Array.isArray(report.settlements) && Array.isArray(report.gates) && typeof report.summary === 'string');
check('report lines name their anchor world or open space', report.settlements.every((s) => /on .+|in open space/.test(s.text))); check('report lines name their anchor world or open space', report.settlements.every((s) => /on .+|in open space/.test(s.text)));
check('report gate lines name their destination star', report.gates.every((gt) => /jump to .+/.test(gt.text)));
check('report population sums match', report.population === report.settlements.reduce((s, x) => s + x.population, 0)); check('report population sums match', report.population === report.settlements.reduce((s, x) => s + x.population, 0));
check('formatPop() scales (1.2k / 9.0M)', formatPop(1234) === '1.2k' && formatPop(9000000) === '9.0M' && formatPop(12) === '12'); check('formatPop() scales (1.2k / 9.0M)', formatPop(1234) === '1.2k' && formatPop(9000000) === '9.0M' && formatPop(12) === '12');
// Unclaimed systems read as "charted · unclaimed" in the report. // The anchor guarantee settled every system, so the "unclaimed" report
const unclaimedRec = sample.map((r) => big2.ensureContent(r.id)).find((c) => c.settlements.length === 0); // branch is a defensive fallback (content with zero settlements).
check('unclaimed systems report "charted · unclaimed"', unclaimedRec && formatSystemReport(unclaimedRec).summary === 'charted · unclaimed');
// No-duplicate guarantee: within a system, planet names and station names // No-duplicate guarantee: within a system, planet names, station names
// never repeat (drawn without replacement from the bank). // and jump-gate names never repeat (drawn without replacement from the
// banks; gates get a " Gate" suffix, disambiguated with II/III if two
// targets share a star name).
const noDupOk = sample.every((r) => { const noDupOk = sample.every((r) => {
const c = big2.ensureContent(r.id); const c = big2.ensureContent(r.id);
const pnames = c.planets.map((p) => p.name); const pnames = c.planets.map((p) => p.name);
const snames = c.settlements.map((s) => s.name); const snames = c.settlements.map((s) => s.name);
return new Set(pnames).size === pnames.length && new Set(snames).size === snames.length; const gnames = (c.jumps ?? []).map((j) => j.name);
return (
new Set(pnames).size === pnames.length &&
new Set(snames).size === snames.length &&
new Set(gnames).size === gnames.length &&
new Set([...pnames, ...snames, ...gnames]).size === pnames.length + snames.length + gnames.length
);
}); });
check('within a system: planet names & station names are all distinct', noDupOk); check('within a system: planet, station & gate names are all distinct', noDupOk);
// The starting system's HOME world gets a bank name distinct from its // The starting system's HOME world gets a bank name distinct from its
// planets; only the starting system has one. // planets; only the starting system has one.

147
dev/jumpgate.test.mjs Normal file
View File

@ -0,0 +1,147 @@
/**
* JumpGate entity test (dev tool, run with Node no browser needed):
*
* node dev/jumpgate.test.mjs
*
* Stubs just enough of Phaser to construct the REAL JumpGate from
* js/entities/JumpGate.js (the system's exit content.jumps, laid out
* by SystemGenerator.layoutGates), then asserts the contract the rest
* of the game relies on:
* - discovery identity: discoveryId/discoveryName from the record,
* size from data/gates.json, bound = size (compass/toast scale),
* clearance = gates.shipClearance, rotation = the record's bearing;
* - the SOLID contract (same as Planet/Station): minCenterDistance,
* edgePoint exactly `gap` past the surface, aimPoint clamping, and
* constrainShip pushing the ship out of the keepout circle
* (through the shared Planet.resolve);
* - update() breathes the field without throwing;
* - destroy() tears the children down.
*/
import { pathToFileURL, fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Phaser stub: the Container base + a recording graphics --------------
class GameObject {
constructor(scene, x = 0, y = 0) {
this.scene = scene;
this.x = x;
this.y = y;
this.rotation = 0;
this.alpha = 1;
this.children = [];
}
add(o) { this.children.push(o); return this; }
remove(o) { this.children = this.children.filter((c) => c !== o); return this; }
removeChildren() { this.children.length = 0; return this; }
setOrigin() { return this; }
setDepth() { return this; }
setAlpha(a) { this.alpha = a; return this; }
destroy() { this.destroyed = true; return this; }
}
class Container extends GameObject {}
class Sprite extends GameObject {}
globalThis.window = {
Phaser: { GameObjects: { Container, Sprite } }, // js/vendor/phaser.js reads this
};
// --- Load the real config (data/*.json) into the config singleton --------
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const { JumpGate } = await import(pathToFileURL(join(__dirname, '../js/entities/JumpGate.js')).href);
// A scene stub: add.existing (v4 quirk), add.graphics (recording no-op),
// add.circle (the field discs — setAlpha is driven by update()).
const alphas = [];
const scene = {
add: {
existing: (o) => o,
graphics: () => ({
lineStyle() { return this; },
strokeCircle() { return this; },
fillStyle() { return this; },
fillCircle() { return this; },
lineBetween() { return this; },
}),
circle: (x, y, r, fill, alpha) => {
const c = { x, y, r, fill, alpha, setAlpha(a) { this.alpha = a; return this; } };
alphas.push(c);
return c;
},
},
};
// A real gate record from the real generator (the home system's first).
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
const g = Galaxy.create('jumpgate-entity-test');
const rec = g.currentSystem();
const content = g.ensureContent(rec.id);
const gateRec = content.jumps[0];
const gate = new JumpGate(scene, gateRec, { depth: 5 });
check('discoveryId / discoveryName come from the record', gate.discoveryId === gateRec.id && gate.discoveryName === gateRec.name);
check('position comes from the record', gate.x === gateRec.x && gate.y === gateRec.y);
check('size comes from data/gates.json', gate.size === config.get('gates.size', 96) && gate.bound === gate.size);
check('clearance comes from gates.shipClearance', gate.clearance === config.get('gates.shipClearance', 50));
check('rotation = the records bearing (facing the destination star)', gate.rotation === gateRec.rotation);
// --- The solid contract (same rules as Planet / Station) ------------------
const shipRadius = (config.get('ship.size', 46) * config.get('ship.scale', 1)) / 2;
const minDist = gate.minCenterDistance(shipRadius);
check('minCenterDistance = radius + clearance + shipRadius', Math.abs(minDist - (gate.radius + gate.clearance + shipRadius)) < 1e-9);
{
const p = gate.edgePoint(1.2, 25, shipRadius);
check('edgePoint sits exactly radius + gap + shipRadius out, on the bearing',
Math.abs(Math.hypot(p.x - gate.x, p.y - gate.y) - (gate.radius + 25 + shipRadius)) < 1e-6 &&
Math.abs(Math.atan2(p.y - gate.y, p.x - gate.x) - 1.2) < 1e-9);
}
{
const inside = { x: gate.x + 10, y: gate.y + 10 }; // inside the keepout
const clamped = gate.aimPoint(inside.x, inside.y, shipRadius);
check('aimPoint clamps an inside target out to the keepout',
Math.abs(Math.hypot(clamped.x - gate.x, clamped.y - gate.y) - minDist) < 1e-6);
const outside = { x: gate.x + minDist * 2, y: gate.y };
const asIs = gate.aimPoint(outside.x, outside.y, shipRadius);
check('aimPoint leaves an outside target alone', asIs.x === outside.x && asIs.y === outside.y);
}
{
const ship = (x, y, vx = 0, vy = 0) => ({ x, y, body: { velocity: { x: vx, y: vy }, acceleration: { x: 0, y: 0 } } });
const s = ship(gate.x + 1, gate.y + 1); // jammed inside the keepout
gate.constrainShip(s, shipRadius);
const d = Math.hypot(s.x - gate.x, s.y - gate.y);
check('constrainShip pushes a ship inside the keepout back out',
Math.abs(d - minDist) < 1e-6);
const s2 = ship(gate.x + minDist * 2, gate.y, 10, 0); // outside: untouched
gate.constrainShip(s2, shipRadius);
check('constrainShip leaves a ship outside the keepout untouched', s2.x === gate.x + minDist * 2 && s2.body.velocity.x === 10);
}
// --- Animation + teardown --------------------------------------------------
gate.update(1000); // must not throw; the field breathes
check('update() breathes the field discs (alpha set)', alphas.length === 2 && alphas.every((c) => typeof c.alpha === 'number' && c.alpha > 0 && c.alpha < 0.5));
const childCount = gate.children.length;
gate.destroy();
check('destroy() clears the children', gate.children.length === 0 && childCount > 0);
if (failures > 0) {
console.error(`\n${failures} jumpgate test(s) FAILED`);
process.exit(1);
}
console.log('\nAll JumpGate entity checks passed ✔');

306
dev/jumps.test.mjs Normal file
View File

@ -0,0 +1,306 @@
/**
* Jump gates test (dev tool, run with Node no browser needed):
*
* node dev/jumps.test.mjs
*
* Asserts the GATE NETWORK (data/gates.json, js/galaxy/JumpNetwork.js,
* exposed as Galaxy.jumpNetwork / jumpGatesFor):
* - every system holds minGates..maxGates gates (13 in data);
* - every gate is LOCAL its destination is in the system's
* nearest-star pool (symmetric union, pool = gates.neighborPool);
* - the network is STRONGLY CONNECTED: from the home system every
* other system is reachable (forward BFS) AND every system can
* reach home (reverse BFS) no closed systems, no trapped sets;
*
* the IN-SYSTEM PLACEMENT (js/galaxy/SystemGenerator.js layoutGates):
* - content.jumps matches the network (count + destinations);
* - every gate is within level-1 tether (tether.level1Radius) of an
* ANCHOR a planet, a free-space station, or the home world in the
* starting system;
* - every gate FACES its destination star on the 2-D map: the bearing
* from the anchor to the gate is within 90° of the systemstar
* bearing (soft rule same side, never opposite);
* - gates stay gates.minRadius..gates.maxRadius from the star;
* - gates keep size+clearance from anchor discs and 2·size+gateGap
* from each other;
* - gate ids/names are unique per system;
*
* and DETERMINISM: same seed same network, same gates, same layout;
* different seed different network (spot check).
*/
process.env.NODE_ENV = 'dev';
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
let failures = 0;
const check = (label, cond, extra = '') => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}${cond ? '' : ' — ' + extra}`);
if (!cond) failures++;
};
const GATES = config.section('gates', {});
const MIN_GATES = GATES.minGates ?? 1;
const MAX_GATES = GATES.maxGates ?? 3;
const POOL = Math.max(1, GATES.neighborPool ?? 8);
const TETHER = config.get('tether.level1Radius', 5120);
const SIZE = GATES.size ?? 96;
const CLEAR = GATES.clearance ?? 256;
const GAP = GATES.gateGap ?? 192;
const MIN_R = GATES.minRadius ?? 2048;
const MAX_R = GATES.maxRadius ?? 20480;
const SEED = 'jumps-test-seed';
const g = Galaxy.create(SEED);
const HOME = g.currentSystemId;
const recOf = new Map(g.records.map((r) => [r.id, r]));
const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.PI;
// ----------------------------------------------------------------------
// 1. The network: counts, locality, strong connectivity
// ----------------------------------------------------------------------
{
const bad = [];
for (const r of g.records) {
const n = g.jumpGatesFor(r.id).length;
if (n < MIN_GATES || n > MAX_GATES) bad.push(`${r.id}:${n}`);
}
check(
`every system holds ${MIN_GATES}${MAX_GATES} gates (${g.records.length} systems)`,
bad.length === 0,
bad.slice(0, 5).join(', '),
);
// Locality: every destination sits in the system's nearest-star pool
// (the symmetric union — the gate network's `neighborsOf` contract).
let local = true;
let why = '';
outer: for (const r of g.records) {
for (const t of g.jumpGatesFor(r.id)) {
if (t.id === r.id) {
local = false;
why = `${r.id} jumps to itself`;
break outer;
}
const inPool =
g.neighborsOf(r.id, POOL).some((x) => x.id === t.id) ||
g.neighborsOf(t.id, POOL).some((x) => x.id === r.id);
if (!inPool) {
local = false;
why = `${r.id}${t.id} outside the ${POOL}-nearest pool`;
break outer;
}
}
}
check('every gate jumps to a star in the systems nearest-star pool (local jumps)', local, why);
// Strong connectivity, forward: from home, every system is reachable.
const fwd = new Set([HOME]);
const q = [HOME];
while (q.length) {
const u = q.pop();
for (const t of g.jumpGatesFor(u)) if (!fwd.has(t.id)) fwd.add(t.id), q.push(t.id);
}
check('reachable from home: every system (no forward dead ends)', fwd.size === g.records.length, `${fwd.size}/${g.records.length}`);
// Strong connectivity, backward: every system can reach home
// (no closed systems, no trapped sets).
const radj = new Map(g.records.map((r) => [r.id, []]));
for (const r of g.records) for (const t of g.jumpGatesFor(r.id)) radj.get(t.id).push(r.id);
const rev = new Set([HOME]);
const qr = [HOME];
while (qr.length) {
const u = qr.pop();
for (const s of radj.get(u)) if (!rev.has(s)) rev.add(s), qr.push(s);
}
check('every system can reach home (no closed systems, no trapped sets)', rev.size === g.records.length, `${rev.size}/${g.records.length}`);
}
// ----------------------------------------------------------------------
// 2. Placement: tether, facing, radius band, clearances, uniqueness
// ----------------------------------------------------------------------
{
const sample = [recOf.get(HOME), ...g.records.slice(0, 3000).filter((r) => r.id !== HOME)];
let tether = 0, facing = 0, radius = 0, clearance = 0, gap = 0, unique = 0;
let tWhy = '', fWhy = '', rWhy = '', cWhy = '', gWhy = '', mWhy = '';
let netMismatch = false;
for (const sys of sample) {
const c = g.ensureContent(sys.id);
const isHome = sys.id === HOME;
// content.jumps matches the network (count + destinations, in order).
const net = g.jumpGatesFor(sys.id);
if (
c.jumps.length !== net.length ||
c.jumps.some((j, i) => j.to !== net[i].id || j.toName !== net[i].name)
) {
netMismatch = true;
if (!mWhy) mWhy = `${sys.id}: content.jumps ≠ jumpGatesFor`;
}
// Anchors: planets, free-space stations, and (home) the home world.
const anchors = [
...c.planets.map((p) => ({ x: p.x, y: p.y, name: p.name })),
...(c.settlements ?? []).filter((s) => s.anchor?.type === 'space').map((s) => ({ x: s.x, y: s.y, name: s.name })),
];
if (isHome) anchors.push({ x: 0, y: 0, name: 'home world' });
c.jumps.forEach((j, gi) => {
const t = recOf.get(j.to);
if (!t || t.id === sys.id) {
tether++;
if (!tWhy) tWhy = `${sys.id}: gate ${j.id} has no valid destination`;
return;
}
const th = Math.atan2(t.y - sys.y, t.x - sys.x);
// TETHER (hard): within level-1 range of some anchor.
let bestD = Infinity;
for (const a of anchors) bestD = Math.min(bestD, Math.hypot(j.x - a.x, j.y - a.y));
if (bestD > TETHER + 1e-6) {
tether++;
if (!tWhy) tWhy = `${sys.id}: gate ${j.id} is ${Math.round(bestD)} px from its nearest anchor (> ${TETHER})`;
}
// FACING (soft): from the anchoring object, the gate is on the
// target side — within 90° of the system→star bearing (some anchor
// must satisfy BOTH the tether and the facing).
let onSide = false;
for (const a of anchors) {
const d = Math.hypot(j.x - a.x, j.y - a.y);
if (d > TETHER + 1e-6) continue;
if (Math.abs(norm(Math.atan2(j.y - a.y, j.x - a.x) - th)) < Math.PI / 2) onSide = true;
}
if (!onSide) {
facing++;
if (!fWhy) fWhy = `${sys.id}: gate ${j.id} is on the wrong side of every tethering anchor`;
}
// RADIUS band from the star.
const d0 = Math.hypot(j.x, j.y);
if (d0 < MIN_R - 1e-6 || d0 > MAX_R + 1e-6) {
radius++;
if (!rWhy) rWhy = `${sys.id}: gate ${j.id} at ${Math.round(d0)} px from the star (band ${MIN_R}..${MAX_R})`;
}
// CLEARANCE from anchor discs (every disc in this game is under
// 800 px across, so a size + 100 px floor is a fair check).
for (const a of anchors) {
const d = Math.hypot(j.x - a.x, j.y - a.y);
if (d < SIZE + 100) {
clearance++;
if (!cWhy) cWhy = `${sys.id}: gate ${j.id} is ${Math.round(d)} px from ${a.name}`;
break;
}
}
// GAP to the other gates of this system.
c.jumps.forEach((k, ki) => {
if (ki <= gi) return;
const d = Math.hypot(j.x - k.x, j.y - k.y);
if (d < 2 * SIZE + GAP) {
gap++;
if (!gWhy) gWhy = `${sys.id}: gates ${j.id}/${k.id} are ${Math.round(d)} px apart (< ${2 * SIZE + GAP})`;
}
});
});
const ids = new Set(c.jumps.map((j) => j.id));
const names = new Set(c.jumps.map((j) => j.name));
const idShape = c.jumps.every((j, i) => j.id === `${sys.id}-j${i + 1}`);
if (ids.size !== c.jumps.length || names.size !== c.jumps.length || !idShape) {
unique++;
}
}
check('content.jumps matches the gate network (count, destinations, order)', !netMismatch, mWhy);
check(`tether (hard): every gate ≤ ${TETHER} px from a planet/station anchor`, tether === 0, tWhy);
check('facing (soft): every gate is on the target side of its anchor (< 90°)', facing === 0, fWhy);
check(`radius band: every gate ${MIN_R}..${MAX_R} px from the star`, radius === 0, rWhy);
check(`clearance: every gate keeps ${SIZE} + 100 px from anchor discs`, clearance === 0, cWhy);
check(`gate gap: gates of a system are ≥ ${2 * SIZE + GAP} px apart`, gap === 0, gWhy);
check('gate ids are <systemId>-j<n> and names are unique per system', unique === 0);
// The anchor guarantee: every system holds a planet or space station
// (a gate must be tether-reachable from one).
let noAnchor = 0;
for (const r of g.records) {
const c = g.ensureContent(r.id);
if (c.planets.length === 0 && !(c.settlements ?? []).some((s) => s.anchor?.type === 'space')) noAnchor++;
}
check(`anchor guarantee: every one of the ${g.records.length} systems holds a planet or space station`, noAnchor === 0, `${noAnchor} without`);
}
// ----------------------------------------------------------------------
// 3. Determinism — same seed ⇒ same network + gates + layout
// ----------------------------------------------------------------------
{
const g2 = Galaxy.create(SEED);
let net = true;
let placement = true;
for (let i = 0; i < 40 && net; i++) {
if (JSON.stringify(g.jumpGatesFor(g.records[i].id).map((t) => t.id)) !== JSON.stringify(g2.jumpGatesFor(g2.records[i].id).map((t) => t.id))) net = false;
}
for (let i = 0; i < 40 && placement; i++) {
const a = g.ensureContent(g.records[i].id);
const b = g2.ensureContent(g2.records[i].id);
if (JSON.stringify(a.jumps) !== JSON.stringify(b.jumps)) placement = false;
if (
a.planets.some((p, k) => p.x !== b.planets[k].x || p.y !== b.planets[k].y) ||
JSON.stringify((a.settlements ?? []).map((s) => [s.x, s.y])) !== JSON.stringify((b.settlements ?? []).map((s) => [s.x, s.y]))
)
placement = false;
}
check('same seed ⇒ same gate network', net);
check('same seed ⇒ same gate placements + object layout', placement);
const g3 = Galaxy.create('jumps-test-OTHER');
let diff = false;
for (let i = 0; i < 50 && !diff; i++) {
const idA = g.records[i].id;
const idB = g3.records[i].id;
if (idA !== idB) continue;
diff =
JSON.stringify(g.jumpGatesFor(idA).map((t) => t.id)) !== JSON.stringify(g3.jumpGatesFor(idB).map((t) => t.id));
}
check('different seed ⇒ different gate network (spot check)', diff);
}
// ----------------------------------------------------------------------
// 4. The home system
// ----------------------------------------------------------------------
{
const c = g.ensureContent(HOME);
const objects = c.planets.length + (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length;
check('home system holds ≤ 3 objects (2 planets + ≤ 1 free-space station)', objects <= 3 && c.planets.length === 2, `${objects} objects, ${c.planets.length} planets`);
check('home system holds jump gates', c.jumps.length >= MIN_GATES && c.jumps.length <= MAX_GATES, `${c.jumps.length} gates`);
const home = recOf.get(HOME);
let homeOk = true;
for (const j of c.jumps) {
const t = recOf.get(j.to);
const th = Math.atan2(t.y - home.y, t.x - home.x);
// From a home anchor (the home world, or — when the gate gap pushes
// a pair of close targets apart — a home planet) the gate is within
// level-1 tether and on the target side.
const anchors = [...c.planets.map((p) => [p.x, p.y]), [0, 0]];
let ok = false;
for (const [ax, ay] of anchors) {
const d = Math.hypot(j.x - ax, j.y - ay);
const a = Math.abs(norm(Math.atan2(j.y - ay, j.x - ax) - th));
if (d <= TETHER + 1e-6 && a < Math.PI / 2) ok = true;
}
if (!ok) homeOk = false;
}
check('home gates sit within level-1 tether of a home anchor, facing their star', homeOk);
}
console.log(failures === 0 ? '\nAll jump-gate tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -82,6 +82,7 @@ const report = formatSystemReport(content);
const detailValues = [ const detailValues = [
report.subtitle, report.subtitle,
...report.settlements.map((s) => s.text), ...report.settlements.map((s) => s.text),
...(report.gates ?? []).map((gt) => gt.text),
report.summary, report.summary,
`seed ${galaxy.seed}`, `seed ${galaxy.seed}`,
]; ];
@ -214,6 +215,10 @@ check('arrival timeline: name first, then the detail lines',
ys.push(y); ys.push(y);
y += 20; y += 20;
} }
for (let i = 0; i < (report.gates ?? []).length; i++) {
ys.push(y);
y += 20;
}
ys.push(y); // summary ys.push(y); // summary
y += 20; y += 20;
ys.push(y); // seed ys.push(y); // seed

View File

@ -123,8 +123,9 @@ menu (displayed, editable, rerollable; same seed ⇒ same galaxy).
The player's **current system** starts at `galaxy.currentSystem()` The player's **current system** starts at `galaxy.currentSystem()`
(`startingSystem.policy`: `center` or `random`). Jumping between systems (`startingSystem.policy`: `center` or `random`). Jumping between systems
(the eventual star map / jump drives) will use `galaxy.neighborsOf(id)` now has its NETWORK (see "Jump gates" below — `data/gates.json` +
and the spatial hash already built for it. `js/galaxy/JumpNetwork.js`, built on `galaxy.neighborsOf(id)` and the
spatial hash); the in-flight jump drive is the next mechanic on top.
**The galaxy is already lived in.** It was settled long before the **The galaxy is already lived in.** It was settled long before the
player arrives. **Every planet is settled (for now)**: each world hosts player arrives. **Every planet is settled (for now)**: each world hosts
@ -134,8 +135,11 @@ rocky world, `miningStation` over every other rocky/ice/lava world,
`cloudBase` riding every gas giant. Plus the free-space kinds: `cloudBase` riding every gas giant. Plus the free-space kinds:
`deepSpaceStation` (adrift in open space) and `waypoint` (a small beacon `deepSpaceStation` (adrift in open space) and `waypoint` (a small beacon
— the faint trace of a crossed galaxy), still rolled per archetype and — the faint trace of a crossed galaxy), still rolled per archetype and
thinned core→rim. The charted-but-unclaimed systems are the barren ones thinned core→rim. The **anchor guarantee** (jump gates must be
that also roll no free-space station. Model & seams: reachable on a level-1 tether) settles the rest: a system that rolls no
planet and no free-space station still gets a `deepSpaceStation`, so no
system is left unclaimed (the "charted · unclaimed" report branch is a
defensive fallback). Model & seams:
- **Kinds vocabulary**`data/settlements.json` (label, description, - **Kinds vocabulary**`data/settlements.json` (label, description,
theme color, population range, anchor type). Add a kind = JSON + naming theme color, population range, anchor type). Add a kind = JSON + naming
pool; the generator picks it up by name. pool; the generator picks it up by name.
@ -155,8 +159,9 @@ that also roll no free-space station. Model & seams:
where **factions and pirates** will plug in later (claim, flag, where **factions and pirates** will plug in later (claim, flag,
relations). Deliberately absent for now — no factions yet. relations). Deliberately absent for now — no factions yet.
- **Pure report formatter**`js/galaxy/SystemReport.js` - **Pure report formatter**`js/galaxy/SystemReport.js`
(`formatSystemReport(content)` → title/subtitle/settlements/summary). (`formatSystemReport(content)` → title/subtitle/settlements/gates/
The GameScene HUD renders it; future star map / terminal UI reuse it. summary). The GameScene HUD renders it; future star map / terminal UI
reuse it.
- Landing/exploration (a future feature) will treat settlements as points - Landing/exploration (a future feature) will treat settlements as points
of interest: the data already says what's there and where (anchor = of interest: the data already says what's there and where (anchor =
planet ordinal or open space). planet ordinal or open space).
@ -187,19 +192,22 @@ The player starts in a solar system, and the other worlds **exist in the
world** — solid, rendered, flyable-to. Rules and seams: world** — solid, rendered, flyable-to. Rules and seams:
- **World layout** — `SystemGenerator.layoutSystem(seed, systemId, planets, - **World layout** — `SystemGenerator.layoutSystem(seed, systemId, planets,
freeSpace)` (pure, `js/galaxy/SystemGenerator.js`) places each system's freeSpace, isHome, targetAngles)` (pure, `js/galaxy/SystemGenerator.js`)
planets and free-space stations (deep-space stations, waypoints) on one places each system's planets and free-space stations on a ring (a
or two orbits (rings) around the home world (origin), enforcing the hard regular polygon) around the central body (the home world at the origin
spacing rules in `data/planets.json → solarSystem`: `minSpacing` (6144 px, in the starting system, the star elsewhere), enforcing the SOLAR
center-to-center, between ANY two objects — planets, stations, the home SYSTEM BAND in `data/planets.json → solarSystem`: EVERY pair of layout
world) and `maxNeighbor` (10240 px — whenever a system holds more than objects — planets, stations, the central body — sits center-to-center
one object, every object is within it of at least one other; the inner in `[minSpacing, maxSpacing]` (640015360 px; the starting system's
orbit's near neighbor is the home world at the origin). The current data band tightens to `[minSpacing, homeMaxSpacing]` = 640010240 px). The
max is 6 objects (4 planets + 2 stations), which always shares one ring radius is chosen inside the band (non-home: `maxSpacing` over the
orbit; the 11-object two-orbit case (3-ring + 8-ring) is kept as a chord of the N-gon; home: the (N+1)-gon side over the band's tight
defensive fallback for any future data that could produce it. ratio), and the rotation (ring phase) is a deterministic scan that
Planet-bound settlements (colonies, mining stations, cloud bases) are serves the system's gate target bearings. The starting system is capped
features of their planet, not layout objects. at 3 objects (2 planets + ≤ 1 station) — five points cannot sit
640010240 px apart (the tightest 5-point spacing needs ratio ≥ φ >
1.6). Planet-bound settlements (colonies, mining stations, cloud bases)
are features of their planet, not layout objects.
Draws come from the dedicated fork `Rng.derive(seed, 'system', id, Draws come from the dedicated fork `Rng.derive(seed, 'system', id,
'layout')` — layout results are seed-deterministic *and* don't perturb 'layout')` — layout results are seed-deterministic *and* don't perturb
the content stream (lazy === eager is preserved). Class sizes/tints in the content stream (lazy === eager is preserved). Class sizes/tints in
@ -222,7 +230,53 @@ world** — solid, rendered, flyable-to. Rules and seams:
(`GameScene.solidPlanets`), not just home: you can approach a rim, (`GameScene.solidPlanets`), not just home: you can approach a rim,
never pass through. never pass through.
- Verified: `dev/discovery.test.mjs` (rule, boundaries, per-system - Verified: `dev/discovery.test.mjs` (rule, boundaries, per-system
state, JSON round-trip, layout bounds + determinism, compass geometry). state, JSON round-trip, layout band + determinism, compass geometry).
## Jump gates — the galaxy's highway layer
Every system has **13 jump gates** (`data/gates.json`), each one the
exit toward a NEARBY star on the 2-D map. Two layers:
- **The network**`js/galaxy/JumpNetwork.js` (pure, Node-tested;
built once per galaxy in `Galaxy._generate()`, exposed as
`galaxy.jumpNetwork` / `galaxy.jumpGatesFor(id)`). It is a
degree-limited spanning tree of the nearest-star graph (each system's
`neighborPool` = 8 closest stars, `galaxy.neighborsOf`), with tree
edges run BOTH ways plus optional one-way `shortcuts` bought with the
spare gate budget. Result: the directed graph is **strongly
connected** (from any star you can reach any other — no closed
systems, no trapped sets), every jump is local, and no system holds
more than `maxGates` gates. A 3-tier repair (local attach → swap →
last-resort attach, logged) covers pathological pools, preferring a
working network over the cap.
- **The in-system placement**`SystemGenerator.layoutGates(...)` (pure)
places each gate on the tether circle of one of the system's ANCHORS
(a planet, a free-space station, or the home world in the starting
system), exactly `anchorTetherLevel` (level 1 = 5120 px, from
`data/tether.json`) from it — so the tether rule is hard by
construction. Candidates are tried in facing quality: the ray-circle
intersection (gate exactly on the system→star ray), the circle point
aimed at the star, then a ±75° scan — so the direction rule is soft
(same side of the anchor as the target, deviation < 90°; in practice
~98% are within 75°). Gates stay `minRadius..maxRadius` (204820480
px) from the star, keep `size` + `clearance` from anchor discs and
2·`size` + `gateGap` from each other, and get unique names ("Avidy
Gate", "Avidy Gate II" — star names are syllable-generated and
collide). A gate's `rotation` is the bearing from the gate to its
destination star — the art (twin-pylon portal, `js/entities/JumpGate.js`
— procedural, Station.js-style) faces where it jumps.
- **In the scene** — GameScene builds the gates as solid world objects
(the ship keeps `gates.shipClearance` from them, autopilot flies to
their rim), discoverable (compass arrows + toast, in the gate cyan
`#5fd4ff`), and the HUD dossier lists them ("… Gate · jump to
<Star>"). They are NOT comms targets (`worldObjectAt` excludes them)
— standing at a gate is where the jump happens; the jump mechanic
itself is the follow-up.
- **Determinism** — same seed ⇒ same network, same gates, same
placements. Verified: `dev/jumps.test.mjs` (network invariants,
placement invariants, determinism), `dev/jumpgate.test.mjs` (the
entity's solid contract), and the layout band in
`dev/discovery.test.mjs`.
## The tether — the player's range (important) ## The tether — the player's range (important)

206
js/entities/JumpGate.js Normal file
View File

@ -0,0 +1,206 @@
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { Planet } from './Planet.js';
/**
* A JUMP GATE the system's exit (SystemGenerator: `content.jumps`;
* the destination network in js/galaxy/JumpNetwork.js, config in
* data/gates.json). Rendered as a world object: a twin-pylon portal
* whose mouth and chevrons point at the DESTINATION STAR (`rotation`
* is the bearing from the gate to that star), a slow-spinning outer
* ring, and a breathing energy field.
*
* Solid like a planet (the ship keeps its clearance the same plain
* circle rule, GameScene.solids), discoverable (a compass arrow + the
* discovery toast, at the scale of its keepout, in its own cyan).
* It is NOT a comms target (GameScene.worldObjectAt excludes it)
* standing at a gate is where the jump happens; that arrives with the
* jump mechanic.
*
* The container itself carries `rotation` (the art is drawn facing
* +x), so the whole gate pylons, mouth, chevrons faces its
* destination. The collision API is circular and rotation-blind.
*
* update(time) breathes the field and drifts the ring driven by
* GameScene.update, like the stations and asteroid clusters.
*/
export class JumpGate extends Phaser.GameObjects.Container {
/**
* @param {Phaser.Scene} scene
* @param {object} gate the content record: { id, name, to, toName,
* x, y, size, rotation }
* @param {object} [o] { depth }
*/
constructor(scene, gate, o = {}) {
super(scene, gate.x, gate.y);
this.scene.add.existing(this); // v4: new'd containers are not on the display list
this.gate = gate;
this.discoveryId = gate.id;
this.discoveryName = gate.name;
this.size = gate.size ?? config.get('gates.size', 96);
this.radius = this.size; // the keepout circle's radius
this.bound = this.size; // the discovery radius (the compass/toast scale)
this.clearance = config.get('gates.shipClearance', 50);
this.theme = this._theme();
this.ringBody = null;
this.setDepth(o.depth ?? 5);
this.rotation = gate.rotation ?? 0; // face the destination star
this.build();
}
_theme() {
const hex = config.get('gates.theme.color', '#5fd4ff');
let v = parseInt(hex.replace('#', ''), 16);
if (Number.isNaN(v)) v = 0x5fd4ff;
return { r: (v >> 16) & 255, g: (v >> 8) & 255, b: v & 255 };
}
/** Procedural build — the art faces +x (toward the destination). */
build() {
const scene = this.scene;
const S = this.size;
const { r, g, b } = this.theme;
const rgb = (mulR = 1, mulG = 1, mulB = 1) =>
(Math.min(255, Math.round(r * mulR)) << 16) | (Math.min(255, Math.round(g * mulG)) << 8) |
Math.min(255, Math.round(b * mulB));
const gph = scene.add.graphics();
this.add(gph);
const RING = S * 0.8;
const FIELD = S * 0.55;
// The outer ring — its own container so update() can drift it.
this.ringBody = new Phaser.GameObjects.Container(scene, 0, 0);
const rg = scene.add.graphics();
rg.lineStyle(S * 0.055, rgb(0.55, 0.6, 0.7), 0.95);
rg.strokeCircle(0, 0, RING);
rg.lineStyle(2, rgb(0.75, 0.8, 0.9), 0.8);
rg.strokeCircle(0, 0, RING + S * 0.045);
// Six hub marks on the ring (the drift makes them read as motion).
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2;
rg.fillStyle(rgb(0.8, 0.85, 1), 1);
rg.fillCircle(Math.cos(a) * RING, Math.sin(a) * RING, S * 0.035);
}
this.ringBody.add(rg);
this.add(this.ringBody);
// The pylons — one on each side, off the axis, weathered frames.
for (const dir of [-1, 1]) {
const py = dir * (RING + S * 0.02);
gph.fillStyle(0x57493a, 1);
gph.fillCircle(0, py, S * 0.085);
gph.lineStyle(2, 0x2c2118, 1);
gph.strokeCircle(0, py, S * 0.085);
gph.fillStyle(0x3a2f26, 1);
gph.fillCircle(0, py, S * 0.045);
gph.fillStyle(rgb(0.9, 0.95, 1), 0.9);
gph.fillCircle(S * 0.05, py, S * 0.018); // the pylon light
}
// The mouth — a bright arc on the destination side (+x), so the gate
// reads as an opening that way. (Polyline arc — plain Graphics API.)
const arc = (radius, a0, a1, lw, alpha, color) => {
gph.lineStyle(lw, color, alpha);
const SEG = 14;
let px = Math.cos(a0) * radius;
let py = Math.sin(a0) * radius;
for (let i = 1; i <= SEG; i++) {
const a = a0 + ((a1 - a0) * i) / SEG;
const nx = Math.cos(a) * radius;
const ny = Math.sin(a) * radius;
gph.lineBetween(px, py, nx, ny);
px = nx;
py = ny;
}
};
arc(FIELD * 0.82, -0.85, 0.85, S * 0.09, 0.95, rgb(1, 1, 1.05));
arc(FIELD * 0.82, -0.5, 0.5, S * 0.035, 1, rgb(1.4, 1.6, 2));
// The energy field (the breathing discs — alpha driven in update()).
this.field = scene.add.circle(0, 0, FIELD, rgb(0.5, 0.7, 1), 0.16);
this.fieldCore = scene.add.circle(0, 0, FIELD * 0.5, rgb(0.75, 0.9, 1), 0.22);
this.add([this.field, this.fieldCore]);
// The chevrons — pointing at the destination star (+x), over the field.
const top = scene.add.graphics();
const chev = (x0, w, lw, alpha) => {
top.lineStyle(lw, rgb(1.1, 1.25, 1.6), alpha);
top.lineBetween(x0 - w, -w * 0.75, x0, 0);
top.lineBetween(x0, 0, x0 - w, w * 0.75);
};
chev(S * 0.24, S * 0.2, S * 0.05, 0.95);
chev(S * 0.48, S * 0.2, S * 0.04, 0.6);
this.add(top);
}
/** The ring drifts; the field breathes. (GameScene.update drives this.) */
update(time) {
const t = time / 1000;
if (this.ringBody) this.ringBody.rotation = t * 0.12;
const pulse = 0.5 + 0.5 * Math.sin(t * 1.8);
if (this.field) this.field.setAlpha(0.1 + 0.14 * pulse);
if (this.fieldCore) this.fieldCore.setAlpha(0.14 + 0.2 * pulse);
}
// ---- SOLID (the same contract as Planet / Station / AsteroidCluster) --
minCenterDistance(shipRadius = 0) {
return this.radius + this.clearance + shipRadius;
}
/** A point `gap` past the surface toward (wx, wy) — the ship's approach stop. */
edgePoint(angle, gap, shipRadius = 0) {
const d = this.radius + gap + shipRadius;
return {
x: this.x + Math.cos(angle) * d,
y: this.y + Math.sin(angle) * d,
};
}
/** Clamp a target to at least clearance outside the surface. */
aimPoint(wx, wy, shipRadius = 0) {
const minDist = this.minCenterDistance(shipRadius);
const dx = wx - this.x;
const dy = wy - this.y;
const dist = Math.hypot(dx, dy);
if (dist >= minDist) return { x: wx, y: wy };
if (dist === 0) return { x: this.x + minDist, y: this.y };
return {
x: this.x + (dx / dist) * minDist,
y: this.y + (dy / dist) * minDist,
};
}
/** Hard constraint — push the ship outside the keepout circle. */
constrainShip(ship, shipRadius = 0) {
const body = ship.body;
const r = Planet.resolve(
this.x,
this.y,
this.minCenterDistance(shipRadius),
ship.x,
ship.y,
body.velocity.x,
body.velocity.y,
body.acceleration ? body.acceleration.x : 0,
body.acceleration ? body.acceleration.y : 0,
);
ship.x = r.x;
ship.y = r.y;
body.velocity.x = r.vx;
body.velocity.y = r.vy;
if (body.acceleration) {
body.acceleration.x = r.ax;
body.acceleration.y = r.ay;
}
}
destroy() {
this.removeChildren();
super.destroy();
}
}

View File

@ -1,6 +1,7 @@
import { config } from '../config/Config.js'; import { config } from '../config/Config.js';
import { Rng } from '../utils/Rng.js'; import { Rng } from '../utils/Rng.js';
import { NameGenerator } from '../utils/NameGenerator.js'; import { NameGenerator } from '../utils/NameGenerator.js';
import { buildJumpNetwork } from './JumpNetwork.js';
import { generateSystemContent } from './SystemGenerator.js'; import { generateSystemContent } from './SystemGenerator.js';
const TAU = Math.PI * 2; const TAU = Math.PI * 2;
@ -161,6 +162,28 @@ export class Galaxy {
} }
cell.push(rec); cell.push(rec);
} }
// The JUMP NETWORK (data/gates.json): which star each system's jump
// gates reach. A bidirected, degree-limited spanning tree of the
// nearest-star graph (plus local shortcuts) — STRONGLY CONNECTED, so
// there are no closed systems and no trapped sets: from any star the
// player can reach any other, and every system holds
// minGatesmaxGates gates (13 in data/gates.json). Deterministic:
// same roster ⇒ same network.
const pool = Math.max(1, Math.min(count - 1, (config.get('gates.neighborPool', 8) | 0)));
this.jumpNetwork = buildJumpNetwork({
records,
knn: (id) => this.neighborsOf(id, pool),
minGates: Math.max(0, config.get('gates.minGates', 1) | 0),
maxGates: Math.max(1, config.get('gates.maxGates', 3) | 0),
shortcuts: config.get('gates.shortcuts', true) === true,
rootId: this.currentSystemId,
});
// Defensive repairs that had to fire (0 on a healthy kNN graph).
this.jumpNetworkRepaired = this.jumpNetwork.repaired;
if (this.jumpNetworkRepaired > 0) {
console.warn(`[orbit] jump network: ${this.jumpNetworkRepaired} system(s) needed a repair attach`);
}
} }
/** Center-weighted radius sample in [0,1]: core bulge + disk. */ /** Center-weighted radius sample in [0,1]: core bulge + disk. */
@ -242,6 +265,18 @@ export class Galaxy {
return out; return out;
} }
/**
* The other systems this system's jump gates jump to the star
* RECORDS ({ id, name, x, y }), ordered with the "road home" (parent)
* edge first, then the local shortcuts. minGatesmaxGates entries for
* n > 1 (data/gates.json); empty for a one-system galaxy.
*/
jumpGatesFor(id) {
const rec = this.byId.get(id);
if (!rec) throw new Error(`Unknown system "${id}"`);
return (this.jumpNetwork.gates.get(id) ?? []).map((tid) => this.byId.get(tid)).filter(Boolean);
}
/** @returns {object} the player's current (starting) system record */ /** @returns {object} the player's current (starting) system record */
currentSystem() { currentSystem() {
return this.byId.get(this.currentSystemId) ?? this.records[0]; return this.byId.get(this.currentSystemId) ?? this.records[0];

241
js/galaxy/JumpNetwork.js Normal file
View File

@ -0,0 +1,241 @@
/**
* The jump network the galaxy's highway layer (config: data/gates.json).
*
* Given the roster (a 2-D map of star systems) this builds, for EVERY
* system, the list of OTHER systems its jump gates jump to, subject to:
*
* LOCAL a gate only ever jumps to a star in the system's own
* neighbor pool (its `pool` nearest stars, or one that lists
* the system among ITS nearest the symmetric union). The
* gate then sits on the side of the system facing that star
* (SystemGenerator.layoutGates), so "upper-right gate" means
* "a star to the upper right on the map".
* COMPLETE no closed systems, no trapped sets: the directed graph is
* STRONGLY CONNECTED from any star the player can reach
* any other (dev/jumps.test.mjs verifies forward AND
* backward reachability from the home system).
* BOUNDED minGates gates(system) maxGates (1..3 in practice).
* PURE no Math.random: every tie is broken by index. Same seed
* same network (deterministic across machines and runs).
*
* Construction:
* 1. The undirected neighbor graph (u~v iff v nn(u) or u nn(v)).
* 2. A spanning tree of it with every node's degree maxGates, grown
* BFS-outward from the home system with a "keep the frontier open"
* child heuristic: attach the unvisited neighbors with the MOST
* unvisited neighbors first, so rim clusters are absorbed before
* their degree budget is spent.
* 3. Tree edges run BOTH ways. A bidirected tree is strongly connected
* by construction (the unique tree path between any two systems can
* be walked in either direction), and every node's gate count is its
* tree degree at least 1 (no isolated node) and at most maxGates.
* 4. Optional SHORTCUTS: any spare degree budget (nodes under
* maxGates) buys extra one-way local edges the web, not just the
* roads. Adding edges never removes reachability, so the strong
* connectivity survives.
*
* The repair pass (below) is defensive: it fires only if the neighbor
* graph is disconnected (effectively impossible at 40k points), and it
* still respects the degree budget.
*/
/**
* Build the gate network over the roster.
* @param {object} o
* @param {Array<{id:string, x:number, y:number}>} o.records the star roster.
* @param {(id:string) => Array<{id:string}>} o.knn a system's nearest
* neighbors (its neighbor pool; `pool` is the expected pool size, only
* used to validate).
* @param {number} [o.minGates=1] validation bound.
* @param {number} [o.maxGates=3] hard degree budget per node.
* @param {boolean} [o.shortcuts=true] spend spare budget on extra local edges.
* @param {string|null} [o.rootId] grow the tree from this system (the home
* system) it then keeps the lowest possible degree.
* @returns {{ gates: Map<string, string[]>, repaired: number }}
* gates: system id ordered list of gate destinations (parent edge
* first "the road home" then shortcuts).
* repaired: systems that needed the defensive attach (0 on real data).
*/
export function buildJumpNetwork({
records,
knn,
minGates = 1,
maxGates = 3,
shortcuts = true,
rootId = null,
}) {
const n = records.length;
const idx = new Map();
records.forEach((r, i) => idx.set(r.id, i));
const iOf = (id) => {
const i = idx.get(id);
if (i === undefined) throw new Error(`Unknown system "${id}" in jump network`);
return i;
};
// Directed k-nearest per node, as indices.
const out = new Array(n);
for (let i = 0; i < n; i++) out[i] = knn(records[i].id).map((r) => iOf(r.id));
// Reverse links (who lists me) — the undirected neighborhood is the union.
const rev = Array.from({ length: n }, () => []);
for (let i = 0; i < n; i++) for (const j of out[i]) rev[j].push(i);
const nbr = new Array(n);
for (let i = 0; i < n; i++) {
const seen = new Set(out[i]);
nbr[i] = out[i].slice();
for (const j of rev[i]) {
if (!seen.has(j)) {
seen.add(j);
nbr[i].push(j);
}
}
}
// A single-system "galaxy": no one to jump to (the minGates rule is
// vacuous — there is no other star in existence).
if (n <= 1) {
const gates = new Map();
for (const r of records) gates.set(r.id, []);
return { gates, repaired: 0 };
}
const root = rootId ? iOf(rootId) : 0;
const visited = new Uint8Array(n);
const parent = new Int32Array(n).fill(-1);
const deg = new Uint8Array(n);
// --- The degree-limited spanning tree (BFS from the home system) ------
const queue = [root];
visited[root] = 1;
let head = 0;
while (head < queue.length) {
const u = queue[head++];
const budget = maxGates - deg[u];
if (budget <= 0) continue;
const cands = nbr[u].filter((v) => !visited[v]);
if (cands.length === 0) continue;
// "Keep the frontier open": candidates with the most unvisited
// neighbors grow the tree for others; ties by index (determinism).
const scored = cands.map((v) => {
let unv = 0;
for (const w of nbr[v]) if (!visited[w]) unv++;
return { v, unv };
});
scored.sort((a, b) => b.unv - a.unv || a.v - b.v);
for (const { v } of scored.slice(0, budget)) {
visited[v] = 1;
parent[v] = u;
deg[u]++;
deg[v]++;
queue.push(v);
}
}
// --- Repair (defensive): attach anything the tree left behind ---------
// A leftover node has no visited neighbor with spare degree (its whole
// neighborhood sat in a disconnected pocket). Repair, in order of
// preference (all deterministic — index order, strict comparisons):
// 1. Attach to a visited neighbor with spare degree (local).
// 2. SWAP: take one of a visited node u's tree children x, re-home x
// onto one of x's OWN visited neighbors that has spare degree, and
// use the freed budget for w. The tree stays a tree; locality is
// preserved (x stays inside its own neighborhood).
// 3. Last resort (should never fire on a kNN graph): attach w to the
// nearest visited node and accept one over-budget degree — a working
// network beats a broken one.
const dist2 = (a, b) => {
const dx = records[a].x - records[b].x;
const dy = records[a].y - records[b].y;
return dx * dx + dy * dy;
};
let repaired = 0;
for (let w = 0; w < n; w++) {
if (visited[w]) continue;
// (1) local attach
let u = -1;
for (const c of nbr[w]) if (visited[c] && deg[c] < maxGates) { u = c; break; }
if (u === -1) {
// (2) swap: best (u, x) pair by dist(w, u), then indices
let bestU = -1, bestX = -1, bestNew = -1, bestD = Infinity;
for (let c = 0; c < n; c++) {
if (!visited[c] || c === w || deg[c] < 2) continue; // needs a child to free
const d = dist2(w, c);
if (d > bestD) continue;
// children of c in the tree (visited nodes whose parent is c)
for (let x = 0; x < n; x++) {
if (parent[x] !== c) continue;
for (const nn of nbr[x]) {
if (nn === c || nn === w || !visited[nn] || deg[nn] >= maxGates) continue;
if (d < bestD || (d === bestD && (c < bestU || (c === bestU && x < bestX)))) {
bestD = d; bestU = c; bestX = x; bestNew = nn;
}
}
}
}
if (bestU !== -1) {
parent[bestX] = bestNew; // re-home the child
deg[bestU]--;
deg[bestNew]++;
u = bestU;
}
}
if (u === -1) {
// (3) nearest visited node, budget be damned
let bestD = Infinity;
for (let c = 0; c < n; c++) {
if (!visited[c] || c === w) continue;
const d = dist2(w, c);
if (d < bestD) { bestD = d; u = c; }
}
if (u === -1) continue; // nothing to attach to (n === 1 handled above)
console.warn(`[orbit] jump network: forced attach of ${records[w].id} (degree budget exceeded)`);
}
visited[w] = 1;
parent[w] = u;
deg[u]++;
deg[w]++;
repaired++;
}
// --- Shortcuts: the spare budget buys extra one-way local edges --------
const shortcutsOf = Array.from({ length: n }, () => []);
if (shortcuts) {
const linked = new Set();
for (let i = 0; i < n; i++) if (parent[i] >= 0) linked.add(key(i, parent[i]));
for (let u = 0; u < n; u++) {
for (const v of out[u]) {
if (deg[u] >= maxGates) break;
if (linked.has(key(u, v))) continue; // already linked, either way
linked.add(key(u, v));
shortcutsOf[u].push(v);
deg[u]++;
}
}
}
// --- Assemble ----------------------------------------------------------
// Each node's gates = the tree edges touching it — its parent first ("the
// road home"), then its tree children in index order — plus its
// shortcuts. A bidirected spanning tree is strongly connected by
// construction (the unique tree path between any two systems is walkable
// in both directions), so every system both reaches and is reachable;
// every node's gate count is its tree degree (≥ 1 for n > 1, ≤ maxGates)
// plus any shortcuts it bought.
const children = Array.from({ length: n }, () => []);
for (let i = 0; i < n; i++) if (parent[i] >= 0) children[parent[i]].push(i);
const gates = new Map();
for (let i = 0; i < n; i++) {
const targets = [];
if (parent[i] >= 0) targets.push(records[parent[i]].id);
for (const c of children[i].sort((a, b) => a - b)) targets.push(records[c].id);
for (const v of shortcutsOf[i]) targets.push(records[v].id);
if (targets.length < minGates && n > 1) {
// Can't happen (tree degree ≥ 1), but never emit a closed system.
throw new Error(`Jump network left system ${records[i].id} with ${targets.length} gate(s) < minGates ${minGates}`);
}
gates.set(records[i].id, targets);
}
return { gates, repaired };
}
const key = (a, b) => (a < b ? a : b) + '\u0000' + (a < b ? b : a);

View File

@ -31,16 +31,38 @@ const DEG = Math.PI / 180;
* The galaxy is ALREADY LIVED IN: it was settled long before the player. * The galaxy is ALREADY LIVED IN: it was settled long before the player.
* EVERY planet hosts a settlement (for now data/settlements.json * EVERY planet hosts a settlement (for now data/settlements.json
* allPlanetsSettled + settledKindByClass): colonies on habitable worlds, * allPlanetsSettled + settledKindByClass): colonies on habitable worlds,
* mining stations over the rest, cloud bases riding gas giants. The * mining stations over the rest, cloud bases riding gas giants. Every
* charted-but-unclaimed systems are the barren ones that also roll no * system holds at least one planet or free-space station a barren
* free-space station. Nothing here is hostile yet: `owner` on every * system that rolls no station gets a gate station because the jump
* settlement is a reserved seam for the factions and pirates we'll * gates (below) must be tether-reachable from an anchor. Nothing here is
* introduce later. * hostile yet: `owner` on every settlement is a reserved seam for the
* factions and pirates we'll introduce later.
* *
* The STARTING system is special: the player's home world sits at the * The STARTING system is special: the player's home world sits at the
* origin (not a generated planet, fixed key 'home'), and the system always * origin (not a generated planet, fixed key 'home'), and the system always
* holds exactly two more planets a gas giant and a rocky world. With the * holds exactly two more planets a gas giant and a rocky world. With the
* home world, three planets, always. * home world, three planets, always. And it is capped at ONE free-space
* station: five objects (the home world + four) cannot sit 6400..10240 px
* apart the tightest 5-point spacing needs a max/min ratio φ 1.618,
* which the home band 10240/6400 = 1.6 cannot give so the home system
* stays a 4-object configuration (see layoutSystem).
*
* LAYOUT (data/planets.json solarSystem) the SOLAR SYSTEM BAND: every
* PAIR of layout objects (planets, free-space stations, and the central
* body the star, or the home world in the starting system at the
* local origin) sits 6400..15360 px apart, center to center; in the home
* system the band tightens to 6400..10240 px. Normal systems lay out as a
* regular N-gon ring around the star; the home system as a regular
* (N+1)-polygon with the home world as one vertex. The rotation is chosen
* to serve the jump gates objects bias toward the directions the system
* jumps (see layoutSystem + layoutGates).
*
* JUMP GATES (data/gates.json; the network in js/galaxy/JumpNetwork.js):
* 13 gates per system each placed on the side of the system facing its
* destination star on the 2-D map (an upper-right gate jumps to a star in
* the upper right), within level-1 tether (5120 px) of a planet or space
* station. The galaxy-wide network is strongly connected: no closed
* systems, no trapped sets.
* *
* Place identity (the reputation/trading/faction keys): every planet and * Place identity (the reputation/trading/faction keys): every planet and
* settlement carries a stable `id`, seed-deterministic because it is * settlement carries a stable `id`, seed-deterministic because it is
@ -133,21 +155,39 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
Rng.derive(galaxy.seed, 'system', record.id, 'names', 'stations') Rng.derive(galaxy.seed, 'system', record.id, 'names', 'stations')
), ),
density: settlementDensity(galaxy, record), density: settlementDensity(galaxy, record),
isHome,
}); });
// --- Layout: orbits around the home world ----------------------------- // --- Jump gate targets (the galaxy's gate network) --------------------
// The other stars this system's gates jump to (Galaxy.jumpNetwork —
// data/gates.json, js/galaxy/JumpNetwork.js): 1maxGates, local (the
// system's nearest-star pool), strongly connected. Ordered with the
// "road home" (parent) edge first. Empty for a one-system galaxy.
const targets =
typeof galaxy?.jumpGatesFor === 'function' ? galaxy.jumpGatesFor(record.id) : [];
// Bearings in the 2-D map plane — the same frame the system view uses,
// so "an upper-right gate" means "a star to the upper right on the map".
const targetAngles =
targets.length > 0
? targets.map((t) => Math.atan2(t.y - record.y, t.x - record.x))
: null;
// --- Layout: the system's objects + its jump gates --------------------
// Planets and free-space stations are the system's layout objects — each // Planets and free-space stations are the system's layout objects — each
// gets an x/y (see layoutSystem for the spacing rules and the N=11 case). // gets an x/y (see layoutSystem for the band rule), and the gates are
// placed toward their target stars (see layoutGates).
const freeSpace = settlements.filter((s) => s.anchor?.type === 'space'); const freeSpace = settlements.filter((s) => s.anchor?.type === 'space');
layoutSystem(galaxy.seed, record.id, planets, freeSpace); layoutSystem(galaxy.seed, record.id, planets, freeSpace, isHome, targetAngles);
const jumps = layoutGates(galaxy.seed, record, planets, freeSpace, isHome, targets);
// --- Asteroid clusters ------------------------------------------------ // --- Asteroid clusters ------------------------------------------------
// Loose groups of slowly tumbling rocks scattered through the void. // Loose groups of slowly tumbling rocks scattered through the void.
// Generated AFTER the layout (so every placed object is a spacing // Generated AFTER the layout (so every placed object — worlds, stations,
// obstacle) from the dedicated stream (seed, 'system', id, 'asteroids') // and the jump gates — is a spacing obstacle) from the dedicated stream
// — independent of the star/planet/settlement/layout draws above, so // (seed, 'system', id, 'asteroids') — independent of the
// star/planet/settlement/layout draws above, so
// lazy (on-arrival) === eager (generateAll) is preserved. // lazy (on-arrival) === eager (generateAll) is preserved.
const asteroids = generateAsteroidClusters(galaxy, record, planets, freeSpace); const asteroids = generateAsteroidClusters(galaxy, record, planets, freeSpace, jumps);
// --- Debris belt & system-level hazard -------------------------------- // --- Debris belt & system-level hazard --------------------------------
const belt = { const belt = {
@ -165,6 +205,7 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
asteroids, asteroids,
belt, belt,
hazard, hazard,
jumps, // the system's jump gates (13; [] for a one-system galaxy)
}; };
if (isHome) content.homeName = homeName; // the player's home world (starting system only) if (isHome) content.homeName = homeName; // the player's home world (starting system only)
return content; return content;
@ -172,142 +213,359 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
/** /**
* Top-down layout of a system's objects its planets and its free-space * Top-down layout of a system's objects its planets and its free-space
* stations as one or two ORBITS (rings) around the home world, which * stations under the SOLAR SYSTEM BAND (data/planets.json
* always sits at the system origin (the game renders a solid world there; * solarSystem):
* see GameScene).
* *
* Each object gains x, y (world position). Planets also gain scale (size * Every PAIR of layout objects the planets, the free-space stations,
* multiplier, data/planets.json classScale the spacing rules below are * and the central body (the star, or the HOME world in the starting
* center-to-center, but the rendered discs still scale by class). * system which sits at the local origin) is at least `minSpacing`
* and at most the band's maximum apart, center to center:
* *
* The hard spacing rules (data/planets.json solarSystem): * normal systems : minSpacing..maxSpacing (6400..15360 px)
* minSpacing no two objects (planets, space stations, the home world) * home system : minSpacing..homeMaxSpacing (6400..10240 px)
* may be closer than this, center to center;
* maxNeighbor whenever a system holds more than one object, every
* object must be within this of at least one other.
* *
* A ring of k objects at radius R around the origin satisfies both at once: * (The old "every object within maxNeighbor of some object" rule is
* - object home-world distance is R, so R [minSpacing, maxNeighbor] * implied: 6 objects inside a 15360 px band keeps every object within
* takes care of the home world's own pair of constraints; * level-2/3 tether of the others.)
* - the closest object-object pair on a regular k-gon is an edge, *
* 2·R·sin(π/k) an edge is the shortest chord (vertices further around * Shapes that satisfy a full pairwise band exactly:
* are further), so edge minSpacing covers every pair; * N = 1, non-home a single point at distance R [min, max]; its only
* - every object's nearest neighbor is then the home world, at * pair (with the star) is just R.
* R maxNeighbor. * N 2, non-home a REGULAR N-GON RING around the star: every pair is
* That radius range is non-empty for k 10 (k = 11 would need * a chord, the longest being 2·R·sin(N/2·π/N), so
* R 10905, which already strands the home world beyond maxNeighbor). * R [min, max / (2·sin(N/2·π/N))].
* The current data's maximum is 6 objects (4 planets + 2 free-space * home system a REGULAR (N+1)-POLYGON with the home world as ONE
* stations), which always fits a single orbit; the 11-object case is kept * VERTEX (the origin): every pair is a polygon chord,
* as a defensive fallback (an inner 3-ring and an outer 8-ring) for any * the longest ratio(N+1)·side, so side [min,
* future data that could produce it. * homeMax / ratio(N+1)]. (The home world + 4 objects
* The outer ring's near neighbor is its ring-mate (edge stays in * 5 points would need a max/min ratio φ 1.618,
* [minSpacing, maxNeighbor]); the inner ring keeps the home world within * which the home band 10240/6400 = 1.6 cannot give;
* maxNeighbor; and any inner/outer pair is at least R_outer R_inner * that is why the home system is capped at 3 objects:
* minSpacing apart. Smaller N all share one orbit. (Defensively, beyond * its 2 fixed planets + at most one free-space
* 11 objects the orbits chain outward rings of 10, and a lone world * station see generateSettlements.)
* always fits radially outside the previous orbit so this terminates
* for any N.)
* *
* What counts as an object: planets (all of them) and free-space * What counts as an object: planets (all of them) and free-space
* settlements (anchor.type 'space' deep-space stations, waypoints). * settlements (anchor.type 'space' deep-space stations, waypoints).
* Planet-bound settlements are features OF their planet (a colony sits on * Planet-bound settlements are features OF their planet (a colony sits on
* it) and so are not layout objects of their own. * it) and so are not layout objects of their own. The central body is the
* origin (the game renders the home world there in the starting system;
* elsewhere it is the star the visual, but part of the spacing rule).
* *
* Determinism: draws come from the dedicated fork (seed, 'system', id, * The ROTATION (ring phase / polygon orientation) is chosen to serve the
* 'layout') layout never perturbs the star/planet/settlement draws * jump gates: it minimizes the worst perpendicular distance between a
* above, and lazy (on-arrival) === eager (generateAll) is preserved. * gate's target ray and the nearest object, so layoutGates can sit each
* gate on the true target ray while staying tether-reachable. Without
* targets (gates disabled, one-system galaxy) it falls back to a seeded
* random rotation.
*
* Each planet also gains scale (size multiplier, data/planets.json
* classScale the band is center-to-center, but the rendered discs still
* scale by class).
*
* Determinism: the radius/side draw comes from the dedicated fork
* (seed, 'system', id, 'layout'); the rotation is a pure function of
* (shape, target angles). Same seed same layout, and lazy
* (on-arrival) === eager (generateAll).
*/ */
function layoutSystem(seed, systemId, planets, freeSpace) { function layoutSystem(seed, systemId, planets, freeSpace, isHome, targetAngles) {
const band = config.get('planets.solarSystem', {}); const band = config.get('planets.solarSystem', {});
if (band.enabled === false) return; if (band.enabled === false) return;
const MIN_SEP = band.minSpacing ?? 6144; const MIN = Math.max(1, band.minSpacing ?? 6400);
const MAX_NBR = band.maxNeighbor ?? 10240; const MAX = Math.max(MIN, isHome ? (band.homeMaxSpacing ?? 10240) : (band.maxSpacing ?? 15360));
// Rendered size per class (visual only — the spacing rules are // Rendered size per class (visual only — the band is center-to-center).
// center-to-center, not edge-to-edge).
for (const p of planets) { for (const p of planets) {
p.scale = config.get(`planets.classScale.${p.class}`, 1) ?? 1; p.scale = config.get(`planets.classScale.${p.class}`, 1) ?? 1;
} }
// Slot assignment order: planets in orbital order, then free-space // Slot assignment order: planets in orbital order, then free-space
// stations in generation order (deep-space station, then waypoint) — // stations in generation order — stable per system, so lazy === eager.
// stable per system, so lazy === eager.
const objects = [...planets, ...freeSpace]; const objects = [...planets, ...freeSpace];
const N = objects.length; const N = objects.length;
if (N === 0) return; if (N === 0) return;
const TAU = Math.PI * 2;
const lay = Rng.derive(seed, 'system', systemId, 'layout'); const lay = Rng.derive(seed, 'system', systemId, 'layout');
const edgeFactor = (k) => (k <= 1 ? 1 : 2 * Math.sin(Math.PI / k));
const slots = [];
let prevR = 0;
let prevAngle = 0;
const placeRing = (k, lo, hi) => { // `place(phi)` → the N object positions [{x, y}] for rotation `phi`.
const R = lay.range(lo, hi); let place;
// A LONE object on an outer orbit sits radially outside an if (isHome) {
// already-placed one: its guaranteed neighbor is then exactly // Regular (N+1)-gon, the home world (origin) as vertex 0: vertex k is
// R prevR away, whereas a random angle could leave it more than // Rc·(u(φ + 2πk/m) u(φ)) — a chord of length 2·Rc·sin(πk/m).
// maxNeighbor from every inner object. const m = N + 1;
const th0 = k === 1 && prevR > 0 ? prevAngle : lay.range(0, TAU); const sideMax = MAX / ratioOf(m);
for (let i = 0; i < k; i++) { if (sideMax < MIN) {
const th = th0 + (k === 1 ? 0 : (TAU * i) / k); console.warn(
slots.push({ x: R * Math.cos(th), y: R * Math.sin(th) }); `[orbit] ${systemId}: ${N} objects cannot fit the home band [${MIN}, ${MAX}] px — using the minimum spacing`,
);
} }
prevR = R; const side = lay.range(MIN, Math.max(MIN, sideMax));
prevAngle = th0; const Rc = side / (2 * Math.sin(Math.PI / m));
}; place = (phi) => {
const out = [];
if (N <= 10) { for (let k = 1; k < m; k++) {
// One orbit around the home world (k = 1: a single object, whose only out.push({
// neighbor is the home world — fine, it's still within [MIN_SEP, MAX_NBR]). x: Rc * (Math.cos(phi + (TAU * k) / m) - Math.cos(phi)),
const f = edgeFactor(N); y: Rc * (Math.sin(phi + (TAU * k) / m) - Math.sin(phi)),
placeRing(N, Math.max(MIN_SEP, MIN_SEP / f), MAX_NBR); });
} else if (N === 11) {
// Inner 3-ring: the home world must stay within MAX_NBR of it (R1 ≤
// MAX_NBR), and the outer 8-ring must sit at least MIN_SEP beyond it
// at a radius where its edge is still ≤ MAX_NBR:
// R1 ≤ MAX_NBR / edgeFactor(8) MIN_SEP.
const f8 = edgeFactor(8);
placeRing(
3,
Math.max(MIN_SEP, MIN_SEP / edgeFactor(3)),
Math.min(MAX_NBR, MAX_NBR / f8 - MIN_SEP),
);
const R1 = prevR;
// Outer 8-ring: ring-mates are its near neighbors (edge in
// [MIN_SEP, MAX_NBR]); every inner object is ≥ R2 R1 ≥ MIN_SEP away.
placeRing(
8,
Math.max(MIN_SEP / f8, R1 + MIN_SEP),
Math.min(MAX_NBR / f8, R1 + MAX_NBR),
);
} else {
// Beyond the data maximum: chain orbits outward. A ring of ≤ 10
// objects always has a valid radius as ring 1, a lone world always
// fits on any outer orbit — the loop terminates for any N.
let rem = N;
while (rem > 0) {
const k = Math.min(rem, 10);
const f = edgeFactor(k);
const lo = prevR === 0 ? Math.max(MIN_SEP, MIN_SEP / f) : Math.max(MIN_SEP / f, prevR + MIN_SEP);
const hi = prevR === 0 ? MAX_NBR : Math.min(MAX_NBR / f, prevR + MAX_NBR);
if (lo <= hi) {
placeRing(k, lo, hi);
rem -= k;
} else {
placeRing(1, prevR + MIN_SEP, prevR + MAX_NBR);
rem -= 1;
} }
} return out;
};
} else {
// Regular N-gon ring around the star (origin). N = 1: one point at
// distance R — its only pair (with the star) is just R.
const rMax = N === 1 ? MAX : MAX / (2 * Math.sin((Math.floor(N / 2) * Math.PI) / N));
const R = lay.range(MIN, Math.max(MIN, rMax));
place = (phi) => {
const out = [];
for (let k = 0; k < N; k++) {
const a = phi + (TAU * k) / N;
out.push({ x: R * Math.cos(a), y: R * Math.sin(a) });
}
return out;
};
} }
for (let i = 0; i < objects.length; i++) { const phi = targetAngles ? bestRotation(place, targetAngles) : lay.range(0, TAU);
const slots = place(phi);
for (let i = 0; i < N; i++) {
objects[i].x = slots[i].x; objects[i].x = slots[i].x;
objects[i].y = slots[i].y; objects[i].y = slots[i].y;
} }
} }
/** Max-chord / side ratio of a regular `m`-gon (m ≥ 3); 1 for m < 3. */
function ratioOf(m) {
if (m < 3) return 1;
const half = Math.floor(m / 2);
return Math.sin((half * Math.PI) / m) / Math.sin(Math.PI / m);
}
/**
* The rotation that best serves the jump gates: minimize the WORST
* perpendicular distance between a gate's target ray and the NEAREST
* object (the gate then sits on the ray whenever that distance is within
* the anchor tether range layoutGates). Coarse scan + local refine; the
* first minimum on the deterministic grid wins, so the result is exact
* for the seed (no Math.random anywhere).
*/
function bestRotation(place, targetAngles) {
const score = (phi) => {
const pts = place(phi);
let worst = 0;
for (const t of targetAngles) {
const st = Math.sin(t);
const ct = Math.cos(t);
let best = Infinity;
for (const p of pts) {
const h = Math.abs(p.x * st - p.y * ct); // perpendicular distance to the ray line
if (h < best) best = h;
}
if (best > worst) worst = best;
}
return worst;
};
const STEPS = 4096;
const span = TAU / STEPS;
let bi = 0;
let bv = Infinity;
for (let i = 0; i < STEPS; i++) {
const v = score(i * span);
if (v < bv) {
bv = v;
bi = i;
}
}
let bestPhi = bi * span;
for (let j = -16; j <= 16; j++) {
const i = bi + j;
if (i < 0 || i >= STEPS) continue;
const phi = i * span;
const v = score(phi);
if (v < bv) {
bv = v;
bestPhi = phi;
}
}
return bestPhi;
}
/**
* JUMP GATES the physical gates of the system, one per gate-network
* target (Galaxy.jumpNetwork; data/gates.json). Each gate:
*
* - sits on the system's side of its DESTINATION star the bearing is
* computed in the 2-D map plane, so "an upper-right gate" means "a
* star to the upper right on the map";
* - is within level-`anchorTetherLevel` tether (5120 px for level 1) of
* an ANCHOR a planet or space station (the home world counts) on
* the anchor's tether circle, chosen in order of facing quality:
* (1) the far ray-circle intersection (the gate exactly on the target
* ray system, gate, and star collinear), (2) the point of the
* circle aimed exactly at the target star, (3) a forward-hemisphere
* scan of the circle (±75°). Every candidate is exactly `range` from
* its anchor, so tether-reachability holds by construction and the
* facing deviation never exceeds 90° (in practice a few degrees);
* - stays `minRadius..maxRadius` from the star, `size` + `clearance`
* clear of every anchor disc, and 2·`size` + `gateGap` from every
* other gate.
*
* The target list arrives ordered (the "road home" parent edge first) and
* anchors iterate in content order, so the placement is exact for the
* seed same seed same gates (dev/jumps.test.mjs).
*
* Record shape (one per gate):
* { id: `<systemId>-j<n>`, name: `<Star> Gate`, to, toName,
* x, y, size, rotation }
*/
function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
const g = config.section('gates', {});
if (g.enabled === false) return [];
if (!Array.isArray(targets) || targets.length === 0) return [];
const size = Math.max(1, Math.floor(g.size ?? 96));
const clearance = Math.max(0, g.clearance ?? 256);
const gap = Math.max(0, g.gateGap ?? 192);
const minR = Math.max(0, g.minRadius ?? 2048);
const maxR = Math.max(minR, g.maxRadius ?? 20480);
const range = anchorTetherRange(g.anchorTetherLevel ?? 1);
// Anchors: the planets, the free-space stations, and (home only) the
// home world at the origin — the bodies a gate's tether may hang from.
// (Every system holds at least one — generateSettlements guarantees it.)
const anchors = [
...planets.map((p) => ({ x: p.x, y: p.y, r: planetRenderRadius(p) })),
...freeSpace.map((f) => ({ x: f.x, y: f.y, r: stationKeepout(f.kind) })),
];
if (isHome) anchors.push({ x: 0, y: 0, r: homeWorldRadius() });
const jumps = [];
const gateNames = new Map(); // star name → how many gates named after it
for (let i = 0; i < targets.length; i++) {
const t = targets[i];
const th = Math.atan2(t.y - record.y, t.x - record.x);
const ux = Math.cos(th);
const uy = Math.sin(th);
// Candidate points, best first:
// tier 1 — for every anchor, the FAR ray-circle intersection: the
// gate exactly ON the target ray (system center, gate, and
// star collinear), outside the anchor. Always faces the
// target (≤ 90° from the anchor's point of view).
// tier 2 — for every anchor, the point on the anchor's tether circle
// that faces the target: a + range·u — exactly `range` from
// the anchor (tether-reachable) and aimed exactly at the
// star (zero deviation from the anchor's point of view).
// tier 3 — a forward-hemisphere scan around each anchor's circle
// (32 points, ± up to 75° from the target direction) — the
// clearance search for tight systems.
// Every candidate is exactly `range` from an anchor, so the level-N
// tether rule (hard) is met by construction; the direction rule (soft)
// is honored by the tier order: on-ray → aimed → near-aimed.
const cands = [];
anchors.forEach((a, ai) => {
const proj = a.x * ux + a.y * uy; // signed distance along the ray
const h = Math.abs(a.x * uy - a.y * ux); // perpendicular distance
if (h <= range) {
const off = Math.sqrt(Math.max(0, range * range - h * h));
cands.push({ tier: 1, order: h * 1e6 + ai * 1000, px: (proj + off) * ux, py: (proj + off) * uy });
}
cands.push({ tier: 2, order: h * 1e6 + ai * 1000, px: a.x + range * ux, py: a.y + range * uy });
for (let k = 0; k < 32; k++) {
const phi = -1.3089 + (2.6179 * k) / 31; // ±75° around the target direction
const dx = ux * Math.cos(phi) - uy * Math.sin(phi);
const dy = ux * Math.sin(phi) + uy * Math.cos(phi);
cands.push({ tier: 3, order: Math.abs(phi) * 1e6 + h + ai * 1e-3, px: a.x + range * dx, py: a.y + range * dy });
}
});
cands.sort((p, q) => p.tier - q.tier || p.order - q.order);
const ok = (c) => {
const d2c = c.px * c.px + c.py * c.py;
if (d2c < minR * minR || d2c > maxR * maxR) return false;
for (const b of anchors) {
const need = b.r + size + clearance;
const dx = c.px - b.x;
const dy = c.py - b.y;
if (dx * dx + dy * dy < need * need) return false;
}
for (const j of jumps) {
const need = 2 * size + gap;
const dx = c.px - j.x;
const dy = c.py - j.y;
if (dx * dx + dy * dy < need * need) return false;
}
return true;
};
let chosen = null;
for (const c of cands) {
if (ok(c)) {
chosen = c;
break;
}
}
if (!chosen) {
// Every candidate failed clearance (nearly impossible — an anchor's
// tether circle is 5120 px across, the discs under a thousand): take
// the first anchor's aimed point anyway — the tether and facing rules
// outrank cosmetics.
chosen = cands.find((c) => c.tier === 2) ?? cands[0];
console.warn(
`[orbit] ${record.id}: gate ${i + 1} fell back to its first aimed candidate (clearance)`,
);
}
// Unique gate name (two targets can share a star name — star names are
// syllable-generated): "Avidy Gate", "Avidy Gate II", "Avidy Gate III".
let name = `${t.name} Gate`;
const k = (gateNames.get(name) ?? 0) + 1;
gateNames.set(name, k);
if (k > 1) name = `${name} ${k === 2 ? 'II' : 'III'}`;
jumps.push({
id: `${record.id}-j${i + 1}`,
name,
to: t.id,
toName: t.name,
x: chosen.px,
y: chosen.py,
size,
// The gate's visual bearing — from the gate's own position to the
// destination star, so it always points exactly at where it jumps.
rotation: Math.atan2(t.y - chosen.py, t.x - chosen.px),
});
}
return jumps;
}
/** Level-N tether radius (data/tether.json): level1Radius × growth^(N1). */
function anchorTetherRange(level) {
const lv = Math.max(1, Math.floor(level ?? 1));
const base = Math.max(1, config.get('tether.level1Radius', 5120));
const growth = Math.max(1, config.get('tether.radiusGrowth', 2.0));
return base * Math.pow(growth, lv - 1);
}
/** A planet's rendered disc radius (the anchor keepout for gate clearance). */
function planetRenderRadius(p) {
const frame = Math.max(1, Math.floor(config.get('planets.frameWidth', 1024)));
const scale = Math.max(0.01, config.get('planets.scale', 1.0));
const classScale = Math.max(0.01, p.scale ?? config.get(`planets.classScale.${p.class}`, 1));
return (frame * scale * classScale) / 2;
}
/** A free-space station's keepout radius (data/stations.json). */
function stationKeepout(kind) {
return Math.max(1, Math.floor(config.get(`stations.kinds.${kind}.size`, 108)));
}
/** The home world's disc radius (data/planets.json). */
function homeWorldRadius() {
const frame = Math.max(1, Math.floor(config.get('planets.frameWidth', 1024)));
const scale = Math.max(0.01, config.get('planets.scale', 1.0));
return (frame * scale) / 2;
}
/** /**
* Asteroid clusters the system's loose rock fields (data/asteroids.json). * Asteroid clusters the system's loose rock fields (data/asteroids.json).
* *
@ -357,7 +615,7 @@ function layoutSystem(seed, systemId, planets, freeSpace) {
* asteroids: [{ frame, x, y, size, spin, phase }] // rocks, local px * asteroids: [{ frame, x, y, size, spin, phase }] // rocks, local px
* } * }
*/ */
function generateAsteroidClusters(galaxy, record, planets, freeSpace) { function generateAsteroidClusters(galaxy, record, planets, freeSpace, jumps = []) {
const cfg = config.section('asteroids', {}); const cfg = config.section('asteroids', {});
if (cfg.enabled === false) return []; if (cfg.enabled === false) return [];
// Without the solar-system layout there are no placed objects to space // Without the solar-system layout there are no placed objects to space
@ -414,10 +672,12 @@ function generateAsteroidClusters(galaxy, record, planets, freeSpace) {
for (const p of planets) if (p.name) usedNames.add(p.name); for (const p of planets) if (p.name) usedNames.add(p.name);
for (const s of freeSpace) if (s.name) usedNames.add(s.name); for (const s of freeSpace) if (s.name) usedNames.add(s.name);
// Spacing obstacles: the home world (origin) + every placed object. // Spacing obstacles: the home world (origin) + every placed object
// (planets, free-space stations, and the jump gates).
const placed = [{ x: 0, y: 0 }]; const placed = [{ x: 0, y: 0 }];
for (const p of planets) if (typeof p.x === 'number' && typeof p.y === 'number') placed.push({ x: p.x, y: p.y }); for (const p of planets) if (typeof p.x === 'number' && typeof p.y === 'number') placed.push({ x: p.x, y: p.y });
for (const s of freeSpace) if (typeof s.x === 'number' && typeof s.y === 'number') placed.push({ x: s.x, y: s.y }); for (const s of freeSpace) if (typeof s.x === 'number' && typeof s.y === 'number') placed.push({ x: s.x, y: s.y });
for (const j of jumps) if (typeof j.x === 'number' && typeof j.y === 'number') placed.push({ x: j.x, y: j.y });
const clusters = []; const clusters = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
@ -626,7 +886,7 @@ function settlementDensity(galaxy, record) {
* the old per-type odds (spec.chance + needs) take over again. The * the old per-type odds (spec.chance + needs) take over again. The
* free-space kinds still roll per type (corerim scaled). * free-space kinds still roll per type (corerim scaled).
*/ */
function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDeck, density }) { function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDeck, density, isHome = false }) {
const out = []; const out = [];
let nameIndex = 0; // next station name from the system's deck (no repeats) let nameIndex = 0; // next station name from the system's deck (no repeats)
@ -668,13 +928,26 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe
} }
// Free-floating, out in the dark (per-type odds, core→rim scaled). // Free-floating, out in the dark (per-type odds, core→rim scaled).
// The HOME system is capped at ONE free-space station: with the home
// world and its two fixed planets it must stay a ≤ 4-object
// configuration — the home band (6400..10240 px) cannot hold 5 points
// (see layoutSystem).
if (roll(rng, spec.deepSpaceStation?.chance ?? 0.12, density)) { if (roll(rng, spec.deepSpaceStation?.chance ?? 0.12, density)) {
make('deepSpaceStation', { type: 'space' }); make('deepSpaceStation', { type: 'space' });
} }
if (roll(rng, spec.waypoint?.chance ?? 0.2, density)) { if (!isHome && roll(rng, spec.waypoint?.chance ?? 0.2, density)) {
make('waypoint', { type: 'space' }); make('waypoint', { type: 'space' });
} }
// JUMP-GATE ANCHOR GUARANTEE — every system must hold at least one
// planet or space station: the jump gates sit within level-1 tether of
// an anchor, so a system with neither would be unreachable (a closed
// system). A barren system that rolled no free-space station gets a
// gate station.
if (planets.length === 0 && out.length === 0) {
make('deepSpaceStation', { type: 'space' });
}
return out; return out;
} }

View File

@ -10,7 +10,7 @@ import { config } from '../config/Config.js';
* *
* const report = formatSystemReport(content); * const report = formatSystemReport(content);
* // { title, subtitle, settlements: [{ text, color, population }], * // { title, subtitle, settlements: [{ text, color, population }],
* // summary, population } * // gates: [{ text, color }], summary, population }
*/ */
export function formatSystemReport(content, typeDefs = null, kindDefs = null) { export function formatSystemReport(content, typeDefs = null, kindDefs = null) {
const types = typeDefs ?? config.get('systems.types', {}); const types = typeDefs ?? config.get('systems.types', {});
@ -35,11 +35,23 @@ export function formatSystemReport(content, typeDefs = null, kindDefs = null) {
} }
const population = settlements.reduce((sum, s) => sum + s.population, 0); const population = settlements.reduce((sum, s) => sum + s.population, 0);
// The jump gates — the system's exits (content.jumps; each line names
// the star the gate jumps to, in the gate's cyan).
const gates = (content.jumps ?? []).map((j) => ({
text: `${j.name} · jump to ${j.toName}`,
color: config.get('gates.theme.color', '#5fd4ff'),
}));
const n = settlements.length; const n = settlements.length;
const g = gates.length;
const summary = const summary =
n === 0 n === 0
? 'charted · unclaimed' ? g === 0
: `${n} settlement${n === 1 ? '' : 's'} · pop ~${formatPop(population)}`; ? 'charted · unclaimed'
: `unclaimed · ${g} jump gate${g === 1 ? '' : 's'}`
: `${n} settlement${n === 1 ? '' : 's'} · pop ~${formatPop(population)}` +
(g ? ` · ${g} jump gate${g === 1 ? '' : 's'}` : '');
const planetN = content.planets.length; const planetN = content.planets.length;
const asteroidN = Array.isArray(content.asteroids) ? content.asteroids.length : 0; const asteroidN = Array.isArray(content.asteroids) ? content.asteroids.length : 0;
@ -49,6 +61,7 @@ export function formatSystemReport(content, typeDefs = null, kindDefs = null) {
`${typeDef.label ?? content.type} system · star ${content.star.class} · ${planetN} planet${planetN === 1 ? '' : 's'}` + `${typeDef.label ?? content.type} system · star ${content.star.class} · ${planetN} planet${planetN === 1 ? '' : 's'}` +
(asteroidN ? ` · ${asteroidN} asteroid cluster${asteroidN === 1 ? '' : 's'}` : ''), (asteroidN ? ` · ${asteroidN} asteroid cluster${asteroidN === 1 ? '' : 's'}` : ''),
settlements, settlements,
gates,
summary, summary,
population, population,
}; };

View File

@ -15,6 +15,7 @@ import { Ship } from '../entities/Ship.js';
import { Planet } from '../entities/Planet.js'; import { Planet } from '../entities/Planet.js';
import { AsteroidCluster } from '../entities/AsteroidCluster.js'; import { AsteroidCluster } from '../entities/AsteroidCluster.js';
import { Station } from '../entities/Station.js'; import { Station } from '../entities/Station.js';
import { JumpGate } from '../entities/JumpGate.js';
import { Starfield } from '../visuals/Starfield.js'; import { Starfield } from '../visuals/Starfield.js';
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js'; import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.js';
import { ActionBar } from '../ui/ActionBar.js'; import { ActionBar } from '../ui/ActionBar.js';
@ -272,10 +273,27 @@ export class GameScene extends Phaser.Scene {
} }
} }
// Jump gates (content.jumps — one per destination in the system's
// gate network, js/galaxy/JumpNetwork.js): the system's exits.
// Solid (the ship keeps its clearance), discoverable (compass +
// toast, in their own cyan), and each one faces its destination
// star on the map (entity.rotation). They are NOT comms targets —
// standing at a gate is where the jump happens (coming with the
// jump mechanic); GameScene.worldObjectAt deliberately excludes
// them, so a click falls through to fly-to-point.
this.systemGates = [];
if (config.get('gates.enabled', true) !== false) {
for (const j of this.systemContent.jumps ?? []) {
if (typeof j.x !== 'number' || typeof j.y !== 'number') continue;
this.systemGates.push(new JumpGate(this, j, { depth: 5 }));
}
}
// Every solid in the system — worlds first (their keep-out circles are // Every solid in the system — worlds first (their keep-out circles are
// disjoint), then the clusters, then the stations. Ship constraint, // disjoint), then the clusters, then the stations, then the gates.
// click-to-fly clamping and autopilot all run against this list. // Ship constraint, click-to-fly clamping and autopilot all run
this.solids = [this.planet, ...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations]; // against this list.
this.solids = [this.planet, ...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations, ...this.systemGates];
this.planet.discoveryId = 'home'; this.planet.discoveryId = 'home';
this.planet.discoveryName = this.homeWorldName; this.planet.discoveryName = this.homeWorldName;
@ -793,6 +811,9 @@ export class GameScene extends Phaser.Scene {
for (const s of report.settlements) { for (const s of report.settlements) {
line(s.text, { fontFamily: fam, fontSize: '13px', color: toCss(s.color, '#8fa0c9') }); line(s.text, { fontFamily: fam, fontSize: '13px', color: toCss(s.color, '#8fa0c9') });
} }
for (const gt of report.gates ?? []) {
line(gt.text, { fontFamily: fam, fontSize: '13px', color: toCss(gt.color, '#5fd4ff') });
}
line(report.summary, { fontFamily: fam, fontSize: '12px', color: '#54608a' }); line(report.summary, { fontFamily: fam, fontSize: '12px', color: '#54608a' });
line(`seed ${this.galaxy.seed}`, { fontFamily: fam, fontSize: '11px', color: '#3d476b' }); line(`seed ${this.galaxy.seed}`, { fontFamily: fam, fontSize: '11px', color: '#3d476b' });
this.hudDetail = detail; this.hudDetail = detail;
@ -997,6 +1018,7 @@ export class GameScene extends Phaser.Scene {
// after the physics step has moved the ship.) // after the physics step has moved the ship.)
for (const c of this.asteroidClusters) c.update(_time); for (const c of this.asteroidClusters) c.update(_time);
for (const st of this.systemStations) st.update(_time); // the ring turns, the beacon breathes for (const st of this.systemStations) st.update(_time); // the ring turns, the beacon breathes
for (const gt of this.systemGates) gt.update(_time); // the field breathes, the ring drifts
this.mining.update(_time, delta); // the arm: extending → beam (tracks the drifting rocks) this.mining.update(_time, delta); // the arm: extending → beam (tracks the drifting rocks)
if (this.scanObjects) this.updateScanHits(_time); // the front crossing objects → ring if (this.scanObjects) this.updateScanHits(_time); // the front crossing objects → ring
this.scanPulse.update(_time, delta); // the pulse: charge → front → absorbed → settle this.scanPulse.update(_time, delta); // the pulse: charge → front → absorbed → settle
@ -1184,6 +1206,19 @@ export class GameScene extends Phaser.Scene {
name: st.discoveryName, name: st.discoveryName,
}); });
} }
// Jump gates are objects too: discoverable, compass arrows, autopilot —
// in their own cyan (the exits, so easy to spot on the compass).
for (const gt of this.systemGates) {
out.push({
id: gt.discoveryId,
x: gt.x,
y: gt.y,
radius: gt.bound,
typeLabel: config.get('gates.typeLabel', 'Jump Gate'),
color: config.get('gates.theme.color', '#5fd4ff'),
name: gt.discoveryName,
});
}
return out; return out;
} }