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.
- 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**
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
world's **name** (e.g. `HOME WORLD · ESHKAELURA`); **clicking the
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…)
│ ├── systems.json # system archetypes: theme, attributes, distribution
│ ├── 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
│ │ # trees live one-per-file below (section name = file basename)
│ ├── research/
@ -160,8 +164,8 @@ orbit/
│ ├── main.js # entry point: load config → boot Phaser
│ ├── config/ # Config singleton, ConfigLoader, game config
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
│ ├── entities/ # Ship (own behavior), Planet (home world, solid)
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport
│ ├── entities/ # Ship (own behavior), Planet (home world, solid), Station, JumpGate (solids)
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport, JumpNetwork (the gate network)
│ ├── tether/ # Tether (pure range math) + TetherField (constraint + barrier line)
│ ├── research/ # ResearchModel (pure tree rules/layout), ResearchState (unlocks + active run), ResearchIcons
│ ├── build/ # BuildModel (pure build rules), BuildState (installed + in-progress records)
@ -234,7 +238,9 @@ runtime data — loaders and tests ignore them.
```sh
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/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/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

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-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": "gasgiant-land-01.mp4", "surface": "terran-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-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": "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",
"settlements.json",
"stations.json",
"gates.json",
"reputation.json",
"naming.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",
"frameWidth": 1024,
"frameHeight": 1024,
@ -37,7 +37,8 @@
"spawnDistanceFromEdge": 150,
"solarSystem": {
"enabled": true,
"minSpacing": 6144,
"maxNeighbor": 10240
"minSpacing": 6400,
"maxSpacing": 15360,
"homeMaxSpacing": 10240
}
}

View File

@ -7,13 +7,15 @@
* - the DISCOVERY RULE (data/game.json discovery.distance): within that
* many px of an object's EDGE discovered, exactly once, per system;
* state round-trips through toJSON/fromJSON (saves-ready);
* - the SOLAR SYSTEM LAYOUT (data/planets.json solarSystem): the
* system's planets + free-space stations (+ the origin's home world)
* sit on orbits around the origin, no pair closer than minSpacing
* (center-to-center) and whenever a system holds more than one
* object every object within maxNeighbor of at least one other,
* planets scaled by class deterministically (same seed same
* layout, different seed different);
* - the SOLAR SYSTEM LAYOUT (data/planets.json solarSystem): every
* pair of layout objects the system's planets + free-space
* stations + the central body (the origin's home world, or the star
* elsewhere) sits in the band [minSpacing, maxSpacing] px
* center-to-center (the home system's band tightens to
* [minSpacing, homeMaxSpacing] and it holds 3 objects 5 points
* 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 screen-edge rect (edges AND corners), circleInView is exact,
* 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 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');
let layoutOk = true;
let layoutWhy = '';
let sawMaxObjects = false; // the N = 6 maximum (4 planets + 2 stations)
let maxObjectsSeen = 0;
let homeOk = true;
const probe = (systems) => {
for (const rec of systems) {
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) {
if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.scale)) {
layoutOk = false;
@ -131,7 +137,8 @@ const probe = (systems) => {
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
// layout objects of their own).
const objs = [{ x: 0, y: 0 }];
@ -148,27 +155,24 @@ const probe = (systems) => {
const nObjects = objs.length - 1;
if (nObjects > maxObjectsSeen) maxObjectsSeen = nObjects;
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 j = i + 1; j < objs.length; j++) {
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;
layoutWhy = `${rec.id}: two objects ${Math.round(MIN_SEP - d)} px closer than minSpacing ${MIN_SEP}`;
}
}
}
// 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;
layoutWhy = `${rec.id}: a pair ${Math.round(d)} px outside the band [${MIN_SEP}, ${MAX}]`;
}
}
}
@ -176,8 +180,8 @@ const probe = (systems) => {
};
probe(g.records.slice(0, 5000));
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}`,
layoutOk,
`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 && homeOk,
);
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);

View File

@ -236,7 +236,7 @@ let big;
const g = Galaxy.create('content-shape');
const sys = g.currentSystem();
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('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);
@ -308,10 +308,16 @@ let big;
}
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.
let unclaimed = 0;
for (const r of sample) if ((big2.ensureContent(r.id).settlements).length === 0) unclaimed++;
check(`some systems are charted-but-unclaimed (${unclaimed}/${sample.length} in sample)`, unclaimed > 0);
// JUMP-GATE ANCHOR GUARANTEE: every system holds at least one planet or
// free-space station — the jump gates sit within level-1 tether of an
// anchor, so a system with neither would be closed (unreachable).
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.
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 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 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('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.
const unclaimedRec = sample.map((r) => big2.ensureContent(r.id)).find((c) => c.settlements.length === 0);
check('unclaimed systems report "charted · unclaimed"', unclaimedRec && formatSystemReport(unclaimedRec).summary === 'charted · unclaimed');
// The anchor guarantee settled every system, so the "unclaimed" report
// branch is a defensive fallback (content with zero settlements).
// No-duplicate guarantee: within a system, planet names and station names
// never repeat (drawn without replacement from the bank).
// No-duplicate guarantee: within a system, planet names, station names
// 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 c = big2.ensureContent(r.id);
const pnames = c.planets.map((p) => p.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
// 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 = [
report.subtitle,
...report.settlements.map((s) => s.text),
...(report.gates ?? []).map((gt) => gt.text),
report.summary,
`seed ${galaxy.seed}`,
];
@ -214,6 +215,10 @@ check('arrival timeline: name first, then the detail lines',
ys.push(y);
y += 20;
}
for (let i = 0; i < (report.gates ?? []).length; i++) {
ys.push(y);
y += 20;
}
ys.push(y); // summary
y += 20;
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()`
(`startingSystem.policy`: `center` or `random`). Jumping between systems
(the eventual star map / jump drives) will use `galaxy.neighborsOf(id)`
and the spatial hash already built for it.
now has its NETWORK (see "Jump gates" below — `data/gates.json` +
`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
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:
`deepSpaceStation` (adrift in open space) and `waypoint` (a small beacon
— the faint trace of a crossed galaxy), still rolled per archetype and
thinned core→rim. The charted-but-unclaimed systems are the barren ones
that also roll no free-space station. Model & seams:
thinned core→rim. The **anchor guarantee** (jump gates must be
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,
theme color, population range, anchor type). Add a kind = JSON + naming
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,
relations). Deliberately absent for now — no factions yet.
- **Pure report formatter**`js/galaxy/SystemReport.js`
(`formatSystemReport(content)` → title/subtitle/settlements/summary).
The GameScene HUD renders it; future star map / terminal UI reuse it.
(`formatSystemReport(content)` → title/subtitle/settlements/gates/
summary). The GameScene HUD renders it; future star map / terminal UI
reuse it.
- Landing/exploration (a future feature) will treat settlements as points
of interest: the data already says what's there and where (anchor =
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 layout** — `SystemGenerator.layoutSystem(seed, systemId, planets,
freeSpace)` (pure, `js/galaxy/SystemGenerator.js`) places each system's
planets and free-space stations (deep-space stations, waypoints) on one
or two orbits (rings) around the home world (origin), enforcing the hard
spacing rules in `data/planets.json → solarSystem`: `minSpacing` (6144 px,
center-to-center, between ANY two objects — planets, stations, the home
world) and `maxNeighbor` (10240 px — whenever a system holds more than
one object, every object is within it of at least one other; the inner
orbit's near neighbor is the home world at the origin). The current data
max is 6 objects (4 planets + 2 stations), which always shares one
orbit; the 11-object two-orbit case (3-ring + 8-ring) is kept as a
defensive fallback for any future data that could produce it.
Planet-bound settlements (colonies, mining stations, cloud bases) are
features of their planet, not layout objects.
freeSpace, isHome, targetAngles)` (pure, `js/galaxy/SystemGenerator.js`)
places each system's planets and free-space stations on a ring (a
regular polygon) around the central body (the home world at the origin
in the starting system, the star elsewhere), enforcing the SOLAR
SYSTEM BAND in `data/planets.json → solarSystem`: EVERY pair of layout
objects — planets, stations, the central body — sits center-to-center
in `[minSpacing, maxSpacing]` (640015360 px; the starting system's
band tightens to `[minSpacing, homeMaxSpacing]` = 640010240 px). The
ring radius is chosen inside the band (non-home: `maxSpacing` over the
chord of the N-gon; home: the (N+1)-gon side over the band's tight
ratio), and the rotation (ring phase) is a deterministic scan that
serves the system's gate target bearings. The starting system is capped
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,
'layout')` — layout results are seed-deterministic *and* don't perturb
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,
never pass through.
- 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)

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 { Rng } from '../utils/Rng.js';
import { NameGenerator } from '../utils/NameGenerator.js';
import { buildJumpNetwork } from './JumpNetwork.js';
import { generateSystemContent } from './SystemGenerator.js';
const TAU = Math.PI * 2;
@ -161,6 +162,28 @@ export class Galaxy {
}
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. */
@ -242,6 +265,18 @@ export class Galaxy {
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 */
currentSystem() {
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.
* EVERY planet hosts a settlement (for now data/settlements.json
* allPlanetsSettled + settledKindByClass): colonies on habitable worlds,
* mining stations over the rest, cloud bases riding gas giants. The
* charted-but-unclaimed systems are the barren ones that also roll no
* free-space station. Nothing here is hostile yet: `owner` on every
* settlement is a reserved seam for the factions and pirates we'll
* introduce later.
* mining stations over the rest, cloud bases riding gas giants. Every
* system holds at least one planet or free-space station a barren
* system that rolls no station gets a gate station because the jump
* gates (below) must be tether-reachable from an anchor. Nothing here is
* 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
* 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
* 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
* 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')
),
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
// 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');
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 ------------------------------------------------
// Loose groups of slowly tumbling rocks scattered through the void.
// Generated AFTER the layout (so every placed object is a spacing
// obstacle) from the dedicated stream (seed, 'system', id, 'asteroids')
// — independent of the star/planet/settlement/layout draws above, so
// Generated AFTER the layout (so every placed object — worlds, stations,
// and the jump gates — is a spacing obstacle) from the dedicated stream
// (seed, 'system', id, 'asteroids') — independent of the
// star/planet/settlement/layout draws above, so
// 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 --------------------------------
const belt = {
@ -165,6 +205,7 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
asteroids,
belt,
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)
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
* stations as one or two ORBITS (rings) around the home world, which
* always sits at the system origin (the game renders a solid world there;
* see GameScene).
* stations under the SOLAR SYSTEM BAND (data/planets.json
* solarSystem):
*
* Each object gains x, y (world position). Planets also gain scale (size
* multiplier, data/planets.json classScale the spacing rules below are
* center-to-center, but the rendered discs still scale by class).
* Every PAIR of layout objects the planets, the free-space stations,
* and the central body (the star, or the HOME world in the starting
* 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):
* minSpacing no two objects (planets, space stations, the home world)
* 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.
* normal systems : minSpacing..maxSpacing (6400..15360 px)
* home system : minSpacing..homeMaxSpacing (6400..10240 px)
*
* A ring of k objects at radius R around the origin satisfies both at once:
* - object home-world distance is R, so R [minSpacing, maxNeighbor]
* takes care of the home world's own pair of constraints;
* - 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
* are further), so edge minSpacing covers every pair;
* - every object's nearest neighbor is then the home world, at
* R maxNeighbor.
* That radius range is non-empty for k 10 (k = 11 would need
* R 10905, which already strands the home world beyond maxNeighbor).
* The current data's maximum is 6 objects (4 planets + 2 free-space
* stations), which always fits a single orbit; the 11-object case is kept
* as a defensive fallback (an inner 3-ring and an outer 8-ring) for any
* future data that could produce it.
* The outer ring's near neighbor is its ring-mate (edge stays in
* [minSpacing, maxNeighbor]); the inner ring keeps the home world within
* maxNeighbor; and any inner/outer pair is at least R_outer R_inner
* minSpacing apart. Smaller N all share one orbit. (Defensively, beyond
* 11 objects the orbits chain outward rings of 10, and a lone world
* always fits radially outside the previous orbit so this terminates
* for any N.)
* (The old "every object within maxNeighbor of some object" rule is
* implied: 6 objects inside a 15360 px band keeps every object within
* level-2/3 tether of the others.)
*
* Shapes that satisfy a full pairwise band exactly:
* N = 1, non-home a single point at distance R [min, max]; its only
* pair (with the star) is just R.
* N 2, non-home a REGULAR N-GON RING around the star: every pair is
* a chord, the longest being 2·R·sin(N/2·π/N), so
* R [min, max / (2·sin(N/2·π/N))].
* home system a REGULAR (N+1)-POLYGON with the home world as ONE
* VERTEX (the origin): every pair is a polygon chord,
* the longest ratio(N+1)·side, so side [min,
* homeMax / ratio(N+1)]. (The home world + 4 objects
* 5 points would need a max/min ratio φ 1.618,
* which the home band 10240/6400 = 1.6 cannot give;
* that is why the home system is capped at 3 objects:
* its 2 fixed planets + at most one free-space
* station see generateSettlements.)
*
* What counts as an object: planets (all of them) and free-space
* settlements (anchor.type 'space' deep-space stations, waypoints).
* 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,
* 'layout') layout never perturbs the star/planet/settlement draws
* above, and lazy (on-arrival) === eager (generateAll) is preserved.
* The ROTATION (ring phase / polygon orientation) is chosen to serve the
* jump gates: it minimizes the worst perpendicular distance between a
* 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', {});
if (band.enabled === false) return;
const MIN_SEP = band.minSpacing ?? 6144;
const MAX_NBR = band.maxNeighbor ?? 10240;
const MIN = Math.max(1, band.minSpacing ?? 6400);
const MAX = Math.max(MIN, isHome ? (band.homeMaxSpacing ?? 10240) : (band.maxSpacing ?? 15360));
// Rendered size per class (visual only — the spacing rules are
// center-to-center, not edge-to-edge).
// Rendered size per class (visual only — the band is center-to-center).
for (const p of planets) {
p.scale = config.get(`planets.classScale.${p.class}`, 1) ?? 1;
}
// Slot assignment order: planets in orbital order, then free-space
// stations in generation order (deep-space station, then waypoint) —
// stable per system, so lazy === eager.
// stations in generation order — stable per system, so lazy === eager.
const objects = [...planets, ...freeSpace];
const N = objects.length;
if (N === 0) return;
const TAU = Math.PI * 2;
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) => {
const R = lay.range(lo, hi);
// A LONE object on an outer orbit sits radially outside an
// already-placed one: its guaranteed neighbor is then exactly
// R prevR away, whereas a random angle could leave it more than
// maxNeighbor from every inner object.
const th0 = k === 1 && prevR > 0 ? prevAngle : lay.range(0, TAU);
for (let i = 0; i < k; i++) {
const th = th0 + (k === 1 ? 0 : (TAU * i) / k);
slots.push({ x: R * Math.cos(th), y: R * Math.sin(th) });
// `place(phi)` → the N object positions [{x, y}] for rotation `phi`.
let place;
if (isHome) {
// Regular (N+1)-gon, the home world (origin) as vertex 0: vertex k is
// Rc·(u(φ + 2πk/m) u(φ)) — a chord of length 2·Rc·sin(πk/m).
const m = N + 1;
const sideMax = MAX / ratioOf(m);
if (sideMax < MIN) {
console.warn(
`[orbit] ${systemId}: ${N} objects cannot fit the home band [${MIN}, ${MAX}] px — using the minimum spacing`,
);
}
prevR = R;
prevAngle = th0;
const side = lay.range(MIN, Math.max(MIN, sideMax));
const Rc = side / (2 * Math.sin(Math.PI / m));
place = (phi) => {
const out = [];
for (let k = 1; k < m; k++) {
out.push({
x: Rc * (Math.cos(phi + (TAU * k) / m) - Math.cos(phi)),
y: Rc * (Math.sin(phi + (TAU * k) / m) - Math.sin(phi)),
});
}
return out;
};
if (N <= 10) {
// One orbit around the home world (k = 1: a single object, whose only
// neighbor is the home world — fine, it's still within [MIN_SEP, MAX_NBR]).
const f = edgeFactor(N);
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;
}
// 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].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).
*
@ -357,7 +615,7 @@ function layoutSystem(seed, systemId, planets, freeSpace) {
* 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', {});
if (cfg.enabled === false) return [];
// 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 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 }];
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 j of jumps) if (typeof j.x === 'number' && typeof j.y === 'number') placed.push({ x: j.x, y: j.y });
const clusters = [];
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
* 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 = [];
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).
// 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)) {
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' });
}
// 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;
}

View File

@ -10,7 +10,7 @@ import { config } from '../config/Config.js';
*
* const report = formatSystemReport(content);
* // { title, subtitle, settlements: [{ text, color, population }],
* // summary, population }
* // gates: [{ text, color }], summary, population }
*/
export function formatSystemReport(content, typeDefs = null, kindDefs = null) {
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);
// 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 g = gates.length;
const summary =
n === 0
? g === 0
? 'charted · unclaimed'
: `${n} settlement${n === 1 ? '' : 's'} · pop ~${formatPop(population)}`;
: `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 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'}` +
(asteroidN ? ` · ${asteroidN} asteroid cluster${asteroidN === 1 ? '' : 's'}` : ''),
settlements,
gates,
summary,
population,
};

View File

@ -15,6 +15,7 @@ import { Ship } from '../entities/Ship.js';
import { Planet } from '../entities/Planet.js';
import { AsteroidCluster } from '../entities/AsteroidCluster.js';
import { Station } from '../entities/Station.js';
import { JumpGate } from '../entities/JumpGate.js';
import { Starfield } from '../visuals/Starfield.js';
import { DiscoveryCompass, circleInView } from '../ui/DiscoveryCompass.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
// disjoint), then the clusters, then the stations. Ship constraint,
// click-to-fly clamping and autopilot all run against this list.
this.solids = [this.planet, ...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations];
// disjoint), then the clusters, then the stations, then the gates.
// Ship constraint, click-to-fly clamping and autopilot all run
// against this list.
this.solids = [this.planet, ...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations, ...this.systemGates];
this.planet.discoveryId = 'home';
this.planet.discoveryName = this.homeWorldName;
@ -793,6 +811,9 @@ export class GameScene extends Phaser.Scene {
for (const s of report.settlements) {
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(`seed ${this.galaxy.seed}`, { fontFamily: fam, fontSize: '11px', color: '#3d476b' });
this.hudDetail = detail;
@ -997,6 +1018,7 @@ export class GameScene extends Phaser.Scene {
// after the physics step has moved the ship.)
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 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)
if (this.scanObjects) this.updateScanHits(_time); // the front crossing objects → ring
this.scanPulse.update(_time, delta); // the pulse: charge → front → absorbed → settle
@ -1184,6 +1206,19 @@ export class GameScene extends Phaser.Scene {
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;
}