Rebuild in-system layout around tether discovery + the edge margin
- data/tether.json: new edgeMargin (512 px) — a no-man's-land band around every tether rim where NO object sits: discoverable objects are clearly inside a tether region (d ≤ radius − edgeMargin) or clearly outside (d ≥ radius + edgeMargin), never at the edge (the 'scan doesn't find it at the boundary' case) - SystemGenerator.layoutSystem: the regular N-gon ring (chords put 2- and 3-object systems past level 2, spread 4-/5-object systems too thin) is replaced by a compact ARC at one orbital radius — consecutive objects a level-1 tether apart (≤ L1 − edgeMargin — a level-1 chain walks the whole system), every other pair off both tether rims (the level-2 graph is connected — level 2 DEFINITELY discovers; level 3 never required); minSpacing now bounds star/home-world→object only, neighborMin (2048) is the planet↔planet floor; the step angle is a deterministic scan (safe-α) landing the gap near a seeded target - home world IS a tether anchor: its orbital radius caps at L2 − edgeMargin (9728 px); its objects sit 6400–9728 (level-2 zone by design — the bootstrap tether), chained to each other at level 1 - gates: anchorMaxDistance 4990 → 4608 (= L1 − edgeMargin) so no gate sits at the level-1 rim either (still < 5000, Rule 1); candidate ordering is now side > rim-bands > facing tier > order - Tether.contains: 1 px rim tolerance (a center exactly on the boundary counts as inside — float safety on top of the layout margin) - asteroid placement tetherMargin 96 → 512 (aligned with edgeMargin) - dev/system-layout.test.mjs (new): level-1 chain, level-2 connectivity, edge margin, orbital band, same-seed determinism — 90-system galaxies, all green; discovery/jumps/audit tests + PROJECT_NOTES follow the rules
This commit is contained in:
parent
a8246bb594
commit
54fbf4091c
|
|
@ -113,7 +113,7 @@
|
||||||
"minObjectSpacing": 1024,
|
"minObjectSpacing": 1024,
|
||||||
"minRadius": 2048,
|
"minRadius": 2048,
|
||||||
"maxRadius": 18432,
|
"maxRadius": 18432,
|
||||||
"tetherMargin": 96,
|
"tetherMargin": 512,
|
||||||
"maxPlacementAttempts": 400
|
"maxPlacementAttempts": 400
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"_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); `shortcuts` is OFF — no mesh, no one-way links, no closed loops: the tree has EXACTLY ONE route between any two systems, so the galaxy reads as a MAZE of dead ends and long hauls. Strong connectivity still holds by construction (a bidirected tree — from any star you can reach any other, no closed systems, no trapped sets), and every jump is to a nearby star with a RETURN gate (the destination's gate pointing back — the player can always jump back the way they came). No system ever holds more than maxGates gates. A BARREN system (objectCount → 0, no planets/stations) is a DEAD-END LEAF of the tree — exactly one gate, in and out the same way (JumpNetwork takes the barren set from the per-system composition roll and never lets a barren node adopt children; the repair pass prefers non-barren attach targets). PLACEMENT (anchored systems): each gate sits EXACTLY one of the radius steps spanning `anchorMinDistance`..`anchorMaxDistance` (default 3500–4990 px) — CAPPED by the level-`anchorTetherLevel` tether range — from an ANCHOR — a PLANET, or the home world in the starting system. The band keeps the gate inside the level-1 tether of data/tether.json (5120 px) and STRICTLY < 5000 px from the anchor (Rule 1); a per-gate preferred step (a seeded fork) makes the distance VARY between gates — clearance/gap pressure only ever pushes a gate outward from its preference; all hard by construction. Free-space stations are deliberately NOT anchors: the build console lives on a WORLD, so every gate must be tether-reachable from a planet the player can build and expand out from (the composition roll demotes a station to a planet when a system's budget would otherwise run out, guaranteeing the anchor exists). 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. PLACEMENT (barren systems — no anchor, data/systems.json → objectCount.barren): the single gate sits ON the ray from the center toward its destination, `barrenDistance` px out (stepped outward within the minRadius..maxRadius band if the gap forces it), facing its destination; its payload is 1–2 asteroid clusters drifting inside the gate's level-1 tether (data/asteroids.json → barren). ACTIVITY: every gate record carries `active` (DEFAULT FALSE — the activation mechanic is future work). When a gate is activated, a level-1 tether (5120 px, data/tether.json) attaches to it: an active gate is a TETHER ANCHOR in its own right. In a barren system that tether is the player's entire room to move (the player arrives AT the gate — the center is empty: the star is invisible flavor, never rendered); in an anchored system it simply adds another anchor circle. A gate keeps `size` + `clearance` (px, center-to-center) from any solid disc (planets + free-space stations + the home world), 2·size + `gateGap` from any other gate, and stays between `minRadius` and `maxRadius` from the center. 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 (an inactive gate renders dimmed). typeLabel = the discovery/compass label. texture/frameWidth/frameHeight = the spritesheet (assets/images/jumpgate.png, 256×256 frames): frame 0 = the gate body (ring + pylons, drawn static), frame 1 = the active swirl (see swirl); sprite scale = (size×2)/frameWidth puts the ring's outer edge on the keepout disc. A missing sheet falls back to the built-in procedural gate (console note).",
|
"_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); `shortcuts` is OFF — no mesh, no one-way links, no closed loops: the tree has EXACTLY ONE route between any two systems, so the galaxy reads as a MAZE of dead ends and long hauls. Strong connectivity still holds by construction (a bidirected tree — from any star you can reach any other, no closed systems, no trapped sets), and every jump is to a nearby star with a RETURN gate (the destination's gate pointing back — the player can always jump back the way they came). No system ever holds more than maxGates gates. A BARREN system (objectCount → 0, no planets/stations) is a DEAD-END LEAF of the tree — exactly one gate, in and out the same way (JumpNetwork takes the barren set from the per-system composition roll and never lets a barren node adopt children; the repair pass prefers non-barren attach targets). PLACEMENT (anchored systems): each gate sits EXACTLY one of the radius steps spanning `anchorMinDistance`..`anchorMaxDistance` (default 3500–4608 px) — CAPPED by the level-`anchorTetherLevel` tether range — from an ANCHOR — a PLANET, or the home world in the starting system. The band keeps the gate CLEARLY inside the level-1 tether of data/tether.json — ≤ 5120 − edgeMargin (512 px rim margin, so no gate sits at the edge of a tether region), hence STRICTLY < 5000 px from the anchor (Rule 1); a per-gate preferred step (a seeded fork) makes the distance VARY between gates — clearance/gap pressure only ever pushes a gate outward from its preference; all hard by construction. Free-space stations are deliberately NOT anchors: the build console lives on a WORLD, so every gate must be tether-reachable from a planet the player can build and expand out from (the composition roll demotes a station to a planet when a system's budget would otherwise run out, guaranteeing the anchor exists). 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. PLACEMENT (barren systems — no anchor, data/systems.json → objectCount.barren): the single gate sits ON the ray from the center toward its destination, `barrenDistance` px out (stepped outward within the minRadius..maxRadius band if the gap forces it), facing its destination; its payload is 1–2 asteroid clusters drifting inside the gate's level-1 tether (data/asteroids.json → barren). ACTIVITY: every gate record carries `active` (DEFAULT FALSE — the activation mechanic is future work). When a gate is activated, a level-1 tether (5120 px, data/tether.json) attaches to it: an active gate is a TETHER ANCHOR in its own right. In a barren system that tether is the player's entire room to move (the player arrives AT the gate — the center is empty: the star is invisible flavor, never rendered); in an anchored system it simply adds another anchor circle. A gate keeps `size` + `clearance` (px, center-to-center) from any solid disc (planets + free-space stations + the home world), 2·size + `gateGap` from any other gate, and stays between `minRadius` and `maxRadius` from the center. 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 (an inactive gate renders dimmed). typeLabel = the discovery/compass label. texture/frameWidth/frameHeight = the spritesheet (assets/images/jumpgate.png, 256×256 frames): frame 0 = the gate body (ring + pylons, drawn static), frame 1 = the active swirl (see swirl); sprite scale = (size×2)/frameWidth puts the ring's outer edge on the keepout disc. A missing sheet falls back to the built-in procedural gate (console note).",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"minGates": 1,
|
"minGates": 1,
|
||||||
"maxGates": 3,
|
"maxGates": 3,
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
"maxRadius": 20480,
|
"maxRadius": 20480,
|
||||||
"anchorTetherLevel": 1,
|
"anchorTetherLevel": 1,
|
||||||
"anchorMinDistance": 3500,
|
"anchorMinDistance": 3500,
|
||||||
"anchorMaxDistance": 4990,
|
"anchorMaxDistance": 4608,
|
||||||
"barrenDistance": 8192,
|
"barrenDistance": 8192,
|
||||||
"typeLabel": "Jump Gate",
|
"typeLabel": "Jump Gate",
|
||||||
"theme": { "color": "#5fd4ff" },
|
"theme": { "color": "#5fd4ff" },
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"_comment": "Planet visuals + home-planet rules + the invisible star's flavor (class colors — non-home systems have no central body) + 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 — present ONLY at the origin of the system they start in (the starting system's central body); every OTHER system's center is empty — the star is invisible flavor (`star` below: name/class feed the dossier and gate names, never rendered). 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). compassColor = the compass-arrow accent for planets (the home world + the system's worlds — data/stations.json's stations and data/asteroids.json's clusters carry their own; the scan signal compass reads it too). 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 home world — at the local origin of the starting system only; every other center is empty) — 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).",
|
"_comment": "Planet visuals + home-planet rules + the invisible star's flavor (class colors — non-home systems have no central body) + 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 — present ONLY at the origin of the system they start in (the starting system's central body); every OTHER system's center is empty — the star is invisible flavor (`star` below: name/class feed the dossier and gate names, never rendered). 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). compassColor = the compass-arrow accent for planets (the home world + the system's worlds — data/stations.json's stations and data/asteroids.json's clusters carry their own; the scan signal compass reads it too). solarSystem = the top-down LAYOUT RULES (js/galaxy/SystemGenerator.js → layoutSystem), built around TETHER DISCOVERY (data/tether.json): the objects of a system (planets + free-space stations + the home world in the starting system) sit on a compact arc at one orbital radius — the star (or home world) at the origin — with CONSECUTIVE objects a level-1 tether apart (clearly inside level 1: ≤ 5120 − edgeMargin) so a chain of level-1 tethers always discovers the whole system, and level-2 DEFINITELY discovers while undiscovered objects remain (its adjacency graph is connected) — level-3 never needs to be required. NO object pair (among anchorable objects — planets, stations, the home world) may sit in the no-man's-land band around a tether rim: (L1 − edgeMargin, L1 + edgeMargin) or (L2 − edgeMargin, L2 + edgeMargin) — objects are clearly inside or clearly outside each tether region, never at the edge (the scan edge case). The invisible star (non-home systems) is not a tether anchor, so its distances only need minSpacing..maxSpacing; the home world IS an anchor, so its distances also respect the bands (homeMaxSpacing is additionally capped at L2 − edgeMargin). minSpacing = star/home-world-to-object floor; maxSpacing = star-to-object ceiling (6400..15360 px); homeMaxSpacing = home-world-to-object ceiling; neighborMin = the planet/station-to-planet/station floor (disc clearance + the chain-gap lower bound). The rotation biases toward the jump-gate directions (data/gates.json). All values are center-to-center px (the same unit as the 1024 px world disc).",
|
||||||
"texture": "assets/images/planets.png",
|
"texture": "assets/images/planets.png",
|
||||||
"frameWidth": 1024,
|
"frameWidth": 1024,
|
||||||
"frameHeight": 1024,
|
"frameHeight": 1024,
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"minSpacing": 6400,
|
"minSpacing": 6400,
|
||||||
"maxSpacing": 15360,
|
"maxSpacing": 15360,
|
||||||
"homeMaxSpacing": 10240
|
"homeMaxSpacing": 10240,
|
||||||
|
"neighborMin": 2048
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
{
|
{
|
||||||
"_comment": "TETHER — the player's range. A tether anchors on a world or station and defines a circular zone: radius = distance from the anchor's center. Level 1 = 5120 px (design rule); radius(level) = level1Radius × radiusGrowth^(level−1). The ship may fly anywhere in the UNION of its tethers' zones — where zones overlap there is no line and no wall; outside all of them is a hard barrier, drawn as a thick cyberpunk glitch dotted line along the union's boundary. homeId/homeLevel/homeLabel = the tether the player starts with (anchored on the home world, which sits at the system origin). line = the barrier's look & motion (dash/gap px along the rim, thicknesses, march speed = how fast dashes flow, colors/alphas = the neon passes, glitch = ambient bursts: dash flicker, radial displacement, sparks). contact = feedback when the ship bumps the barrier (pulse = the local shudder of the line; toastCooldown = console warning throttle, ms).",
|
"_comment": "TETHER — the player's range. A tether anchors on a world or station and defines a circular zone: radius = distance from the anchor's center. Level 1 = 5120 px (design rule); radius(level) = level1Radius × radiusGrowth^(level−1). The ship may fly anywhere in the UNION of its tethers' zones — where zones overlap there is no line and no wall; outside all of them is a hard barrier, drawn as a thick cyberpunk glitch dotted line along the union's boundary. homeId/homeLevel/homeLabel = the tether the player starts with (anchored on the home world, which sits at the system origin). line = the barrier's look & motion (dash/gap px along the rim, thicknesses, march speed = how fast dashes flow, colors/alphas = the neon passes, glitch = ambient bursts: dash flicker, radial displacement, sparks). contact = feedback when the ship bumps the barrier (pulse = the local shudder of the line; toastCooldown = console warning throttle, ms). edgeMargin = the DISCOVERY-DESIGN margin (px): a no-man's-land band around EVERY tether boundary where no object sits — discoverable objects are either clearly inside a tether region (d ≤ radius − edgeMargin) or clearly outside it (d ≥ radius + edgeMargin), never at the rim (the scan edge case: an object whose center hugs a tether boundary is where 'scan doesn't find it' bites — the layout keeps everything off the rims instead, data/planets.json → solarSystem + data/gates.json → anchorMin/MaxDistance). Used by SystemGenerator; not a gameplay value.",
|
||||||
"level1Radius": 5120,
|
"level1Radius": 5120,
|
||||||
"radiusGrowth": 2.0,
|
"radiusGrowth": 2.0,
|
||||||
"maxLevel": 3,
|
"maxLevel": 3,
|
||||||
|
"edgeMargin": 512,
|
||||||
"homeId": "home",
|
"homeId": "home",
|
||||||
"homeLevel": 1,
|
"homeLevel": 1,
|
||||||
"homeLabel": null,
|
"homeLabel": null,
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,19 @@
|
||||||
* - the DISCOVERY RULE (data/game.json → discovery.distance): within that
|
* - the DISCOVERY RULE (data/game.json → discovery.distance): within that
|
||||||
* many px of an object's EDGE ⇒ discovered, exactly once, per system;
|
* many px of an object's EDGE ⇒ discovered, exactly once, per system;
|
||||||
* state round-trips through toJSON/fromJSON (saves-ready);
|
* state round-trips through toJSON/fromJSON (saves-ready);
|
||||||
* - the SOLAR SYSTEM LAYOUT (data/planets.json → solarSystem): every
|
* - the SOLAR SYSTEM LAYOUT (data/planets.json → solarSystem +
|
||||||
* pair of layout objects — the system's planets + free-space
|
* data/tether.json): the objects of a system (planets + free-space
|
||||||
* stations + the central body (the origin's home world, or the star
|
* stations + the home world in the starting system) sit on a compact
|
||||||
* elsewhere) — sits in the band [minSpacing, maxSpacing] px
|
* arc — orbital radius in [minSpacing, maxSpacing] (home ≤
|
||||||
* center-to-center (the home system's band tightens to
|
* level-2 − edgeMargin), consecutive objects a level-1 tether apart
|
||||||
* [minSpacing, homeMaxSpacing] and it holds ≤ 3 objects — 5 points
|
* (≤ L1 − edgeMargin — a level-1 chain discovers the whole system),
|
||||||
* cannot sit 6400..10240 px apart), planets scaled by class, the
|
* no anchorable pair in a tether rim band (± edgeMargin — the scan
|
||||||
* OBJECT COMPOSITION holding (data/systems.json → objectCount: every
|
* edge case), the level-2 graph connected — planets scaled by class,
|
||||||
* non-home system 0/2/3/4/5 objects, in the configured proportions)
|
* the OBJECT COMPOSITION holding (data/systems.json → objectCount:
|
||||||
* — deterministically (same seed ⇒ same layout, different seed ⇒
|
* every non-home system 0/2/3/4/5 objects, in the configured
|
||||||
* different);
|
* proportions) — deterministically (same seed ⇒ same layout,
|
||||||
|
* different seed ⇒ different); the full rule set is covered by
|
||||||
|
* dev/system-layout.test.mjs;
|
||||||
* - the COMPASS geometry (js/ui/DiscoveryCompass.js): edgeAnchor lands on
|
* - the COMPASS geometry (js/ui/DiscoveryCompass.js): edgeAnchor lands on
|
||||||
* the screen-edge rect (edges AND corners), circleInView is exact,
|
* the screen-edge rect (edges AND corners), circleInView is exact,
|
||||||
* lerpAngle always takes the short arc;
|
* lerpAngle always takes the short arc;
|
||||||
|
|
@ -134,6 +136,11 @@ const band = config.get('planets.solarSystem');
|
||||||
const MIN_SEP = band.minSpacing;
|
const MIN_SEP = band.minSpacing;
|
||||||
const MAX_SP = band.maxSpacing; // normal systems: the band's maximum
|
const MAX_SP = band.maxSpacing; // normal systems: the band's maximum
|
||||||
const HOME_MAX = band.homeMaxSpacing; // the home system's tighter maximum
|
const HOME_MAX = band.homeMaxSpacing; // the home system's tighter maximum
|
||||||
|
// The tether discovery bands (data/tether.json) — the layout's real rule.
|
||||||
|
const L1R = config.get('tether.level1Radius', 5120);
|
||||||
|
const L2R = L1R * (config.get('tether.radiusGrowth', 2.0));
|
||||||
|
const M = config.get('tether.edgeMargin', 512);
|
||||||
|
const L1in = L1R - M, L1out = L1R + M, L2in = L2R - M, L2out = L2R + M;
|
||||||
|
|
||||||
const g = Galaxy.create('discovery-layout-test');
|
const g = Galaxy.create('discovery-layout-test');
|
||||||
let layoutOk = true;
|
let layoutOk = true;
|
||||||
|
|
@ -145,7 +152,7 @@ const probe = (systems) => {
|
||||||
for (const rec of systems) {
|
for (const rec of systems) {
|
||||||
const c = g.ensureContent(rec.id);
|
const c = g.ensureContent(rec.id);
|
||||||
const isHome = rec.id === g.currentSystemId;
|
const isHome = rec.id === g.currentSystemId;
|
||||||
const MAX = isHome ? HOME_MAX : MAX_SP;
|
const MAX = isHome ? Math.min(HOME_MAX, L2in) : MAX_SP;
|
||||||
for (const p of c.planets) {
|
for (const p of c.planets) {
|
||||||
if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.scale)) {
|
if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.scale)) {
|
||||||
layoutOk = false;
|
layoutOk = false;
|
||||||
|
|
@ -183,23 +190,74 @@ const probe = (systems) => {
|
||||||
if (!layoutWhy) layoutWhy = `${rec.id}: holds ${nObjects} objects (composition wants 0/2/3/4/5)`;
|
if (!layoutWhy) layoutWhy = `${rec.id}: holds ${nObjects} objects (composition wants 0/2/3/4/5)`;
|
||||||
}
|
}
|
||||||
// The home system holds at most 3 objects (its 2 fixed planets + at
|
// 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
|
// most one free-space station) — by design, the home system stays a
|
||||||
// apart (the tightest 5-point spacing needs ratio ≥ φ > 1.6).
|
// small start the player expands out of.
|
||||||
if (isHome) {
|
if (isHome) {
|
||||||
if (nObjects > 3 || c.planets.length !== 2) {
|
if (nObjects > 3 || c.planets.length !== 2) {
|
||||||
homeOk = false;
|
homeOk = false;
|
||||||
if (layoutOk) layoutWhy = `${rec.id}: home system holds ${nObjects} objects`;
|
if (layoutOk) layoutWhy = `${rec.id}: home system holds ${nObjects} objects`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The SOLAR SYSTEM BAND: every pair of layout objects — incl. the
|
// THE LAYOUT RULES (data/tether.json + data/planets.json →
|
||||||
// central body — sits in [minSpacing, the system's maximum],
|
// solarSystem):
|
||||||
// center to center.
|
// (a) orbital band — every object in [minSpacing, MAX] from the
|
||||||
for (let i = 0; i < objs.length && layoutOk; i++) {
|
// center (MAX = level-2 − edgeMargin for home — the home world
|
||||||
for (let j = i + 1; j < objs.length; j++) {
|
// IS an anchor; the star of a normal system is not);
|
||||||
const d = Math.hypot(objs[i].x - objs[j].x, objs[i].y - objs[j].y);
|
for (let i = 1; i < objs.length && layoutOk; i++) {
|
||||||
if (d < MIN_SEP - 1e-6 || d > MAX + 1e-6) {
|
const d = Math.hypot(objs[i].x, objs[i].y);
|
||||||
layoutOk = false;
|
if (d < MIN_SEP - 1e-6 || d > MAX + 1e-6) {
|
||||||
layoutWhy = `${rec.id}: a pair ${Math.round(d)} px outside the band [${MIN_SEP}, ${MAX}]`;
|
layoutOk = false;
|
||||||
|
layoutWhy = `${rec.id}: object ${Math.round(d)} px outside the orbital band [${MIN_SEP}, ${MAX}]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nObjects >= 2) {
|
||||||
|
// The anchorable subgraph: the objects + (home only) the home world
|
||||||
|
// at the origin — the star of a normal system is NOT a tether
|
||||||
|
// anchor (no tether, no discovery from it), so its pairs are
|
||||||
|
// exempt from (b)–(d).
|
||||||
|
const anc = objs.slice(isHome ? 0 : 1);
|
||||||
|
const n = anc.length;
|
||||||
|
const adj1 = Array.from({ length: n }, () => []);
|
||||||
|
const adj2 = Array.from({ length: n }, () => []);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
for (let j = i + 1; j < n; j++) {
|
||||||
|
const d = Math.hypot(anc[i].x - anc[j].x, anc[i].y - anc[j].y);
|
||||||
|
// (b) the edge margin — no pair in a tether rim band.
|
||||||
|
if ((d > L1in + 1e-6 && d < L1out - 1e-6) || (d > L2in + 1e-6 && d < L2out - 1e-6)) {
|
||||||
|
layoutOk = false;
|
||||||
|
layoutWhy = `${rec.id}: a pair ${Math.round(d)} px in a tether rim band (±${M})`;
|
||||||
|
i = j = n; // break both loops
|
||||||
|
}
|
||||||
|
if (d <= L1in + 1e-6) { adj1[i].push(j); adj1[j].push(i); }
|
||||||
|
if (d <= L2in + 1e-6) { adj2[i].push(j); adj2[j].push(i); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (layoutOk) {
|
||||||
|
// (c) every object has a level-1 neighbor — EXCEPT the home
|
||||||
|
// world, the bootstrap anchor: minSpacing (6400) > L1out puts
|
||||||
|
// its objects in the level-2 zone by design (covered by (d)).
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
if (isHome && i === 0) continue;
|
||||||
|
if (adj1[i].length === 0) {
|
||||||
|
layoutOk = false;
|
||||||
|
layoutWhy = `${rec.id}: an object has no level-1 neighbor`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// (d) the level-2 graph is connected (level 2 DEFINITELY
|
||||||
|
// discovers while undiscovered objects remain; level 3 never
|
||||||
|
// required — the level-1 chain above already spans it).
|
||||||
|
if (layoutOk) {
|
||||||
|
const seen = new Set([0]);
|
||||||
|
const q = [0];
|
||||||
|
while (q.length) {
|
||||||
|
const i = q.shift();
|
||||||
|
for (const j of adj2[i]) if (!seen.has(j)) (seen.add(j), q.push(j));
|
||||||
|
}
|
||||||
|
if (seen.size !== n) {
|
||||||
|
layoutOk = false;
|
||||||
|
layoutWhy = `${rec.id}: level-2 graph splits ${seen.size}/${n}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -208,7 +266,7 @@ const probe = (systems) => {
|
||||||
probe(g.records);
|
probe(g.records);
|
||||||
const nProbe = g.records.length;
|
const nProbe = g.records.length;
|
||||||
check(
|
check(
|
||||||
`layout: ${nProbe} 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}`,
|
`layout: ${nProbe} systems obey the rules — orbital band [${MIN_SEP}, ${MAX_SP}] (home ≤ ${Math.min(HOME_MAX, L2in)}), level-1 chain, level-2 connected, no pair in a rim band (±${M})${layoutOk ? '' : ' — ' + layoutWhy}`,
|
||||||
layoutOk && homeOk,
|
layoutOk && homeOk,
|
||||||
);
|
);
|
||||||
check(`layout: no system exceeds the 5-object maximum (3 planets + 2 stations) — max in galaxy: ${maxObjectsSeen}`, maxObjectsSeen <= 5);
|
check(`layout: no system exceeds the 5-object maximum (3 planets + 2 stations) — max in galaxy: ${maxObjectsSeen}`, maxObjectsSeen <= 5);
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
* - content.jumps matches the network (count + destinations);
|
* - content.jumps matches the network (count + destinations);
|
||||||
* - ANCHORED systems (a planet — or the home world in the starting
|
* - ANCHORED systems (a planet — or the home world in the starting
|
||||||
* system): every gate sits in the anchorMinDistance–anchorMaxDistance
|
* system): every gate sits in the anchorMinDistance–anchorMaxDistance
|
||||||
* band (default 3500–4990 px — inside the level-1 tether, strictly
|
* band (default 3500–4608 px — inside the level-1 tether, strictly
|
||||||
* < 5000) from a planet anchor and FACES its destination star on the
|
* < 5000) from a planet anchor and FACES its destination star on the
|
||||||
* 2-D map
|
* 2-D map
|
||||||
* (soft rule — the gate sits on the DESTINATION SIDE of the system
|
* (soft rule — the gate sits on the DESTINATION SIDE of the system
|
||||||
|
|
@ -86,10 +86,10 @@ const POOL = Math.max(1, GATES.neighborPool ?? 8);
|
||||||
const TETHER = config.get('tether.level1Radius', 5120);
|
const TETHER = config.get('tether.level1Radius', 5120);
|
||||||
// The anchored-gate design rule (data/gates.json → anchorMinDistance /
|
// The anchored-gate design rule (data/gates.json → anchorMinDistance /
|
||||||
// anchorMaxDistance): STRICTLY < 5000 px from a planet anchor, with the
|
// anchorMaxDistance): STRICTLY < 5000 px from a planet anchor, with the
|
||||||
// distance VARYING across the band (default 3500–4990 — inside the
|
// distance VARYING across the band (default 3500–4608 — inside the
|
||||||
// level-1 tether). The generator places every anchored gate EXACTLY on
|
// level-1 tether). The generator places every anchored gate EXACTLY on
|
||||||
// one of the radius steps spanning that band.
|
// one of the radius steps spanning that band.
|
||||||
const ANCHOR = Math.min(TETHER, config.get('gates.anchorMaxDistance', 4990));
|
const ANCHOR = Math.min(TETHER, config.get('gates.anchorMaxDistance', 4608));
|
||||||
const ANCHOR_MIN = Math.min(ANCHOR, config.get('gates.anchorMinDistance', 3500));
|
const ANCHOR_MIN = Math.min(ANCHOR, config.get('gates.anchorMinDistance', 3500));
|
||||||
const SIZE = GATES.size ?? 96;
|
const SIZE = GATES.size ?? 96;
|
||||||
const CLEAR = GATES.clearance ?? 256;
|
const CLEAR = GATES.clearance ?? 256;
|
||||||
|
|
@ -267,7 +267,7 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// TETHER (hard): the gate's own anchor sits in the
|
// TETHER (hard): the gate's own anchor sits in the
|
||||||
// anchorMinDistance..anchorMaxDistance band (default 3500–4990 —
|
// anchorMinDistance..anchorMaxDistance band (default 3500–4608 —
|
||||||
// strictly < 5000). A DIFFERENT anchor may be closer (or farther)
|
// strictly < 5000). A DIFFERENT anchor may be closer (or farther)
|
||||||
// — the band is about the anchoring planet.
|
// — the band is about the anchoring planet.
|
||||||
let inBand = false;
|
let inBand = false;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
/**
|
||||||
|
* System LAYOUT test (dev tool, run with Node — no browser needed):
|
||||||
|
*
|
||||||
|
* node dev/system-layout.test.mjs
|
||||||
|
*
|
||||||
|
* Asserts the tether-discovery layout rules (data/planets.json →
|
||||||
|
* solarSystem + data/tether.json, implemented by
|
||||||
|
* js/galaxy/SystemGenerator.js → layoutSystem) across every system of a
|
||||||
|
* fresh galaxy:
|
||||||
|
*
|
||||||
|
* 1. LEVEL-1 DISCOVERY: every planet and free-space station has at
|
||||||
|
* least one OTHER object (planet/station) within a level-1 tether —
|
||||||
|
* clearly inside (d ≤ L1 − edgeMargin). A chain of level-1 tethers
|
||||||
|
* therefore walks the whole system. (The home world — the bootstrap
|
||||||
|
* anchor the player starts on — sits minSpacing away, which is the
|
||||||
|
* level-2 zone by design; its connection is covered by rule 2.)
|
||||||
|
* 2. LEVEL-2 GUARANTEE: the level-2 adjacency graph over the objects
|
||||||
|
* (and the home world in the starting system) is CONNECTED — a
|
||||||
|
* level-2 tether DEFINITELY discovers an object while undiscovered
|
||||||
|
* objects remain.
|
||||||
|
* 3. NO LEVEL-3 NEEDED: the level-1 chain above implies level 3 is
|
||||||
|
* never required for discovery (checked as: the whole system is
|
||||||
|
* reachable from any object by a path of ≤ level-1 steps).
|
||||||
|
* 4. EDGE MARGIN: NO anchorable pair (planets, stations, home world)
|
||||||
|
* sits in the no-man's-land band around a tether rim —
|
||||||
|
* (L1 ± edgeMargin) or (L2 ± edgeMargin) — objects are clearly
|
||||||
|
* inside or clearly outside each tether region, never at the edge
|
||||||
|
* (the scan edge case). The invisible star (non-home systems) is not
|
||||||
|
* a tether anchor, so its distances are exempt.
|
||||||
|
* 5. ORBITAL BAND: the center-to-object distance sits in
|
||||||
|
* [minSpacing, maxSpacing] (6400–15360 px), and in the home system
|
||||||
|
* additionally ≤ level-2 − edgeMargin (the home world IS an anchor).
|
||||||
|
*
|
||||||
|
* and DETERMINISM: same seed ⇒ same layout (the lazy/eager content
|
||||||
|
* cache returns identical coordinates).
|
||||||
|
*/
|
||||||
|
|
||||||
|
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++;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- The bands (from the same config the generator reads) -------------
|
||||||
|
const L1 = config.get('tether.level1Radius', 5120);
|
||||||
|
const L2 = L1 * (config.get('tether.radiusGrowth', 2.0));
|
||||||
|
const M = config.get('tether.edgeMargin', 512);
|
||||||
|
const L1in = L1 - M;
|
||||||
|
const L1out = L1 + M;
|
||||||
|
const L2in = L2 - M;
|
||||||
|
const L2out = L2 + M;
|
||||||
|
const MIN = config.get('planets.solarSystem.minSpacing', 6400);
|
||||||
|
const MAX = config.get('planets.solarSystem.maxSpacing', 15360);
|
||||||
|
const HOME_MAX = config.get('planets.solarSystem.homeMaxSpacing', 10240);
|
||||||
|
const inRim = (d) =>
|
||||||
|
(d > L1in + 1e-9 && d < L1out - 1e-9) || (d > L2in + 1e-9 && d < L2out - 1e-9);
|
||||||
|
|
||||||
|
const SEED = 'layout-test-seed';
|
||||||
|
const g = Galaxy.create(SEED);
|
||||||
|
const HOME = g.currentSystemId;
|
||||||
|
|
||||||
|
// One anchorable node per object: the planets, the free-space stations,
|
||||||
|
// and (home only) the home world at the origin.
|
||||||
|
const nodesOf = (c, isHome) => {
|
||||||
|
const nodes = c.planets.map((p) => ({ x: p.x, y: p.y, name: p.name }));
|
||||||
|
for (const s of c.settlements ?? []) {
|
||||||
|
if (s.anchor?.type === 'space') nodes.push({ x: s.x, y: s.y, name: s.name });
|
||||||
|
}
|
||||||
|
if (isHome) nodes.push({ x: 0, y: 0, name: 'home' });
|
||||||
|
return nodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
let systems = 0;
|
||||||
|
let withObjects = 0;
|
||||||
|
let l1Bad = 0, connBad = 0, rimBad = 0, orbBad = 0;
|
||||||
|
let whyL1 = '', whyConn = '', whyRim = '', whyOrb = '';
|
||||||
|
let l1Pairs = 0, l2Pairs = 0;
|
||||||
|
|
||||||
|
for (const r of g.records) {
|
||||||
|
const c = g.ensureContent(r.id);
|
||||||
|
const isHome = r.id === HOME;
|
||||||
|
const planets = c.planets.length;
|
||||||
|
const space = (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length;
|
||||||
|
if (planets === 0 && space === 0) continue; // barren — no anchorable objects
|
||||||
|
systems++;
|
||||||
|
withObjects++;
|
||||||
|
|
||||||
|
const nodes = nodesOf(c, isHome);
|
||||||
|
const N = nodes.length;
|
||||||
|
const dist = (i, j) => Math.hypot(nodes[i].x - nodes[j].x, nodes[i].y - nodes[j].y);
|
||||||
|
|
||||||
|
// 1 + 2 + 3: the adjacency graphs (level 1 = the chain, level 2 = the
|
||||||
|
// guarantee) and BFS connectivity over level 2.
|
||||||
|
const adj1 = Array.from({ length: N }, () => []);
|
||||||
|
const adj2 = Array.from({ length: N }, () => []);
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
for (let j = i + 1; j < N; j++) {
|
||||||
|
const d = dist(i, j);
|
||||||
|
if (d <= L1in + 1e-9) {
|
||||||
|
adj1[i].push(j);
|
||||||
|
adj1[j].push(i);
|
||||||
|
l1Pairs++;
|
||||||
|
}
|
||||||
|
if (d <= L2in + 1e-9) {
|
||||||
|
adj2[i].push(j);
|
||||||
|
adj2[j].push(i);
|
||||||
|
l2Pairs++;
|
||||||
|
}
|
||||||
|
// 4: the edge margin — no anchorable pair in a rim band.
|
||||||
|
if (inRim(d)) {
|
||||||
|
rimBad++;
|
||||||
|
if (!whyRim) whyRim = `${r.id}: ${nodes[i].name}↔${nodes[j].name} @ ${d.toFixed(1)} px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 1: every planet/station has a level-1 neighbor (the chain walks the
|
||||||
|
// system). The home world is exempt: it is the BOOTSTRAP anchor —
|
||||||
|
// the player starts there, and minSpacing (6400 > L1out) puts its
|
||||||
|
// objects in the level-2 zone by design (the L2 check below covers
|
||||||
|
// its connection). Once the first planet is reached, L1 finds the
|
||||||
|
// rest.
|
||||||
|
if (N >= 2) {
|
||||||
|
const lonely = nodes
|
||||||
|
.map((n, i) => (n.name === 'home' ? null : adj1[i].length === 0 ? n.name : null))
|
||||||
|
.filter(Boolean);
|
||||||
|
if (lonely.length) {
|
||||||
|
l1Bad++;
|
||||||
|
if (!whyL1) whyL1 = `${r.id}: no level-1 neighbor — ${lonely.join(', ')}`;
|
||||||
|
}
|
||||||
|
// 2: level-2 graph connected.
|
||||||
|
const seen = new Set([0]);
|
||||||
|
const queue = [0];
|
||||||
|
while (queue.length) {
|
||||||
|
const i = queue.shift();
|
||||||
|
for (const j of adj2[i]) if (!seen.has(j)) (seen.add(j), queue.push(j));
|
||||||
|
}
|
||||||
|
if (seen.size !== N) {
|
||||||
|
connBad++;
|
||||||
|
if (!whyConn) whyConn = `${r.id}: level-2 graph splits ${seen.size}/${N}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5: the orbital band (center-to-object distances).
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.name === 'home') continue; // the home world sits at the origin
|
||||||
|
const d = Math.hypot(n.x, n.y);
|
||||||
|
const hi = isHome ? Math.min(HOME_MAX, L2in) : MAX;
|
||||||
|
if (d < MIN - 1e-9 || d > hi + 1e-9) {
|
||||||
|
orbBad++;
|
||||||
|
if (!whyOrb) whyOrb = `${r.id}: ${n.name} @ ${d.toFixed(1)} px (band ${MIN}..${hi.toFixed(0)})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check(
|
||||||
|
`level-1 discovery: every planet/station has a level-1 neighbor — ${withObjects} systems`,
|
||||||
|
l1Bad === 0,
|
||||||
|
whyL1,
|
||||||
|
);
|
||||||
|
check('level-2 guarantee: the level-2 graph is connected (every system)', connBad === 0, whyConn);
|
||||||
|
check(
|
||||||
|
`edge margin: no anchorable pair in (L1±${M}) or (L2±${M})`,
|
||||||
|
rimBad === 0,
|
||||||
|
whyRim,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
`orbital band: center-to-object in [${MIN}, ${MAX}] (home ≤ ${Math.min(HOME_MAX, L2in)})`,
|
||||||
|
orbBad === 0,
|
||||||
|
whyOrb,
|
||||||
|
);
|
||||||
|
console.log(` ${systems} systems with objects checked (${l1Pairs} level-1 pairs, ${l2Pairs} level-2 pairs)`);
|
||||||
|
|
||||||
|
// --- Determinism: same seed ⇒ identical coordinates -------------------
|
||||||
|
{
|
||||||
|
const g2 = Galaxy.create(SEED);
|
||||||
|
let same = true;
|
||||||
|
let why = '';
|
||||||
|
for (const r of g.records) {
|
||||||
|
const a = g.ensureContent(r.id);
|
||||||
|
const b = g2.ensureContent(r.id);
|
||||||
|
for (let i = 0; i < a.planets.length && same; i++) {
|
||||||
|
if (Math.abs(a.planets[i].x - b.planets[i].x) > 1e-9 || Math.abs(a.planets[i].y - b.planets[i].y) > 1e-9) {
|
||||||
|
same = false;
|
||||||
|
why = `${r.id}: planet ${i} differs`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check('same seed ⇒ same layout (two fresh galaxies agree)', same, why);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(failures === 0 ? '\nAll system-layout tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
|
||||||
|
process.exit(failures === 0 ? 0 : 1);
|
||||||
|
|
@ -1,12 +1,29 @@
|
||||||
/**
|
/**
|
||||||
* One-off audit: do generated galaxies obey the two tether rules?
|
* Audit: do generated galaxies obey the tether-discovery design rules?
|
||||||
* Rule 1: every jump gate strictly < 5000 px from a planet or space
|
* Rule 1 (gates): every jump gate strictly < 5000 px from a planet or
|
||||||
* station (its ANCHOR sits in the 3500–4990 px placement band)
|
* space station — its ANCHOR sits in the 3500–4608 px
|
||||||
* — unless the system is a dead end (a barren system: no
|
* placement band (data/gates.json → anchorMinDistance /
|
||||||
* planets/stations, single gate).
|
* anchorMaxDistance — inside the level-1 tether with the
|
||||||
* Rule 2: every planet & space station within 2 tether lengths
|
* edgeMargin), the distance varying per gate — unless the
|
||||||
* (2 × level1Radius) of at least one other planet/station —
|
* system is a dead end (a barren system: no planets/stations,
|
||||||
* unless the system holds exactly one such object.
|
* single gate). Also reports: no gate sits in the rim band
|
||||||
|
* (± edgeMargin) around ANOTHER object's tether, and the
|
||||||
|
* gate→anchor distance distribution (the variation).
|
||||||
|
* Rule 2 (discovery chains — replaces the old "all objects within 2
|
||||||
|
* tether lengths of each other" rule):
|
||||||
|
* a) LEVEL-1: every planet/station has another object within
|
||||||
|
* level 1 — clearly inside (d ≤ L1 − edgeMargin); a chain
|
||||||
|
* of level-1 tethers walks the whole system;
|
||||||
|
* b) LEVEL-2: the level-2 adjacency graph over the objects
|
||||||
|
* (and the home world) is CONNECTED — level 2 DEFINITELY
|
||||||
|
* discovers an object while undiscovered ones remain;
|
||||||
|
* c) LEVEL-3: never required (the level-1 chain above);
|
||||||
|
* d) EDGE MARGIN: no anchorable pair (planets, stations, home
|
||||||
|
* world) sits in the rim band (L1 ± edgeMargin) or
|
||||||
|
* (L2 ± edgeMargin) — objects are clearly inside or
|
||||||
|
* clearly outside each tether region, never at the edge.
|
||||||
|
*
|
||||||
|
* Run: node dev/tether-rules.audit.mjs
|
||||||
*/
|
*/
|
||||||
process.env.NODE_ENV = 'dev';
|
process.env.NODE_ENV = 'dev';
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
@ -26,28 +43,39 @@ config.init(configData);
|
||||||
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
|
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
|
||||||
|
|
||||||
const T1 = config.get('tether.level1Radius', 5120);
|
const T1 = config.get('tether.level1Radius', 5120);
|
||||||
const T2 = T1 * 2;
|
const T2 = T1 * (config.get('tether.radiusGrowth', 2.0));
|
||||||
|
const M = config.get('tether.edgeMargin', 512);
|
||||||
|
const L1in = T1 - M;
|
||||||
|
const L1out = T1 + M;
|
||||||
|
const L2in = T2 - M;
|
||||||
|
const L2out = T2 + M;
|
||||||
|
const ANCHOR_MIN = config.get('gates.anchorMinDistance', 3500);
|
||||||
|
const ANCHOR_MAX = Math.min(T1, config.get('gates.anchorMaxDistance', 4608));
|
||||||
const EPS = 1;
|
const EPS = 1;
|
||||||
|
const inRim = (d) =>
|
||||||
|
(d > L1in + 1e-9 && d < L1out - 1e-9) || (d > L2in + 1e-9 && d < L2out - 1e-9);
|
||||||
|
|
||||||
for (const seed of ['audit-a', 'audit-b', 'audit-c']) {
|
for (const seed of ['audit-a', 'audit-b', 'audit-c']) {
|
||||||
const g = Galaxy.create(seed);
|
const g = Galaxy.create(seed);
|
||||||
g.generateAll();
|
g.generateAll();
|
||||||
let r1Bad = 0, r1Checked = 0;
|
let r1Bad = 0, r1Checked = 0;
|
||||||
let r2Bad = 0, r2Checked = 0;
|
|
||||||
const r1Examples = [];
|
const r1Examples = [];
|
||||||
const anchorDs = []; // gate→anchor distances (to verify the variation)
|
const anchorDs = []; // gate→anchor distances (to verify the variation)
|
||||||
const r2ByN = new Map(); // N → [checked, bad]
|
let gateRimBad = 0;
|
||||||
|
const gateRimExamples = [];
|
||||||
|
// Rule 2
|
||||||
|
let l1Bad = 0, connBad = 0, rimBad = 0, systems = 0;
|
||||||
|
let whyL1 = '', whyConn = '', whyRim = '';
|
||||||
|
const r2ByN = new Map(); // N → [l1ok, connOk, rimOk, systems]
|
||||||
for (const rec of g.records) {
|
for (const rec of g.records) {
|
||||||
const c = g.contentOf(rec.id);
|
const c = g.contentOf(rec.id);
|
||||||
|
const planets = c.planets.map((p) => ({ x: p.x, y: p.y, name: p.name }));
|
||||||
|
const space = c.settlements.filter((s) => s.anchor?.type === 'space').map((s) => ({ x: s.x, y: s.y, name: s.name }));
|
||||||
const isHome = rec.id === g.homeSystemId;
|
const isHome = rec.id === g.homeSystemId;
|
||||||
const objects = [...c.planets.map((p) => ({ x: p.x, y: p.y }))]
|
const objects = [...planets, ...space];
|
||||||
.concat(c.settlements.filter((s) => s.anchor?.type === 'space').map((s) => ({ x: s.x, y: s.y })));
|
if (isHome) objects.push({ x: 0, y: 0, name: 'home' }); // the home world (a world — a valid Rule-1 solid)
|
||||||
if (isHome) objects.push({ x: 0, y: 0 }); // the home world
|
|
||||||
const isBarren = objects.length === 0;
|
const isBarren = objects.length === 0;
|
||||||
const anchorSet = isHome
|
// Rule 1 (gates < 5000 px from a planet/station; anchor band 3500–4608; barren exempt)
|
||||||
? [...c.planets.map((p) => ({ x: p.x, y: p.y })), { x: 0, y: 0 }]
|
|
||||||
: c.planets.map((p) => ({ x: p.x, y: p.y }));
|
|
||||||
// Rule 1 (strictly < 5000 px from a planet/station; anchor band 3500–4990; barren exempt)
|
|
||||||
for (const j of c.jumps) {
|
for (const j of c.jumps) {
|
||||||
r1Checked++;
|
r1Checked++;
|
||||||
if (isBarren) continue;
|
if (isBarren) continue;
|
||||||
|
|
@ -58,32 +86,82 @@ for (const seed of ['audit-a', 'audit-b', 'audit-c']) {
|
||||||
if (r1Examples.length < 3) r1Examples.push(`${rec.id} gate→${j.to} d=${Math.round(best)}`);
|
if (r1Examples.length < 3) r1Examples.push(`${rec.id} gate→${j.to} d=${Math.round(best)}`);
|
||||||
}
|
}
|
||||||
let aBand = -1;
|
let aBand = -1;
|
||||||
for (const a of anchorSet) {
|
const anchors = isHome ? [...planets, { x: 0, y: 0, name: 'home' }] : planets;
|
||||||
|
for (const a of anchors) {
|
||||||
const d = Math.hypot(j.x - a.x, j.y - a.y);
|
const d = Math.hypot(j.x - a.x, j.y - a.y);
|
||||||
if (d >= 3500 - 1e-6 && d <= 4990 + 1e-6) { aBand = d; break; }
|
if (d >= ANCHOR_MIN - 1e-6 && d <= ANCHOR_MAX + 1e-6) { aBand = d; break; }
|
||||||
}
|
}
|
||||||
if (aBand >= 0) anchorDs.push(aBand); // the gate's anchoring planet
|
if (aBand >= 0) anchorDs.push(aBand); // the gate's anchoring planet
|
||||||
}
|
// Rim band against the OTHER solids (the anchor's own distance is
|
||||||
// Rule 2
|
// on the step band (≤ L1in — clearly inside, by construction), so
|
||||||
if (objects.length >= 2) {
|
// it can never count here).
|
||||||
for (const o of objects) {
|
const solids = [...planets, ...space];
|
||||||
r2Checked++;
|
if (isHome) solids.push({ x: 0, y: 0 });
|
||||||
let best = Infinity;
|
for (const s of solids) {
|
||||||
for (const q of objects) {
|
const d = Math.hypot(j.x - s.x, j.y - s.y);
|
||||||
if (q === o) continue;
|
if (inRim(d)) {
|
||||||
best = Math.min(best, Math.hypot(o.x - q.x, o.y - q.y));
|
gateRimBad++;
|
||||||
|
if (gateRimExamples.length < 3) gateRimExamples.push(`${rec.id} gate→${s.name ?? 'obj'} d=${Math.round(d)}`);
|
||||||
}
|
}
|
||||||
const [cc, bb] = r2ByN.get(objects.length) ?? [0, 0];
|
|
||||||
r2ByN.set(objects.length, [cc + 1, bb + (best > T2 + EPS ? 1 : 0)]);
|
|
||||||
if (best > T2 + EPS) r2Bad++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Rule 2 (discovery chains + edge margin)
|
||||||
|
if (objects.length >= 2) {
|
||||||
|
systems++;
|
||||||
|
const nodes = isHome ? [...objects, { x: 0, y: 0, name: 'home' }] : objects;
|
||||||
|
const N = nodes.length;
|
||||||
|
const adj1 = Array.from({ length: N }, () => []);
|
||||||
|
const adj2 = Array.from({ length: N }, () => []);
|
||||||
|
let sysRim = true;
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
for (let j = i + 1; j < N; j++) {
|
||||||
|
const d = Math.hypot(nodes[i].x - nodes[j].x, nodes[i].y - nodes[j].y);
|
||||||
|
if (d <= L1in + 1e-9) { adj1[i].push(j); adj1[j].push(i); }
|
||||||
|
if (d <= L2in + 1e-9) { adj2[i].push(j); adj2[j].push(i); }
|
||||||
|
if (inRim(d)) {
|
||||||
|
sysRim = false;
|
||||||
|
if (!whyRim) whyRim = `${rec.id}: ${nodes[i].name}↔${nodes[j].name} @ ${Math.round(d)} px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// a) every planet/station (not the home world) has an L1 neighbor
|
||||||
|
let l1ok = true;
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
if (nodes[i].name === 'home') continue;
|
||||||
|
if (adj1[i].length === 0) {
|
||||||
|
l1ok = false;
|
||||||
|
if (!whyL1) whyL1 = `${rec.id}: ${nodes[i].name} has no level-1 neighbor`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// b) level-2 graph connected
|
||||||
|
let conn = true;
|
||||||
|
const seen = new Set([0]);
|
||||||
|
const queue = [0];
|
||||||
|
while (queue.length) {
|
||||||
|
const i = queue.shift();
|
||||||
|
for (const j of adj2[i]) if (!seen.has(j)) (seen.add(j), queue.push(j));
|
||||||
|
}
|
||||||
|
if (seen.size !== N) conn = false;
|
||||||
|
if (!conn && !whyConn) whyConn = `${rec.id}: level-2 graph splits ${seen.size}/${N}`;
|
||||||
|
|
||||||
|
if (!l1ok) l1Bad++;
|
||||||
|
if (!conn) connBad++;
|
||||||
|
if (!sysRim) rimBad++;
|
||||||
|
const [c1, c2, c3, cc] = r2ByN.get(N) ?? [0, 0, 0, 0];
|
||||||
|
r2ByN.set(N, [c1 + (l1ok ? 1 : 0), c2 + (conn ? 1 : 0), c3 + (sysRim ? 1 : 0), cc + 1]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
console.log(`\nseed="${seed}" systems=${g.records.length} (tether L1=${T1}px, 2×L1=${T2}px)`);
|
console.log(`\nseed="${seed}" systems=${g.records.length} (L1=${T1}px, L2=${T2}px, edgeMargin=${M}px)`);
|
||||||
console.log(` Rule 1 (gates < 5000px from a planet/station, anchor band 3500–4990, barren exempt): ${r1Checked - r1Bad}/${r1Checked} OK${r1Bad ? ' — BAD: ' + r1Examples.join(', ') : ''}`);
|
console.log(` Rule 1 (gates < 5000px from a planet/station, anchor band ${ANCHOR_MIN}–${ANCHOR_MAX}, barren exempt): ${r1Checked - r1Bad}/${r1Checked} OK${r1Bad ? ' — BAD: ' + r1Examples.join(', ') : ''}`);
|
||||||
const byN = [...r2ByN.entries()].map(([n, [cc, bb]]) => `${n}-obj ${cc - bb}/${cc}`).join(', ');
|
console.log(` Rule 1b (no gate in the rim band around another solid): ${gateRimBad ? 'BAD — ' + gateRimExamples.join(', ') : 'OK'}`);
|
||||||
const anchorSorted = [...anchorDs].sort((a, b) => a - b);
|
const anchorSorted = [...anchorDs].sort((a, b) => a - b);
|
||||||
const distinct = new Set(anchorSorted.map((d) => Math.round(d / 50))).size;
|
if (anchorSorted.length) {
|
||||||
console.log(` gate→anchor distances: min=${Math.round(anchorSorted[0])} max=${Math.round(anchorSorted[anchorSorted.length - 1])}, ~${distinct} distinct radii (band 3500–4990)`);
|
const distinct = new Set(anchorSorted.map((d) => Math.round(d / 50))).size;
|
||||||
console.log(` Rule 2 (every object ≤ ${T2}px from another, N=1 exempt): ${r2Checked - r2Bad}/${r2Checked} OK — ${byN}`);
|
console.log(` gate→anchor distances: min=${Math.round(anchorSorted[0])} max=${Math.round(anchorSorted[anchorSorted.length - 1])}, ~${distinct} distinct radii (band ${ANCHOR_MIN}–${ANCHOR_MAX})`);
|
||||||
|
}
|
||||||
|
const byN = [...r2ByN.entries()].map(([n, [a, b, cc, tot]]) => `${n}-obj L1 ${a}/${tot}, L2-conn ${b}/${tot}, margin ${cc}/${tot}`).join('; ');
|
||||||
|
console.log(` Rule 2 (L1 chain + L2 connectivity + edge margin, ${systems} systems): L1 ${systems - l1Bad}/${systems}${l1Bad ? ' — ' + whyL1 : ''}`);
|
||||||
|
console.log(` L2 connectivity ${systems - connBad}/${systems}${connBad ? ' — ' + whyConn : ''}`);
|
||||||
|
console.log(` edge margin ${systems - rimBad}/${systems}${rimBad ? ' — ' + whyRim : ''}`);
|
||||||
|
console.log(` by system size: ${byN}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -232,22 +232,28 @@ world** — solid, rendered, flyable-to. Rules and seams:
|
||||||
|
|
||||||
- **World layout** — `SystemGenerator.layoutSystem(seed, systemId, planets,
|
- **World layout** — `SystemGenerator.layoutSystem(seed, systemId, planets,
|
||||||
freeSpace, isHome, targetAngles)` (pure, `js/galaxy/SystemGenerator.js`)
|
freeSpace, isHome, targetAngles)` (pure, `js/galaxy/SystemGenerator.js`)
|
||||||
places each system's planets and free-space stations on a ring (a
|
places each system's planets and free-space stations (the home world at
|
||||||
regular polygon) around the system center (the home world at the
|
the origin in the starting system; every other system's center is EMPTY —
|
||||||
origin in the starting system; every other system's center is EMPTY —
|
its star is invisible flavor, never rendered) on a COMPACT ARC at one
|
||||||
its star is invisible flavor, never rendered), enforcing the SOLAR
|
orbital radius, built around TETHER DISCOVERY (data/tether.json):
|
||||||
SYSTEM BAND in `data/planets.json → solarSystem`: EVERY pair of layout
|
CONSECUTIVE objects sit a level-1 tether apart (≤ 5120 − edgeMargin — a
|
||||||
objects — planets, stations (and the home world, starting system
|
level-1 tether from any one of them always discovers a neighbor, so a
|
||||||
only) — sits center-to-center in `[minSpacing, maxSpacing]` (6400–15360 px; the starting system's
|
chain of level-1 tethers walks the whole system), every other pair sits
|
||||||
band tightens to `[minSpacing, homeMaxSpacing]` = 6400–10240 px). The
|
clearly inside or outside level 2 (the level-2 graph is connected — level
|
||||||
ring radius is chosen inside the band (non-home: `maxSpacing` over the
|
2 DEFINITELY discovers while undiscovered objects remain), and NO
|
||||||
chord of the N-gon; home: the (N+1)-gon side over the band's tight
|
anchorable pair (planets, stations, home world) sits in the rim band
|
||||||
ratio), and the rotation (ring phase) is a deterministic scan that
|
around a tether boundary (± edgeMargin — data/tether.json →
|
||||||
serves the system's gate target bearings. The starting system is capped
|
`edgeMargin`, 512 px — objects are never at the edge of a tether region,
|
||||||
at 3 objects (2 planets + ≤ 1 station) — five points cannot sit
|
where the scan misses them). Level 3 is never required for discovery.
|
||||||
6400–10240 px apart (the tightest 5-point spacing needs ratio ≥ φ >
|
The orbital radius (center-to-object) sits in
|
||||||
1.6). Planet-bound settlements (colonies, mining stations, cloud bases)
|
`[minSpacing, maxSpacing]` (6400–15360 px; home additionally ≤
|
||||||
are features of their planet, not layout objects.
|
level-2 − edgeMargin = 9728 — the home world IS an anchor), and the
|
||||||
|
consecutive gap in `[neighborMin, level-1 − edgeMargin]` (2048–4608 px).
|
||||||
|
The arc's step angle is a deterministic scan that serves the system's
|
||||||
|
gate target bearings. (The old regular N-gon ring is gone — its chords
|
||||||
|
put 2- and 3-object systems past level 2 and spread 4- and 5-object
|
||||||
|
systems too thin.) Planet-bound settlements (colonies, mining stations,
|
||||||
|
cloud bases) are features of their planet, not layout objects.
|
||||||
Draws come from the dedicated fork `Rng.derive(seed, 'system', id,
|
Draws come from the dedicated fork `Rng.derive(seed, 'system', id,
|
||||||
'layout')` — layout results are seed-deterministic *and* don't perturb
|
'layout')` — layout results are seed-deterministic *and* don't perturb
|
||||||
the content stream (lazy === eager is preserved). Class sizes/tints in
|
the content stream (lazy === eager is preserved). Class sizes/tints in
|
||||||
|
|
@ -305,17 +311,19 @@ exit toward a NEARBY star on the 2-D map. Two layers:
|
||||||
preferring a working network over the cap.
|
preferring a working network over the cap.
|
||||||
- **The in-system placement** — `SystemGenerator.layoutGates(...)` (pure)
|
- **The in-system placement** — `SystemGenerator.layoutGates(...)` (pure)
|
||||||
places each gate on one of the radius steps spanning
|
places each gate on one of the radius steps spanning
|
||||||
`anchorMinDistance`–`anchorMaxDistance` (default 3500–4990 px — the
|
`anchorMinDistance`–`anchorMaxDistance` (default 3500–4608 px — the
|
||||||
distance VARIES per gate, always inside the level-1 tether (5120 px,
|
distance VARIES per gate, always inside the level-1 tether (5120 px,
|
||||||
`data/tether.json`) and strictly < 5000 px) around one of the
|
`data/tether.json`) and strictly < 5000 px) around one of the
|
||||||
system's ANCHORS (a PLANET — free-space stations are deliberately not
|
system's ANCHORS (a PLANET — free-space stations are deliberately not
|
||||||
anchors, since the build console lives on a world — or the home world
|
anchors, since the build console lives on a world — or the home world
|
||||||
in the starting system) — so the tether rule is hard by
|
in the starting system) — so the tether rule is hard by
|
||||||
construction. Candidates are tried in facing quality: the ray-circle
|
construction. Candidates are tried in this order: same side of the
|
||||||
intersection (gate exactly on the system→star ray), the circle point
|
anchor as the target FIRST, then OUTSIDE the rim bands (± edgeMargin —
|
||||||
aimed at the star, then a ±75° scan — so the direction rule is soft
|
a gate must never sit at the edge of ANOTHER object's tether either),
|
||||||
(same side of the anchor as the target, deviation < 90°; in practice
|
then the facing tier — the ray-circle intersection (gate exactly on
|
||||||
~98% are within 75°). Gates stay `minRadius..maxRadius` (2048–20480
|
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` (2048–20480
|
||||||
px) from the system center, keep `size` + `clearance` from solid discs
|
px) from the system center, keep `size` + `clearance` from solid discs
|
||||||
(planets, free-space stations, the home world) and
|
(planets, free-space stations, the home world) and
|
||||||
2·`size` + `gateGap` from each other, and get unique names ("Avidy
|
2·`size` + `gateGap` from each other, and get unique names ("Avidy
|
||||||
|
|
|
||||||
|
|
@ -51,36 +51,40 @@ const DEG = Math.PI / 180;
|
||||||
* The STARTING system is special: the player's home world sits at the
|
* The STARTING system is special: the player's home world sits at the
|
||||||
* origin (not a generated planet, fixed key 'home'), and the system always
|
* origin (not a generated planet, fixed key 'home'), and the system always
|
||||||
* holds exactly two more planets — a gas giant and a rocky world. With the
|
* holds exactly two more planets — a gas giant and a rocky world. With the
|
||||||
* home world, three planets, always. And it is capped at ONE free-space
|
* home world, three planets, always. It is capped at ONE free-space
|
||||||
* station: five objects (the home world + four) cannot sit 6400..10240 px
|
* station by design — the home system stays a small start the player
|
||||||
* apart — the tightest 5-point spacing needs a max/min ratio ≥ φ ≈ 1.618,
|
* expands out of (the arc layout could seat more; the cap keeps the
|
||||||
* which the home band 10240/6400 = 1.6 cannot give — so the home system
|
* starting system readable — see layoutSystem + generateSettlements).
|
||||||
* stays a ≤ 4-object configuration (see layoutSystem). Its TYPE is also
|
* Its TYPE is also
|
||||||
* fixed: always 'main' (Main Sequence — the player's home is an ordinary
|
* fixed: always 'main' (Main Sequence — the player's home is an ordinary
|
||||||
* star; Galaxy.js forces it at roster build, data/galaxy.json →
|
* star; Galaxy.js forces it at roster build, data/galaxy.json →
|
||||||
* startingSystem.type), so its star class rolls from the main-sequence
|
* startingSystem.type), so its star class rolls from the main-sequence
|
||||||
* table (G/K/M).
|
* table (G/K/M).
|
||||||
*
|
*
|
||||||
* LAYOUT (data/planets.json → solarSystem) — the SOLAR SYSTEM BAND: every
|
* LAYOUT (data/planets.json → solarSystem) — built around TETHER
|
||||||
* PAIR of layout objects (planets and free-space stations — the central
|
* DISCOVERY (data/tether.json): the layout objects (planets + free-space
|
||||||
* body, the home world, sits at the local origin of the STARTING system
|
* stations — the central body, the home world, sits at the local origin
|
||||||
* only; every other system has an EMPTY center — the star is invisible
|
* of the STARTING system only; every other system has an EMPTY center —
|
||||||
* flavor, generated as `star` but never rendered) sits 6400..15360 px
|
* the star is invisible flavor, generated as `star` but never rendered)
|
||||||
* apart, center to center; in the home system the band tightens to
|
* sit on a COMPACT ARC at one orbital radius: consecutive objects a
|
||||||
* 6400..10240 px. Normal systems lay out as a regular N-gon ring around
|
* level-1 tether apart (≤ 5120 − edgeMargin — a level-1 tether from any
|
||||||
* the (empty) center; the home system as a regular (N+1)-polygon with the
|
* one of them ALWAYS discovers a neighbor), every other pair clearly
|
||||||
* home world as one vertex. The rotation is chosen to serve the jump
|
* inside or outside level 2 — and NO object sits in the rim band around
|
||||||
* gates — objects bias toward the directions the system jumps (see
|
* any tether boundary (± edgeMargin — the scan edge case). A chain of
|
||||||
* layoutSystem + layoutGates).
|
* level-1 tethers discovers the whole system, so level 3 is never
|
||||||
|
* required (the old regular N-gon ring is gone — its chords put 2- and
|
||||||
|
* 3-object systems PAST level 2 and spread 4- and 5-object systems too
|
||||||
|
* thin). 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):
|
* JUMP GATES (data/gates.json; the network in js/galaxy/JumpNetwork.js):
|
||||||
* 1–3 gates per system — each placed on the side of the system facing its
|
* 1–3 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
|
* destination star on the 2-D map (an upper-right gate jumps to a star in
|
||||||
* the upper right). An ANCHORED system (a planet, or the home world in
|
* the upper right). An ANCHORED system (a planet, or the home world in
|
||||||
* the starting system) hosts its gates 3500–4990 px from an anchor
|
* the starting system) hosts its gates 3500–4608 px from an anchor
|
||||||
* (data/gates.json → anchorMinDistance/anchorMaxDistance — the distance
|
* (data/gates.json → anchorMinDistance/anchorMaxDistance — the distance
|
||||||
* VARIES per gate, always inside the level-1 tether (5120 px) and
|
* VARIES per gate, always CLEARLY inside the level-1 tether (≤ 5120 −
|
||||||
* strictly < 5000 px) — planets ONLY (free-space stations are
|
* edgeMargin — never at its rim) and strictly < 5000 px) — planets ONLY (free-space stations are
|
||||||
* deliberately not anchors: the build console lives on a world, so every
|
* deliberately not anchors: the build console lives on a world, so every
|
||||||
* gate must be tether-reachable from a planet the player can build out
|
* gate must be tether-reachable from a planet the player can build out
|
||||||
* from). A BARREN
|
* from). A BARREN
|
||||||
|
|
@ -325,56 +329,75 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-down layout of a system's objects — its planets and its free-space
|
* Top-down layout of a system's objects — its planets and its free-space
|
||||||
* stations — under the SOLAR SYSTEM BAND (data/planets.json →
|
* stations (the "layout objects"; planet-bound settlements are features of
|
||||||
* solarSystem):
|
* their planet and are NOT layout objects). The central body — the home
|
||||||
|
* world at the local origin in the starting system, an invisible star
|
||||||
|
* (never rendered) elsewhere — is part of the rule but never rendered as
|
||||||
|
* a planet.
|
||||||
*
|
*
|
||||||
* Every PAIR of layout objects — the planets, the free-space stations,
|
* DISCOVERY IS TETHER-BASED (data/tether.json): level 1 = 5120 px, level
|
||||||
* and the central body (the star, or the HOME world in the starting
|
* 2 = 10240 px, level 3 = 20480 px from an anchor. The layout is built
|
||||||
* system — which sits at the local origin) — is at least `minSpacing`
|
* around the design rules:
|
||||||
* and at most the band's maximum apart, center to center:
|
|
||||||
*
|
*
|
||||||
* normal systems : minSpacing..maxSpacing (6400..15360 px)
|
* 1. A LEVEL-1 tether from any planet or station discovers a neighbor —
|
||||||
* home system : minSpacing..homeMaxSpacing (6400..10240 px)
|
* in fact ALWAYS: CONSECUTIVE layout objects sit a level-1 distance
|
||||||
|
* apart (≤ 5120 − edgeMargin), so a chain of level-1 tethers walks
|
||||||
|
* the whole arc. (The player can always build on a discovered world
|
||||||
|
* or station — every one is an anchor in its own right.)
|
||||||
|
* 2. A LEVEL-2 tether DEFINITELY discovers an object while undiscovered
|
||||||
|
* objects remain: the level-2 adjacency graph over the objects (and
|
||||||
|
* the home world, starting system) is CONNECTED — it contains the
|
||||||
|
* level-1 chain, so it holds by construction.
|
||||||
|
* 3. No LEVEL-3 tether is ever REQUIRED to find everything — the
|
||||||
|
* level-1 chain above makes level 3 a reachability luxury, not a
|
||||||
|
* discovery need. Far pairs MAY sit beyond level 2 — that is the
|
||||||
|
* point of "not everything within two tether regions".
|
||||||
*
|
*
|
||||||
* (The old "every object within maxNeighbor of some object" rule is
|
* EDGE MARGIN (data/tether.json → edgeMargin, 512 px): NO anchorable
|
||||||
* implied: ≤ 6 objects inside a 15360 px band keeps every object within
|
* object pair (planets, stations, home world) sits in the no-man's-land
|
||||||
* level-2/3 tether of the others.)
|
* band around a tether rim — (L1 − margin, L1 + margin) or (L2 − margin,
|
||||||
|
* L2 + margin). Objects are clearly inside or clearly outside each tether
|
||||||
|
* region, never at the edge where the scan misses them. The invisible
|
||||||
|
* star is NOT a tether anchor, so a normal system's star–planet distance
|
||||||
|
* only needs the plain orbital band.
|
||||||
*
|
*
|
||||||
* Shapes that satisfy a full pairwise band exactly:
|
* THE SHAPE: the N objects sit on ONE orbit (radius r from the center) as
|
||||||
* N = 1, non-home — a single point at distance R ∈ [min, max]; its only
|
* a compact arc — consecutive objects a level-1 gap apart (chord
|
||||||
* pair (with the star) is just R.
|
* 2·r·sin α, α the half-step) — replacing the old regular N-gon ring,
|
||||||
* N ≥ 2, non-home — a REGULAR N-GON RING around the star: every pair is
|
* which put 2- and 3-object systems past level 2 (chords
|
||||||
* a chord, the longest being 2·R·sin(⌊N/2⌋·π/N), so
|
* 2·r·sin(π(N−1)/N)) and spread 4- and 5-object systems too thin. The
|
||||||
* R ∈ [min, max / (2·sin(⌊N/2⌋·π/N))].
|
* step angle α is chosen deterministically (a seeded scan over the SAFE α
|
||||||
* home system — a REGULAR (N+1)-POLYGON with the home world as ONE
|
* — those where EVERY pair k apart, chord 2·r·|sin kα|, is level-1 for
|
||||||
* VERTEX (the origin): every pair is a polygon chord,
|
* k = 1 and clearly outside/inside level 2 for k ≥ 2) to land the
|
||||||
* the longest ratio(N+1)·side, so side ∈ [min,
|
* consecutive gap near a seeded target in [neighborMin, L1 − margin] —
|
||||||
* homeMax / ratio(N+1)]. (The home world + 4 objects —
|
* variety across systems, every rule hard by construction.
|
||||||
* 5 points — would need a max/min ratio ≥ φ ≈ 1.618,
|
*
|
||||||
* which the home band 10240/6400 = 1.6 cannot give;
|
* Bands (data/planets.json → solarSystem):
|
||||||
* that is why the home system is capped at 3 objects:
|
* orbital radius r (center-to-object):
|
||||||
* its 2 fixed planets + at most one free-space
|
* normal systems : minSpacing..maxSpacing (6400..15360 px)
|
||||||
* station — see generateSettlements.)
|
* home system : minSpacing..homeMaxSpacing, additionally ≤
|
||||||
|
* level-2 − edgeMargin (9728 px — the home world is
|
||||||
|
* an anchor, so its pairs respect the bands)
|
||||||
|
* consecutive gap : neighborMin..(level-1 − edgeMargin) (2048..4608 px)
|
||||||
*
|
*
|
||||||
* What counts as an object: planets (all of them) and free-space
|
* What counts as an object: planets (all of them) and free-space
|
||||||
* settlements (anchor.type 'space' — deep-space stations, waypoints).
|
* settlements (anchor.type 'space' — deep-space stations, waypoints).
|
||||||
* Planet-bound settlements are features OF their planet (a colony sits on
|
* Planet-bound settlements are features OF their planet (a colony sits on
|
||||||
* it) and so are not layout objects of their own. The central body is the
|
* 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;
|
* 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).
|
* elsewhere it is the star — the visual).
|
||||||
*
|
*
|
||||||
* The ROTATION (ring phase / polygon orientation) is chosen to serve the
|
* The ROTATION (arc phase) is chosen to serve the jump gates: it
|
||||||
* jump gates: it minimizes the worst perpendicular distance between a
|
* minimizes the worst perpendicular distance between a gate's target ray
|
||||||
* gate's target ray and the nearest object, so layoutGates can sit each
|
* and the nearest object, so layoutGates can sit each gate on the true
|
||||||
* gate on the true target ray while staying tether-reachable. Without
|
* target ray while staying tether-reachable. Without targets (gates
|
||||||
* targets (gates disabled, one-system galaxy) it falls back to a seeded
|
* disabled, one-system galaxy) it falls back to a seeded random rotation.
|
||||||
* random rotation.
|
|
||||||
*
|
*
|
||||||
* Each planet also gains scale (size multiplier, data/planets.json →
|
* Each planet also gains scale (size multiplier, data/planets.json →
|
||||||
* classScale — the band is center-to-center, but the rendered discs still
|
* classScale — the distances are center-to-center, but the rendered discs
|
||||||
* scale by class).
|
* still scale by class).
|
||||||
*
|
*
|
||||||
* Determinism: the radius/side draw comes from the dedicated fork
|
* Determinism: the radius/gap draws come from the dedicated fork
|
||||||
* (seed, 'system', id, 'layout'); the rotation is a pure function of
|
* (seed, 'system', id, 'layout'); the rotation is a pure function of
|
||||||
* (shape, target angles). Same seed ⇒ same layout, and lazy
|
* (shape, target angles). Same seed ⇒ same layout, and lazy
|
||||||
* (on-arrival) === eager (generateAll).
|
* (on-arrival) === eager (generateAll).
|
||||||
|
|
@ -385,7 +408,7 @@ function layoutSystem(seed, systemId, planets, freeSpace, isHome, targetAngles)
|
||||||
const MIN = Math.max(1, band.minSpacing ?? 6400);
|
const MIN = Math.max(1, band.minSpacing ?? 6400);
|
||||||
const MAX = Math.max(MIN, isHome ? (band.homeMaxSpacing ?? 10240) : (band.maxSpacing ?? 15360));
|
const MAX = Math.max(MIN, isHome ? (band.homeMaxSpacing ?? 10240) : (band.maxSpacing ?? 15360));
|
||||||
|
|
||||||
// Rendered size per class (visual only — the band is center-to-center).
|
// Rendered size per class (visual only — the distances are center-to-center).
|
||||||
for (const p of planets) {
|
for (const p of planets) {
|
||||||
p.scale = config.get(`planets.classScale.${p.class}`, 1) ?? 1;
|
p.scale = config.get(`planets.classScale.${p.class}`, 1) ?? 1;
|
||||||
}
|
}
|
||||||
|
|
@ -397,45 +420,52 @@ function layoutSystem(seed, systemId, planets, freeSpace, isHome, targetAngles)
|
||||||
if (N === 0) return;
|
if (N === 0) return;
|
||||||
|
|
||||||
const lay = Rng.derive(seed, 'system', systemId, 'layout');
|
const lay = Rng.derive(seed, 'system', systemId, 'layout');
|
||||||
|
const B = tetherBands();
|
||||||
|
|
||||||
|
// Orbital radius (every object shares the one orbit). The home world is
|
||||||
|
// a TETHER ANCHOR, so its distances to the objects respect the bands —
|
||||||
|
// the ceiling falls at level-2 − edgeMargin (9728 px, clearly inside
|
||||||
|
// level 2, off the rim); the star of a normal system is not an anchor,
|
||||||
|
// so its distances only need the plain band.
|
||||||
|
const Rmax = isHome ? Math.min(MAX, B.L2in) : MAX;
|
||||||
|
if (Rmax < MIN) {
|
||||||
|
console.warn(
|
||||||
|
`[orbit] ${systemId}: the orbital radius band [${MIN}, ${Math.round(Rmax)}] px is empty — using the minimum spacing`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const r = lay.range(MIN, Math.max(MIN, Rmax));
|
||||||
|
|
||||||
|
// N = 1: a single point at distance r — its only anchorable pair is
|
||||||
|
// with the central body (home world: r ≤ L2in, and 6400 > L1out —
|
||||||
|
// clearly inside level 2 and clearly outside level 1 ✓).
|
||||||
|
if (N === 1) {
|
||||||
|
const single = (phi) => [{ x: r * Math.cos(phi), y: r * Math.sin(phi) }];
|
||||||
|
const phi = targetAngles ? bestRotation(single, targetAngles) : lay.range(0, TAU);
|
||||||
|
objects[0].x = single(phi)[0].x;
|
||||||
|
objects[0].y = single(phi)[0].y;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// N ≥ 2: a COMPACT ARC of consecutive objects. The consecutive gap is
|
||||||
|
// level-1 (clearly inside — discoverable) and disc-clear; every other
|
||||||
|
// pair sits off both tether rims. A chain of level-1 tethers discovers
|
||||||
|
// the whole system; level 2 is the guaranteed fallback, level 3 never
|
||||||
|
// required (see the docstring).
|
||||||
|
const nMin = Math.max(2, band.neighborMin ?? 2048);
|
||||||
|
const nMax = Math.max(nMin, B.L1in);
|
||||||
|
const dTarget = lay.range(nMin, nMax); // desired consecutive gap (seeded variety)
|
||||||
|
const alpha = chooseHalfStep(N, r, nMin, dTarget, B);
|
||||||
|
const step = 2 * alpha; // step angle between consecutive objects
|
||||||
|
|
||||||
// `place(phi)` → the N object positions [{x, y}] for rotation `phi`.
|
// `place(phi)` → the N object positions [{x, y}] for rotation `phi`.
|
||||||
let place;
|
const place = (phi) => {
|
||||||
if (isHome) {
|
const out = [];
|
||||||
// Regular (N+1)-gon, the home world (origin) as vertex 0: vertex k is
|
for (let k = 0; k < N; k++) {
|
||||||
// Rc·(u(φ + 2πk/m) − u(φ)) — a chord of length 2·Rc·sin(πk/m).
|
const a = phi + k * step;
|
||||||
const m = N + 1;
|
out.push({ x: r * Math.cos(a), y: r * Math.sin(a) });
|
||||||
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`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const side = lay.range(MIN, Math.max(MIN, sideMax));
|
return out;
|
||||||
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;
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Regular N-gon ring around the star (origin). N = 1: one point at
|
|
||||||
// distance R — its only pair (with the star) is just R.
|
|
||||||
const rMax = N === 1 ? MAX : MAX / (2 * Math.sin((Math.floor(N / 2) * Math.PI) / N));
|
|
||||||
const R = lay.range(MIN, Math.max(MIN, rMax));
|
|
||||||
place = (phi) => {
|
|
||||||
const out = [];
|
|
||||||
for (let k = 0; k < N; k++) {
|
|
||||||
const a = phi + (TAU * k) / N;
|
|
||||||
out.push({ x: R * Math.cos(a), y: R * Math.sin(a) });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const phi = targetAngles ? bestRotation(place, targetAngles) : lay.range(0, TAU);
|
const phi = targetAngles ? bestRotation(place, targetAngles) : lay.range(0, TAU);
|
||||||
const slots = place(phi);
|
const slots = place(phi);
|
||||||
|
|
@ -445,11 +475,89 @@ function layoutSystem(seed, systemId, planets, freeSpace, isHome, targetAngles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Max-chord / side ratio of a regular `m`-gon (m ≥ 3); 1 for m < 3. */
|
/**
|
||||||
function ratioOf(m) {
|
* The tether discovery bands (data/tether.json): the level radii the
|
||||||
if (m < 3) return 1;
|
* player actually uses + the EDGE MARGIN around each rim — the
|
||||||
const half = Math.floor(m / 2);
|
* no-man's-land bands where no object sits. L1in/L1out = the level-1
|
||||||
return Math.sin((half * Math.PI) / m) / Math.sin(Math.PI / m);
|
* boundary ± edgeMargin (clearly inside / clearly outside level 1);
|
||||||
|
* likewise L2in/L2out for level 2.
|
||||||
|
*/
|
||||||
|
function tetherBands() {
|
||||||
|
const L1 = Math.max(1, config.get('tether.level1Radius', 5120));
|
||||||
|
const growth = Math.max(1, config.get('tether.radiusGrowth', 2.0));
|
||||||
|
const M = Math.max(0, config.get('tether.edgeMargin', 512));
|
||||||
|
const L2 = L1 * growth;
|
||||||
|
return {
|
||||||
|
L1, L2, M,
|
||||||
|
L1in: L1 - M,
|
||||||
|
L1out: L1 + M,
|
||||||
|
L2in: L2 - M,
|
||||||
|
L2out: L2 + M,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The safe half-step angle α for the N-object arc at orbit radius r: with
|
||||||
|
* step angle 2α, objects k apart sit 2·r·|sin kα| apart (|sin| also covers
|
||||||
|
* the wrap past a half-circle). α is SAFE when:
|
||||||
|
* - k = 1: nMin ≤ d ≤ L1in — the consecutive gap is clearly inside
|
||||||
|
* level 1 (discoverable) and clear of the rendered discs;
|
||||||
|
* - k ≥ 2: d NOT in (L1in, L1out) nor in (L2in, L2out) — every other
|
||||||
|
* pair sits clearly inside or clearly outside each tether region,
|
||||||
|
* never at a rim (the scan edge case).
|
||||||
|
* A deterministic scan (coarse + local refine, like bestRotation) picks
|
||||||
|
* the safe α whose consecutive gap is nearest the seeded target — variety
|
||||||
|
* across systems, every rule hard by construction. If no α passes
|
||||||
|
* (degenerate config / N too large for this radius), fall back to the
|
||||||
|
* level-1 cap and warn.
|
||||||
|
*/
|
||||||
|
function chooseHalfStep(N, r, nMin, dTarget, B) {
|
||||||
|
const aMax = Math.asin(Math.min(1, B.L1in / (2 * r)));
|
||||||
|
const aMin = Math.asin(Math.min(1, Math.min(nMin, B.L1in) / (2 * r)));
|
||||||
|
const safe = (a) => {
|
||||||
|
for (let k = 1; k < N; k++) {
|
||||||
|
const d = 2 * r * Math.abs(Math.sin(k * a));
|
||||||
|
if (k === 1) {
|
||||||
|
if (d < nMin - 1e-9 || d > B.L1in + 1e-9) return false;
|
||||||
|
} else if (
|
||||||
|
(d > B.L1in + 1e-9 && d < B.L1out - 1e-9) ||
|
||||||
|
(d > B.L2in + 1e-9 && d < B.L2out - 1e-9)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const gap = (a) => 2 * r * Math.abs(Math.sin(a));
|
||||||
|
const score = (a) => (safe(a) ? Math.abs(gap(a) - dTarget) : Infinity);
|
||||||
|
|
||||||
|
let best = null; // [α, score]
|
||||||
|
const COARSE = 2048;
|
||||||
|
for (let s = 0; s < COARSE; s++) {
|
||||||
|
const a = aMin + ((aMax - aMin) * s) / (COARSE - 1);
|
||||||
|
const sc = score(a);
|
||||||
|
if (best === null || sc < best[1] - 1e-12) best = [a, sc];
|
||||||
|
}
|
||||||
|
if (best !== null && best[1] !== Infinity) {
|
||||||
|
// Local refine around the coarse winner (the safe set is a union of
|
||||||
|
// intervals — this pins the gap to well under a pixel).
|
||||||
|
const w = (2 * (aMax - aMin)) / (COARSE - 1);
|
||||||
|
for (let s = 0; s < 256; s++) {
|
||||||
|
const a = best[0] - w + ((2 * w) * s) / 255;
|
||||||
|
if (a < aMin - 1e-12 || a > aMax + 1e-12) continue;
|
||||||
|
const sc = score(a);
|
||||||
|
if (sc < best[1] - 1e-12) best = [a, sc];
|
||||||
|
}
|
||||||
|
return best[0];
|
||||||
|
}
|
||||||
|
// Degenerate (N too large for this orbit radius, or a config conflict):
|
||||||
|
// the level-1 cap keeps the consecutive gap safe; a far pair may end up
|
||||||
|
// in a rim band — flag it for inspection rather than fail.
|
||||||
|
console.warn(
|
||||||
|
`[orbit] arc layout: no safe half-step for ${N} objects at r=${r} px — ` +
|
||||||
|
'using the level-1 cap (a far pair may sit in a tether band)',
|
||||||
|
);
|
||||||
|
return aMax;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -510,9 +618,10 @@ function bestRotation(place, targetAngles) {
|
||||||
* star to the upper right on the map";
|
* star to the upper right on the map";
|
||||||
* - ANCHORED systems (a planet — or the home world in the starting
|
* - ANCHORED systems (a planet — or the home world in the starting
|
||||||
* system): the gate sits BETWEEN `anchorMinDistance` and
|
* system): the gate sits BETWEEN `anchorMinDistance` and
|
||||||
* `anchorMaxDistance` from an anchor (default 3500–4990 px — the
|
* `anchorMaxDistance` from an anchor (default 3500–4608 px — the
|
||||||
* distance VARIES per gate, always inside the level-1 tether and
|
* distance VARIES per gate, always CLEARLY inside the level-1 tether
|
||||||
* strictly < 5000 px) — on one of the radius STEPS spanning that
|
* (≤ 5120 − edgeMargin — never at its rim) and strictly < 5000 px) —
|
||||||
|
* on one of the radius STEPS spanning that
|
||||||
* band around the anchor, chosen in order of facing quality: (1) the
|
* band around the anchor, chosen in order of facing quality: (1) the
|
||||||
* far ray-circle intersection (the gate exactly on the target ray —
|
* far ray-circle intersection (the gate exactly on the target ray —
|
||||||
* system center, gate, and destination star collinear), (2) the point
|
* system center, gate, and destination star collinear), (2) the point
|
||||||
|
|
@ -527,7 +636,10 @@ function bestRotation(place, targetAngles) {
|
||||||
* tier — so the gate faces its destination FROM THE CENTER (the rule
|
* tier — so the gate faces its destination FROM THE CENTER (the rule
|
||||||
* the map reads) and is never placed on the far side; an on-ray
|
* the map reads) and is never placed on the far side; an on-ray
|
||||||
* intersection behind the center loses to any destination-side
|
* intersection behind the center loses to any destination-side
|
||||||
* aimed/scan point.
|
* aimed/scan point. After the side, RIM-BAND VIOLATIONS order the
|
||||||
|
* candidates: a gate never sits in the no-man's-land band around
|
||||||
|
* another object's tether rim (± edgeMargin — the scan edge case) when
|
||||||
|
* a clean point exists.
|
||||||
* Anchors are PLANETS
|
* Anchors are PLANETS
|
||||||
* only (plus the home world at home) — free-space stations are
|
* only (plus the home world at home) — free-space stations are
|
||||||
* deliberately excluded: the build console lives on a world, so every
|
* deliberately excluded: the build console lives on a world, so every
|
||||||
|
|
@ -569,13 +681,15 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
|
||||||
const maxR = Math.max(minR, g.maxRadius ?? 20480);
|
const maxR = Math.max(minR, g.maxRadius ?? 20480);
|
||||||
// The anchor circles: the gate's distance from its anchor VARIES
|
// The anchor circles: the gate's distance from its anchor VARIES
|
||||||
// across `anchorMinDistance`..`anchorMaxDistance` (data/gates.json —
|
// across `anchorMinDistance`..`anchorMaxDistance` (data/gates.json —
|
||||||
// default 3500–4990 px: the design rule "strictly < 5000 px from a
|
// default 3500–4608 px: the design rule "strictly < 5000 px from a
|
||||||
// planet or space station", Rule 1), CAPPED by the anchor's
|
// planet or space station" (Rule 1), with the EDGE MARGIN — the gate
|
||||||
|
// never sits at the rim of the anchor's level-1 tether
|
||||||
|
// (data/tether.json → edgeMargin)), CAPPED by the anchor's
|
||||||
// level-`anchorTetherLevel` tether range (the gate must stay inside
|
// level-`anchorTetherLevel` tether range (the gate must stay inside
|
||||||
// it). Every candidate below sits exactly on one of these circles, so
|
// it). Every candidate below sits exactly on one of these circles, so
|
||||||
// both rules hold by construction.
|
// both rules hold by construction.
|
||||||
const tetherRange = anchorTetherRange(g.anchorTetherLevel ?? 1);
|
const tetherRange = anchorTetherRange(g.anchorTetherLevel ?? 1);
|
||||||
const rangeMax = Math.min(tetherRange, Math.max(1, g.anchorMaxDistance ?? 4990));
|
const rangeMax = Math.min(tetherRange, Math.max(1, g.anchorMaxDistance ?? 4608));
|
||||||
const rangeMin = Math.min(rangeMax, Math.max(1, g.anchorMinDistance ?? 3500));
|
const rangeMin = Math.min(rangeMax, Math.max(1, g.anchorMinDistance ?? 3500));
|
||||||
|
|
||||||
// Anchors: the PLANETS and (home only) the home world at the origin —
|
// Anchors: the PLANETS and (home only) the home world at the origin —
|
||||||
|
|
@ -654,27 +768,48 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
|
||||||
// (32 points, ± up to 75° from the target direction) — the
|
// (32 points, ± up to 75° from the target direction) — the
|
||||||
// clearance search for tight systems.
|
// clearance search for tight systems.
|
||||||
// Every candidate sits EXACTLY `r` from its anchor, `r` one of the
|
// Every candidate sits EXACTLY `r` from its anchor, `r` one of the
|
||||||
// RADIUS STEPS spanning [rangeMin, rangeMax] (default 3500–4990 px —
|
// RADIUS STEPS spanning [rangeMin, rangeMax] (default 3500–4608 px —
|
||||||
// the gate's distance from its planet VARIES, always < 5000 and
|
// the gate's distance from its planet VARIES, always < 5000 and
|
||||||
// inside the level-1 tether). The per-gate PREFERRED step (a
|
// clearly inside the level-1 tether — off its rim). The per-gate
|
||||||
// dedicated seeded fork — deterministic, lazy === eager) orders the
|
// PREFERRED step (a dedicated seeded fork — deterministic, lazy ===
|
||||||
// steps, and clearance/gap pressure pushes the gate outward from it
|
// eager) orders the steps, and clearance/gap pressure pushes the
|
||||||
// (the radius is the LAST tie-break — facing quality always wins).
|
// gate outward from it (the radius is the LAST tie-break — facing
|
||||||
// The facing rule (soft — the gate faces its destination FROM THE
|
// quality wins over it). The facing rule (soft — the gate faces its
|
||||||
// SYSTEM CENTER) is honored by the sort: destination-side candidates
|
// destination FROM THE SYSTEM CENTER) is honored by the sort:
|
||||||
// (side 0) come FIRST — before every tier — so a gate is never
|
// destination-side candidates (side 0) come FIRST — before every
|
||||||
// placed on the far side of the center (an on-ray intersection
|
// tier — so a gate is never placed on the far side of the center
|
||||||
// behind the center loses to any destination-side aimed/scan point);
|
// (an on-ray intersection behind the center loses to any
|
||||||
// then on-ray → aimed → near-aimed → preferred radius first.
|
// destination-side aimed/scan point); then the RIM-BAND count
|
||||||
|
// (a gate never sits in the no-man's-land band around another
|
||||||
|
// object's tether rim — the scan edge case — when a clean point
|
||||||
|
// exists); then on-ray → aimed → near-aimed → preferred radius.
|
||||||
const STEPS = 8;
|
const STEPS = 8;
|
||||||
const radii = Array.from(
|
const radii = Array.from(
|
||||||
{ length: STEPS },
|
{ length: STEPS },
|
||||||
(_, s) => rangeMin + ((rangeMax - rangeMin) * s) / (STEPS - 1),
|
(_, s) => rangeMin + ((rangeMax - rangeMin) * s) / (STEPS - 1),
|
||||||
);
|
);
|
||||||
const radPref = Rng.derive(seed, 'system', record.id, 'gates', i).int(0, STEPS - 1);
|
const radPref = Rng.derive(seed, 'system', record.id, 'gates', i).int(0, STEPS - 1);
|
||||||
|
// RIM BANDS (data/tether.json → edgeMargin): the no-man's-land
|
||||||
|
// around each tether rim where no object sits. A gate's distance to
|
||||||
|
// its OWN anchor is on the step band (≤ L1in — clearly inside, by
|
||||||
|
// construction), so only the OTHER solids count.
|
||||||
|
const B = tetherBands();
|
||||||
|
const inRim = (d) =>
|
||||||
|
(d > B.L1in + 1e-9 && d < B.L1out - 1e-9) ||
|
||||||
|
(d > B.L2in + 1e-9 && d < B.L2out - 1e-9);
|
||||||
|
const bandCount = (px, py, ai) => {
|
||||||
|
let n = 0;
|
||||||
|
for (let j = 0; j < discs.length; j++) {
|
||||||
|
if (j === ai) continue;
|
||||||
|
const dx = px - discs[j].x;
|
||||||
|
const dy = py - discs[j].y;
|
||||||
|
if (inRim(Math.hypot(dx, dy))) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
};
|
||||||
const cands = [];
|
const cands = [];
|
||||||
const add = (tier, order, px, py) =>
|
const add = (tier, order, px, py, ai) =>
|
||||||
cands.push({ tier, order, side: px * ux + py * uy > 0 ? 0 : 1, px, py });
|
cands.push({ tier, order, side: px * ux + py * uy > 0 ? 0 : 1, band: bandCount(px, py, ai), px, py });
|
||||||
anchors.forEach((a, ai) => {
|
anchors.forEach((a, ai) => {
|
||||||
const proj = a.x * ux + a.y * uy; // signed distance along the ray
|
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
|
const h = Math.abs(a.x * uy - a.y * ux); // perpendicular distance
|
||||||
|
|
@ -682,18 +817,20 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
|
||||||
const rad = (s - radPref + STEPS) % STEPS; // 0 = preferred step
|
const rad = (s - radPref + STEPS) % STEPS; // 0 = preferred step
|
||||||
if (h <= r) {
|
if (h <= r) {
|
||||||
const off = Math.sqrt(Math.max(0, r * r - h * h));
|
const off = Math.sqrt(Math.max(0, r * r - h * h));
|
||||||
add(1, h * 1e6 + ai * 1000 + rad * 10, (proj + off) * ux, (proj + off) * uy);
|
add(1, h * 1e6 + ai * 1000 + rad * 10, (proj + off) * ux, (proj + off) * uy, ai);
|
||||||
}
|
}
|
||||||
add(2, h * 1e6 + ai * 1000 + rad * 10, a.x + r * ux, a.y + r * uy);
|
add(2, h * 1e6 + ai * 1000 + rad * 10, a.x + r * ux, a.y + r * uy, ai);
|
||||||
for (let k = 0; k < 32; k++) {
|
for (let k = 0; k < 32; k++) {
|
||||||
const phi = -1.3089 + (2.6179 * k) / 31; // ±75° around the target direction
|
const phi = -1.3089 + (2.6179 * k) / 31; // ±75° around the target direction
|
||||||
const dx = ux * Math.cos(phi) - uy * Math.sin(phi);
|
const dx = ux * Math.cos(phi) - uy * Math.sin(phi);
|
||||||
const dy = ux * Math.sin(phi) + uy * Math.cos(phi);
|
const dy = ux * Math.sin(phi) + uy * Math.cos(phi);
|
||||||
add(3, Math.abs(phi) * 1e6 + h + ai * 1e-3 + rad * 1e-4, a.x + r * dx, a.y + r * dy);
|
add(3, Math.abs(phi) * 1e6 + h + ai * 1e-3 + rad * 1e-4, a.x + r * dx, a.y + r * dy, ai);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
cands.sort((p, q) => p.side - q.side || p.tier - q.tier || p.order - q.order);
|
cands.sort(
|
||||||
|
(p, q) => p.side - q.side || p.band - q.band || p.tier - q.tier || p.order - q.order,
|
||||||
|
);
|
||||||
|
|
||||||
const ok = (c) => {
|
const ok = (c) => {
|
||||||
const d2c = c.px * c.px + c.py * c.py;
|
const d2c = c.px * c.px + c.py * c.py;
|
||||||
|
|
@ -721,7 +858,7 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
|
||||||
}
|
}
|
||||||
if (!chosen) {
|
if (!chosen) {
|
||||||
// Every candidate failed clearance (nearly impossible — an anchor's
|
// Every candidate failed clearance (nearly impossible — an anchor's
|
||||||
// circles span 3500–4990 px, the discs under a thousand): take
|
// circles span 3500–4608 px, the discs under a thousand): take
|
||||||
// the first DESTINATION-SIDE aimed point anyway — the tether and
|
// the first DESTINATION-SIDE aimed point anyway — the tether and
|
||||||
// facing rules outrank cosmetics.
|
// facing rules outrank cosmetics.
|
||||||
chosen =
|
chosen =
|
||||||
|
|
@ -1129,7 +1266,8 @@ function mixTint(hex, strength) {
|
||||||
*
|
*
|
||||||
* home system → fixed: 2 planets (gas giant + rocky) beside the home
|
* home system → fixed: 2 planets (gas giant + rocky) beside the home
|
||||||
* world, + at most one free-space station (deepSpace only —
|
* world, + at most one free-space station (deepSpace only —
|
||||||
* a waypoint would be a 5th point the home band can't hold);
|
* by design: the home system stays a small start the player
|
||||||
|
* expands out of; the arc layout could seat more)
|
||||||
* every other → 1) the FINAL object count N from the configured table
|
* every other → 1) the FINAL object count N from the configured table
|
||||||
* (0 = barren, then 2/3/4/5 at the configured weights — the
|
* (0 = barren, then 2/3/4/5 at the configured weights — the
|
||||||
* distribution is on the FINAL count, by design);
|
* distribution is on the FINAL count, by design);
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,18 @@ export class Tether {
|
||||||
return l1 * Math.pow(g, n - 1);
|
return l1 * Math.pow(g, n - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Is the point inside this tether's zone (center distance ≤ radius)? */
|
/**
|
||||||
|
* Is the point inside this tether's zone (center distance ≤ radius)?
|
||||||
|
* A 1 px rim tolerance keeps an object whose center sits exactly ON the
|
||||||
|
* boundary counted as inside — the scan edge case (the layout keeps
|
||||||
|
* objects off the rims by edgeMargin, data/tether.json — but floats
|
||||||
|
* don't know that).
|
||||||
|
*/
|
||||||
static contains(t, x, y) {
|
static contains(t, x, y) {
|
||||||
const dx = x - t.x;
|
const dx = x - t.x;
|
||||||
const dy = y - t.y;
|
const dy = y - t.y;
|
||||||
return dx * dx + dy * dy <= t.radius * t.radius;
|
const r = t.radius + 1; // rim tolerance (px)
|
||||||
|
return dx * dx + dy * dy <= r * r;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Union membership: the ship is allowed wherever ANY tether reaches. */
|
/** Union membership: the ship is allowed wherever ANY tether reaches. */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue