From 54fbf4091cdfd8e12b7edcca21236b1dbc4dfa2e Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Thu, 10 Sep 2026 18:53:30 -0600 Subject: [PATCH] Rebuild in-system layout around tether discovery + the edge margin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- data/asteroids.json | 2 +- data/gates.json | 4 +- data/planets.json | 5 +- data/tether.json | 3 +- dev/discovery.test.mjs | 106 +++++++--- dev/jumps.test.mjs | 8 +- dev/system-layout.test.mjs | 212 +++++++++++++++++++ dev/tether-rules.audit.mjs | 154 ++++++++++---- docs/PROJECT_NOTES.md | 52 +++-- js/galaxy/SystemGenerator.js | 384 ++++++++++++++++++++++++----------- js/tether/Tether.js | 11 +- 11 files changed, 722 insertions(+), 219 deletions(-) create mode 100644 dev/system-layout.test.mjs diff --git a/data/asteroids.json b/data/asteroids.json index 618445e..2f3c09e 100644 --- a/data/asteroids.json +++ b/data/asteroids.json @@ -113,7 +113,7 @@ "minObjectSpacing": 1024, "minRadius": 2048, "maxRadius": 18432, - "tetherMargin": 96, + "tetherMargin": 512, "maxPlacementAttempts": 400 } } diff --git a/data/gates.json b/data/gates.json index d987f6c..4a66586 100644 --- a/data/gates.json +++ b/data/gates.json @@ -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, "minGates": 1, "maxGates": 3, @@ -16,7 +16,7 @@ "maxRadius": 20480, "anchorTetherLevel": 1, "anchorMinDistance": 3500, - "anchorMaxDistance": 4990, + "anchorMaxDistance": 4608, "barrenDistance": 8192, "typeLabel": "Jump Gate", "theme": { "color": "#5fd4ff" }, diff --git a/data/planets.json b/data/planets.json index 34961a8..89f979a 100644 --- a/data/planets.json +++ b/data/planets.json @@ -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", "frameWidth": 1024, "frameHeight": 1024, @@ -53,6 +53,7 @@ "enabled": true, "minSpacing": 6400, "maxSpacing": 15360, - "homeMaxSpacing": 10240 + "homeMaxSpacing": 10240, + "neighborMin": 2048 } } diff --git a/data/tether.json b/data/tether.json index c565dee..48c16f6 100644 --- a/data/tether.json +++ b/data/tether.json @@ -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, "radiusGrowth": 2.0, "maxLevel": 3, + "edgeMargin": 512, "homeId": "home", "homeLevel": 1, "homeLabel": null, diff --git a/dev/discovery.test.mjs b/dev/discovery.test.mjs index 0afe73b..c4d3b97 100644 --- a/dev/discovery.test.mjs +++ b/dev/discovery.test.mjs @@ -7,17 +7,19 @@ * - the DISCOVERY RULE (data/game.json → discovery.distance): within that * many px of an object's EDGE ⇒ discovered, exactly once, per system; * state round-trips through toJSON/fromJSON (saves-ready); - * - the SOLAR SYSTEM LAYOUT (data/planets.json → solarSystem): every - * pair of layout objects — the system's planets + free-space - * stations + the central body (the origin's home world, or the star - * elsewhere) — sits in the band [minSpacing, maxSpacing] px - * center-to-center (the home system's band tightens to - * [minSpacing, homeMaxSpacing] and it holds ≤ 3 objects — 5 points - * cannot sit 6400..10240 px apart), planets scaled by class, the - * OBJECT COMPOSITION holding (data/systems.json → objectCount: every - * non-home system 0/2/3/4/5 objects, in the configured proportions) - * — deterministically (same seed ⇒ same layout, different seed ⇒ - * different); + * - the SOLAR SYSTEM LAYOUT (data/planets.json → solarSystem + + * data/tether.json): the objects of a system (planets + free-space + * stations + the home world in the starting system) sit on a compact + * arc — orbital radius in [minSpacing, maxSpacing] (home ≤ + * level-2 − edgeMargin), consecutive objects a level-1 tether apart + * (≤ L1 − edgeMargin — a level-1 chain discovers the whole system), + * no anchorable pair in a tether rim band (± edgeMargin — the scan + * edge case), the level-2 graph connected — planets scaled by class, + * the OBJECT COMPOSITION holding (data/systems.json → objectCount: + * every non-home system 0/2/3/4/5 objects, in the configured + * 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 screen-edge rect (edges AND corners), circleInView is exact, * lerpAngle always takes the short arc; @@ -134,6 +136,11 @@ const band = config.get('planets.solarSystem'); const MIN_SEP = band.minSpacing; const MAX_SP = band.maxSpacing; // normal systems: the band's 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'); let layoutOk = true; @@ -145,7 +152,7 @@ const probe = (systems) => { for (const rec of systems) { const c = g.ensureContent(rec.id); const isHome = rec.id === g.currentSystemId; - const MAX = isHome ? HOME_MAX : MAX_SP; + const MAX = isHome ? Math.min(HOME_MAX, L2in) : MAX_SP; for (const p of c.planets) { if (!Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.scale)) { layoutOk = false; @@ -183,23 +190,74 @@ const probe = (systems) => { 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 - // most one free-space station) — 5 points cannot sit 6400..10240 px - // apart (the tightest 5-point spacing needs ratio ≥ φ > 1.6). + // most one free-space station) — by design, the home system stays a + // small start the player expands out of. if (isHome) { if (nObjects > 3 || c.planets.length !== 2) { homeOk = false; if (layoutOk) layoutWhy = `${rec.id}: home system holds ${nObjects} objects`; } } - // The SOLAR SYSTEM BAND: every pair of layout objects — incl. the - // central body — sits in [minSpacing, the system's maximum], - // center to center. - for (let i = 0; i < objs.length && layoutOk; i++) { - for (let j = i + 1; j < objs.length; j++) { - const d = Math.hypot(objs[i].x - objs[j].x, objs[i].y - objs[j].y); - if (d < MIN_SEP - 1e-6 || d > MAX + 1e-6) { - layoutOk = false; - layoutWhy = `${rec.id}: a pair ${Math.round(d)} px outside the band [${MIN_SEP}, ${MAX}]`; + // THE LAYOUT RULES (data/tether.json + data/planets.json → + // solarSystem): + // (a) orbital band — every object in [minSpacing, MAX] from the + // center (MAX = level-2 − edgeMargin for home — the home world + // IS an anchor; the star of a normal system is not); + for (let i = 1; i < objs.length && layoutOk; i++) { + const d = Math.hypot(objs[i].x, objs[i].y); + if (d < MIN_SEP - 1e-6 || d > MAX + 1e-6) { + 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); const nProbe = g.records.length; 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, ); check(`layout: no system exceeds the 5-object maximum (3 planets + 2 stations) — max in galaxy: ${maxObjectsSeen}`, maxObjectsSeen <= 5); diff --git a/dev/jumps.test.mjs b/dev/jumps.test.mjs index 4755464..1d0d124 100644 --- a/dev/jumps.test.mjs +++ b/dev/jumps.test.mjs @@ -20,7 +20,7 @@ * - content.jumps matches the network (count + destinations); * - ANCHORED systems (a planet — or the home world in the starting * 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 * 2-D map * (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); // The anchored-gate design rule (data/gates.json → anchorMinDistance / // 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 // 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 SIZE = GATES.size ?? 96; 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 { // 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) // — the band is about the anchoring planet. let inBand = false; diff --git a/dev/system-layout.test.mjs b/dev/system-layout.test.mjs new file mode 100644 index 0000000..c4d0e4d --- /dev/null +++ b/dev/system-layout.test.mjs @@ -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); diff --git a/dev/tether-rules.audit.mjs b/dev/tether-rules.audit.mjs index 5cfdfb5..77a2996 100644 --- a/dev/tether-rules.audit.mjs +++ b/dev/tether-rules.audit.mjs @@ -1,12 +1,29 @@ /** - * One-off audit: do generated galaxies obey the two tether rules? - * Rule 1: every jump gate strictly < 5000 px from a planet or space - * station (its ANCHOR sits in the 3500–4990 px placement band) - * — unless the system is a dead end (a barren system: no - * planets/stations, single gate). - * Rule 2: every planet & space station within 2 tether lengths - * (2 × level1Radius) of at least one other planet/station — - * unless the system holds exactly one such object. + * Audit: do generated galaxies obey the tether-discovery design rules? + * Rule 1 (gates): every jump gate strictly < 5000 px from a planet or + * space station — its ANCHOR sits in the 3500–4608 px + * placement band (data/gates.json → anchorMinDistance / + * anchorMaxDistance — inside the level-1 tether with the + * edgeMargin), the distance varying per gate — unless the + * system is a dead end (a barren system: no planets/stations, + * 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'; 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 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 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']) { const g = Galaxy.create(seed); g.generateAll(); let r1Bad = 0, r1Checked = 0; - let r2Bad = 0, r2Checked = 0; const r1Examples = []; 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) { 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 objects = [...c.planets.map((p) => ({ x: p.x, y: p.y }))] - .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 }); // the home world + const objects = [...planets, ...space]; + if (isHome) objects.push({ x: 0, y: 0, name: 'home' }); // the home world (a world — a valid Rule-1 solid) const isBarren = objects.length === 0; - const anchorSet = isHome - ? [...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) + // Rule 1 (gates < 5000 px from a planet/station; anchor band 3500–4608; barren exempt) for (const j of c.jumps) { r1Checked++; 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)}`); } 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); - 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 - } - // Rule 2 - if (objects.length >= 2) { - for (const o of objects) { - r2Checked++; - let best = Infinity; - for (const q of objects) { - if (q === o) continue; - best = Math.min(best, Math.hypot(o.x - q.x, o.y - q.y)); + // Rim band against the OTHER solids (the anchor's own distance is + // on the step band (≤ L1in — clearly inside, by construction), so + // it can never count here). + const solids = [...planets, ...space]; + if (isHome) solids.push({ x: 0, y: 0 }); + for (const s of solids) { + const d = Math.hypot(j.x - s.x, j.y - s.y); + if (inRim(d)) { + 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(` Rule 1 (gates < 5000px from a planet/station, anchor band 3500–4990, 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(`\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 ${ANCHOR_MIN}–${ANCHOR_MAX}, barren exempt): ${r1Checked - r1Bad}/${r1Checked} OK${r1Bad ? ' — BAD: ' + r1Examples.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 distinct = new Set(anchorSorted.map((d) => Math.round(d / 50))).size; - console.log(` gate→anchor distances: min=${Math.round(anchorSorted[0])} max=${Math.round(anchorSorted[anchorSorted.length - 1])}, ~${distinct} distinct radii (band 3500–4990)`); - console.log(` Rule 2 (every object ≤ ${T2}px from another, N=1 exempt): ${r2Checked - r2Bad}/${r2Checked} OK — ${byN}`); + if (anchorSorted.length) { + const distinct = new Set(anchorSorted.map((d) => Math.round(d / 50))).size; + 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}`); } diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 7fe4ea5..d5c64da 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -232,22 +232,28 @@ world** — solid, rendered, flyable-to. Rules and seams: - **World layout** — `SystemGenerator.layoutSystem(seed, systemId, planets, freeSpace, isHome, targetAngles)` (pure, `js/galaxy/SystemGenerator.js`) - places each system's planets and free-space stations on a ring (a - regular polygon) around the system center (the home world at the - origin in the starting system; every other system's center is EMPTY — - its star is invisible flavor, never rendered), enforcing the SOLAR - SYSTEM BAND in `data/planets.json → solarSystem`: EVERY pair of layout - objects — planets, stations (and the home world, starting system - only) — sits center-to-center in `[minSpacing, maxSpacing]` (6400–15360 px; the starting system's - band tightens to `[minSpacing, homeMaxSpacing]` = 6400–10240 px). The - ring radius is chosen inside the band (non-home: `maxSpacing` over the - chord of the N-gon; home: the (N+1)-gon side over the band's tight - ratio), and the rotation (ring phase) is a deterministic scan that - serves the system's gate target bearings. The starting system is capped - at 3 objects (2 planets + ≤ 1 station) — five points cannot sit - 6400–10240 px apart (the tightest 5-point spacing needs ratio ≥ φ > - 1.6). Planet-bound settlements (colonies, mining stations, cloud bases) - are features of their planet, not layout objects. + places each system's planets and free-space stations (the home world at + the 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 + orbital radius, built around TETHER DISCOVERY (data/tether.json): + CONSECUTIVE objects sit a level-1 tether apart (≤ 5120 − edgeMargin — a + level-1 tether from any one of them always discovers a neighbor, so a + chain of level-1 tethers walks the whole system), every other pair sits + clearly inside or outside level 2 (the level-2 graph is connected — level + 2 DEFINITELY discovers while undiscovered objects remain), and NO + anchorable pair (planets, stations, home world) sits in the rim band + around a tether boundary (± edgeMargin — data/tether.json → + `edgeMargin`, 512 px — objects are never at the edge of a tether region, + where the scan misses them). Level 3 is never required for discovery. + The orbital radius (center-to-object) sits in + `[minSpacing, maxSpacing]` (6400–15360 px; home additionally ≤ + 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, 'layout')` — layout results are seed-deterministic *and* don't perturb 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. - **The in-system placement** — `SystemGenerator.layoutGates(...)` (pure) 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, `data/tether.json`) and strictly < 5000 px) around one of the system's ANCHORS (a PLANET — free-space stations are deliberately not anchors, since the build console lives on a world — or the home world in the starting system) — so the tether rule is hard by - construction. Candidates are tried in facing quality: the ray-circle - intersection (gate exactly on the system→star ray), the circle point - aimed at the star, then a ±75° scan — so the direction rule is soft - (same side of the anchor as the target, deviation < 90°; in practice - ~98% are within 75°). Gates stay `minRadius..maxRadius` (2048–20480 + construction. Candidates are tried in this order: same side of the + anchor as the target FIRST, then OUTSIDE the rim bands (± edgeMargin — + a gate must never sit at the edge of ANOTHER object's tether either), + then the facing tier — the ray-circle intersection (gate exactly on + the system→star ray), the circle point aimed at the star, then a ±75° + scan — so the direction rule is soft (same side of the anchor as the + target, deviation < 90°; in practice ~98% are within 75°). Gates stay `minRadius..maxRadius` (2048–20480 px) from the system center, keep `size` + `clearance` from solid discs (planets, free-space stations, the home world) and 2·`size` + `gateGap` from each other, and get unique names ("Avidy diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js index cf3bc6e..fbe9459 100644 --- a/js/galaxy/SystemGenerator.js +++ b/js/galaxy/SystemGenerator.js @@ -51,36 +51,40 @@ const DEG = Math.PI / 180; * The STARTING system is special: the player's home world sits at the * origin (not a generated planet, fixed key 'home'), and the system always * holds exactly two more planets — a gas giant and a rocky world. With the - * home world, three planets, always. And it is capped at ONE free-space - * station: five objects (the home world + four) cannot sit 6400..10240 px - * apart — the tightest 5-point spacing needs a max/min ratio ≥ φ ≈ 1.618, - * which the home band 10240/6400 = 1.6 cannot give — so the home system - * stays a ≤ 4-object configuration (see layoutSystem). Its TYPE is also + * home world, three planets, always. It is capped at ONE free-space + * station by design — the home system stays a small start the player + * expands out of (the arc layout could seat more; the cap keeps the + * starting system readable — see layoutSystem + generateSettlements). + * Its TYPE is also * fixed: always 'main' (Main Sequence — the player's home is an ordinary * star; Galaxy.js forces it at roster build, data/galaxy.json → * startingSystem.type), so its star class rolls from the main-sequence * table (G/K/M). * - * LAYOUT (data/planets.json → solarSystem) — the SOLAR SYSTEM BAND: every - * PAIR of layout objects (planets and free-space stations — the central - * body, the home world, sits at the local origin of the STARTING system - * only; every other system has an EMPTY center — the star is invisible - * flavor, generated as `star` but never rendered) sits 6400..15360 px - * apart, center to center; in the home system the band tightens to - * 6400..10240 px. Normal systems lay out as a regular N-gon ring around - * the (empty) center; the home system as a regular (N+1)-polygon with the - * home world as one vertex. The rotation is chosen to serve the jump - * gates — objects bias toward the directions the system jumps (see - * layoutSystem + layoutGates). + * LAYOUT (data/planets.json → solarSystem) — built around TETHER + * DISCOVERY (data/tether.json): the layout objects (planets + free-space + * stations — the central body, the home world, sits at the local origin + * of the STARTING system only; every other system has an EMPTY center — + * the star is invisible flavor, generated as `star` but never rendered) + * sit on a COMPACT ARC at one orbital radius: consecutive objects a + * level-1 tether apart (≤ 5120 − edgeMargin — a level-1 tether from any + * one of them ALWAYS discovers a neighbor), every other pair clearly + * inside or outside level 2 — and NO object sits in the rim band around + * any tether boundary (± edgeMargin — the scan edge case). A chain of + * 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): * 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 * 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 - * VARIES per gate, always inside the level-1 tether (5120 px) and - * strictly < 5000 px) — planets ONLY (free-space stations are + * VARIES per gate, always CLEARLY inside the level-1 tether (≤ 5120 − + * 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 * gate must be tether-reachable from a planet the player can build out * 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 - * stations — under the SOLAR SYSTEM BAND (data/planets.json → - * solarSystem): + * stations (the "layout objects"; planet-bound settlements are features of + * 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, - * and the central body (the star, or the HOME world in the starting - * system — which sits at the local origin) — is at least `minSpacing` - * and at most the band's maximum apart, center to center: + * DISCOVERY IS TETHER-BASED (data/tether.json): level 1 = 5120 px, level + * 2 = 10240 px, level 3 = 20480 px from an anchor. The layout is built + * around the design rules: * - * normal systems : minSpacing..maxSpacing (6400..15360 px) - * home system : minSpacing..homeMaxSpacing (6400..10240 px) + * 1. A LEVEL-1 tether from any planet or station discovers a neighbor — + * 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 - * implied: ≤ 6 objects inside a 15360 px band keeps every object within - * level-2/3 tether of the others.) + * EDGE MARGIN (data/tether.json → edgeMargin, 512 px): NO anchorable + * object pair (planets, stations, home world) sits in the no-man's-land + * 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: - * N = 1, non-home — a single point at distance R ∈ [min, max]; its only - * pair (with the star) is just R. - * N ≥ 2, non-home — a REGULAR N-GON RING around the star: every pair is - * a chord, the longest being 2·R·sin(⌊N/2⌋·π/N), so - * R ∈ [min, max / (2·sin(⌊N/2⌋·π/N))]. - * home system — a REGULAR (N+1)-POLYGON with the home world as ONE - * VERTEX (the origin): every pair is a polygon chord, - * the longest ratio(N+1)·side, so side ∈ [min, - * homeMax / ratio(N+1)]. (The home world + 4 objects — - * 5 points — would need a max/min ratio ≥ φ ≈ 1.618, - * which the home band 10240/6400 = 1.6 cannot give; - * that is why the home system is capped at 3 objects: - * its 2 fixed planets + at most one free-space - * station — see generateSettlements.) + * THE SHAPE: the N objects sit on ONE orbit (radius r from the center) as + * a compact arc — consecutive objects a level-1 gap apart (chord + * 2·r·sin α, α the half-step) — replacing the old regular N-gon ring, + * which put 2- and 3-object systems past level 2 (chords + * 2·r·sin(π(N−1)/N)) and spread 4- and 5-object systems too thin. The + * step angle α is chosen deterministically (a seeded scan over the SAFE α + * — those where EVERY pair k apart, chord 2·r·|sin kα|, is level-1 for + * k = 1 and clearly outside/inside level 2 for k ≥ 2) to land the + * consecutive gap near a seeded target in [neighborMin, L1 − margin] — + * variety across systems, every rule hard by construction. + * + * Bands (data/planets.json → solarSystem): + * orbital radius r (center-to-object): + * normal systems : minSpacing..maxSpacing (6400..15360 px) + * 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 * settlements (anchor.type 'space' — deep-space stations, waypoints). * Planet-bound settlements are features OF their planet (a colony sits on * it) and so are not layout objects of their own. The central body is the * origin (the game renders the home world there in the starting system; - * elsewhere it is the star — the visual, but part of the spacing rule). + * elsewhere it is the star — the visual). * - * The ROTATION (ring phase / polygon orientation) is chosen to serve the - * jump gates: it minimizes the worst perpendicular distance between a - * gate's target ray and the nearest object, so layoutGates can sit each - * gate on the true target ray while staying tether-reachable. Without - * targets (gates disabled, one-system galaxy) it falls back to a seeded - * random rotation. + * The ROTATION (arc phase) is chosen to serve the jump gates: it + * minimizes the worst perpendicular distance between a gate's target ray + * and the nearest object, so layoutGates can sit each gate on the true + * target ray while staying tether-reachable. Without targets (gates + * disabled, one-system galaxy) it falls back to a seeded random rotation. * * Each planet also gains scale (size multiplier, data/planets.json → - * classScale — the band is center-to-center, but the rendered discs still - * scale by class). + * classScale — the distances are center-to-center, but the rendered discs + * 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 * (shape, target angles). Same seed ⇒ same layout, and lazy * (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 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) { 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; 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`. - let place; - if (isHome) { - // Regular (N+1)-gon, the home world (origin) as vertex 0: vertex k is - // Rc·(u(φ + 2πk/m) − u(φ)) — a chord of length 2·Rc·sin(πk/m). - const m = N + 1; - const sideMax = MAX / ratioOf(m); - if (sideMax < MIN) { - console.warn( - `[orbit] ${systemId}: ${N} objects cannot fit the home band [${MIN}, ${MAX}] px — using the minimum spacing`, - ); + const place = (phi) => { + const out = []; + for (let k = 0; k < N; k++) { + const a = phi + k * step; + out.push({ x: r * Math.cos(a), y: r * Math.sin(a) }); } - const side = lay.range(MIN, Math.max(MIN, sideMax)); - const Rc = side / (2 * Math.sin(Math.PI / m)); - place = (phi) => { - const out = []; - for (let k = 1; k < m; k++) { - out.push({ - x: Rc * (Math.cos(phi + (TAU * k) / m) - Math.cos(phi)), - y: Rc * (Math.sin(phi + (TAU * k) / m) - Math.sin(phi)), - }); - } - return out; - }; - } 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; - }; - } + return out; + }; const phi = targetAngles ? bestRotation(place, targetAngles) : lay.range(0, TAU); 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) { - if (m < 3) return 1; - const half = Math.floor(m / 2); - return Math.sin((half * Math.PI) / m) / Math.sin(Math.PI / m); +/** + * The tether discovery bands (data/tether.json): the level radii the + * player actually uses + the EDGE MARGIN around each rim — the + * no-man's-land bands where no object sits. L1in/L1out = the level-1 + * 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"; * - ANCHORED systems (a planet — or the home world in the starting * system): the gate sits BETWEEN `anchorMinDistance` and - * `anchorMaxDistance` from an anchor (default 3500–4990 px — the - * distance VARIES per gate, always inside the level-1 tether and - * strictly < 5000 px) — on one of the radius STEPS spanning that + * `anchorMaxDistance` from an anchor (default 3500–4608 px — the + * distance VARIES per gate, always CLEARLY inside the level-1 tether + * (≤ 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 * far ray-circle intersection (the gate exactly on the target ray — * 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 * the map reads) and is never placed on the far side; an on-ray * 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 * only (plus the home world at home) — free-space stations are * 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); // The anchor circles: the gate's distance from its anchor VARIES // across `anchorMinDistance`..`anchorMaxDistance` (data/gates.json — - // default 3500–4990 px: the design rule "strictly < 5000 px from a - // planet or space station", Rule 1), CAPPED by the anchor's + // default 3500–4608 px: the design rule "strictly < 5000 px from a + // 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 // it). Every candidate below sits exactly on one of these circles, so // both rules hold by construction. 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)); // 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 // clearance search for tight systems. // 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 - // inside the level-1 tether). The per-gate PREFERRED step (a - // dedicated seeded fork — deterministic, lazy === eager) orders the - // steps, and clearance/gap pressure pushes the gate outward from it - // (the radius is the LAST tie-break — facing quality always wins). - // The facing rule (soft — the gate faces its destination FROM THE - // SYSTEM CENTER) is honored by the sort: destination-side candidates - // (side 0) come FIRST — before every tier — so a gate is never - // placed on the far side of the center (an on-ray intersection - // behind the center loses to any destination-side aimed/scan point); - // then on-ray → aimed → near-aimed → preferred radius first. + // clearly inside the level-1 tether — off its rim). The per-gate + // PREFERRED step (a dedicated seeded fork — deterministic, lazy === + // eager) orders the steps, and clearance/gap pressure pushes the + // gate outward from it (the radius is the LAST tie-break — facing + // quality wins over it). The facing rule (soft — the gate faces its + // destination FROM THE SYSTEM CENTER) is honored by the sort: + // destination-side candidates (side 0) come FIRST — before every + // tier — so a gate is never placed on the far side of the center + // (an on-ray intersection behind the center loses to any + // 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 radii = Array.from( { length: STEPS }, (_, s) => rangeMin + ((rangeMax - rangeMin) * s) / (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 add = (tier, order, px, py) => - cands.push({ tier, order, side: px * ux + py * uy > 0 ? 0 : 1, px, py }); + const add = (tier, order, px, py, ai) => + cands.push({ tier, order, side: px * ux + py * uy > 0 ? 0 : 1, band: bandCount(px, py, ai), px, py }); anchors.forEach((a, ai) => { const proj = a.x * ux + a.y * uy; // signed distance along the ray const h = Math.abs(a.x * uy - a.y * ux); // perpendicular distance @@ -682,18 +817,20 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) { const rad = (s - radPref + STEPS) % STEPS; // 0 = preferred step if (h <= r) { 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++) { const phi = -1.3089 + (2.6179 * k) / 31; // ±75° around the target direction const dx = ux * Math.cos(phi) - uy * Math.sin(phi); const dy = ux * Math.sin(phi) + uy * Math.cos(phi); - 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 d2c = c.px * c.px + c.py * c.py; @@ -721,7 +858,7 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) { } if (!chosen) { // 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 // facing rules outrank cosmetics. chosen = @@ -1129,7 +1266,8 @@ function mixTint(hex, strength) { * * home system → fixed: 2 planets (gas giant + rocky) beside the home * 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 * (0 = barren, then 2/3/4/5 at the configured weights — the * distribution is on the FINAL count, by design); diff --git a/js/tether/Tether.js b/js/tether/Tether.js index bf2a461..25df0e5 100644 --- a/js/tether/Tether.js +++ b/js/tether/Tether.js @@ -45,11 +45,18 @@ export class Tether { 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) { const dx = x - t.x; 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. */