Place anchored jump gates on a 3500–4990 px band around their anchor

- Replace the fixed level-1 tether radius (5120 px) with a configurable
  `anchorMinDistance`/`anchorMaxDistance` band (default 3500–4990), capped by
  the tether range, so gate distance from its planet varies per gate while
  staying strictly < 5000 px.
- Generate candidate positions on 8 discrete radius steps spanning the band;
  a seeded preferred step orders them and clearance/gap pressure only pushes
  the gate outward from its preference.
- Update the tether check in `dev/jumps.test.mjs` to assert the gate sits
  within the anchor band rather than merely under the tether radius, and use
  the band max for facing-side calculations.
- Update `dev/tether-rules.audit.mjs` Rule 1 to verify < 5000 px and report
  the distribution of gate→anchor distances across the band.
- Refresh comments in `data/gates.json`, `docs/PROJECT_NOTES.md`, and
  `SystemGenerator.js` to describe the new band-based placement.
This commit is contained in:
Brian Fertig 2026-09-10 18:37:22 -06:00
parent b1b6c18fee
commit a8246bb594
5 changed files with 127 additions and 61 deletions

View File

@ -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 within `anchorTetherLevel` tether range of an ANCHOR — a PLANET, or the home world in the starting system — EXACTLY that range from it (the radius comes from data/tether.json — level 1 = 5120 px), so the player can reach it on a starting tether from that world; the tether rule is hard by construction. 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–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).",
"enabled": true,
"minGates": 1,
"maxGates": 3,
@ -15,6 +15,8 @@
"minRadius": 2048,
"maxRadius": 20480,
"anchorTetherLevel": 1,
"anchorMinDistance": 3500,
"anchorMaxDistance": 4990,
"barrenDistance": 8192,
"typeLabel": "Jump Gate",
"theme": { "color": "#5fd4ff" },

View File

@ -19,8 +19,10 @@
* the IN-SYSTEM PLACEMENT (js/galaxy/SystemGenerator.js → layoutGates):
* - content.jumps matches the network (count + destinations);
* - ANCHORED systems (a planet — or the home world in the starting
* system): every gate is within level-1 tether (tether.level1Radius)
* of a planet anchor and FACES its destination star on the 2-D map
* system): every gate sits in the anchorMinDistance–anchorMaxDistance
* band (default 3500–4990 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
* center whenever some anchor can reach that side (maxProj + tether
* > 0), and from the anchoring object it is within 90° of the
@ -82,6 +84,13 @@ const MIN_GATES = GATES.minGates ?? 1;
const MAX_GATES = GATES.maxGates ?? 3;
const POOL = Math.max(1, GATES.neighborPool ?? 8);
const TETHER = config.get('tether.level1Radius', 5120);
// 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
// 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_MIN = Math.min(ANCHOR, config.get('gates.anchorMinDistance', 3500));
const SIZE = GATES.size ?? 96;
const CLEAR = GATES.clearance ?? 256;
const GAP = GATES.gateGap ?? 192;
@ -257,12 +266,20 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
if (!bWhy) bWhy = `${sys.id}: barren gate ${j.id} at ${Math.round(d0)} px, ${(dev * 57.3).toFixed(1)}° off the target ray`;
}
} else {
// TETHER (hard): within level-1 range of some anchor.
// TETHER (hard): the gate's own anchor sits in the
// anchorMinDistance..anchorMaxDistance band (default 3500–4990 —
// strictly < 5000). A DIFFERENT anchor may be closer (or farther)
// — the band is about the anchoring planet.
let inBand = false;
let bestD = Infinity;
for (const a of anchors) bestD = Math.min(bestD, Math.hypot(j.x - a.x, j.y - a.y));
if (bestD > TETHER + 1e-6) {
for (const a of anchors) {
const d = Math.hypot(j.x - a.x, j.y - a.y);
if (d < bestD) bestD = d;
if (d >= ANCHOR_MIN - 1e-6 && d <= ANCHOR + 1e-6) inBand = true;
}
if (!inBand) {
tether++;
if (!tWhy) tWhy = `${sys.id}: gate ${j.id} is ${Math.round(bestD)} px from its nearest anchor (> ${TETHER})`;
if (!tWhy) tWhy = `${sys.id}: gate ${j.id} is ${Math.round(bestD)} px from its nearest anchor (anchor band ${ANCHOR_MIN}–${ANCHOR}, must be < 5000)`;
}
// FACING (soft, from the CENTER): the gate sits on the DESTINATION
// side of the system center — dot(gate, destDir) > 0 — whenever
@ -272,7 +289,7 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
// takes the best aimed point — the tether rule (hard) outranks the
// facing rule (soft).
const maxProj = Math.max(...anchors.map((a) => a.x * Math.cos(th) + a.y * Math.sin(th)));
if (maxProj + TETHER > 0 && j.x * Math.cos(th) + j.y * Math.sin(th) <= 1e-6) {
if (maxProj + ANCHOR > 0 && j.x * Math.cos(th) + j.y * Math.sin(th) <= 1e-6) {
facing++;
if (!fWhy) fWhy = `${sys.id}: gate ${j.id} faces AWAY from its destination (wrong side of the center)`;
}
@ -282,7 +299,7 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
let onSide = false;
for (const a of anchors) {
const d = Math.hypot(j.x - a.x, j.y - a.y);
if (d > TETHER + 1e-6) continue;
if (d > ANCHOR + 1e-6) continue;
if (Math.abs(norm(Math.atan2(j.y - a.y, j.x - a.x) - th)) < Math.PI / 2) onSide = true;
}
if (!onSide) {
@ -325,7 +342,7 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
}
}
check('content.jumps matches the gate network (count, destinations, order)', !netMismatch, mWhy);
check(`tether (hard): every anchored-system gate ≤ ${TETHER} px from a planet anchor`, tether === 0, tWhy);
check(`tether (hard): every anchored-system gate sits ${ANCHOR_MIN}–${ANCHOR} px from a planet anchor (< 5000)`, tether === 0, tWhy);
check('facing (soft): every anchored-system gate is on the target side of its anchor (< 90°)', facing === 0, fWhy);
check(`barren gates sit on the target ray, in the band ${MIN_R}..${MAX_R}, facing it`, barrenBad === 0, bWhy);
check(`radius band: every gate ${MIN_R}..${MAX_R} px from the center`, radius === 0, rWhy);
@ -461,11 +478,11 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
for (const [ax, ay] of anchors) {
const d = Math.hypot(j.x - ax, j.y - ay);
const a = Math.abs(norm(Math.atan2(j.y - ay, j.x - ax) - th));
if (d <= TETHER + 1e-6 && a < Math.PI / 2) ok = true;
if (d >= ANCHOR_MIN - 1e-6 && d <= ANCHOR + 1e-6 && a < Math.PI / 2) ok = true;
}
if (!ok) homeOk = false;
}
check('home gates sit within level-1 tether of a home anchor, facing their star', homeOk);
check(`home gates sit ${ANCHOR_MIN}–${ANCHOR} px from a home anchor (< 5000), facing their star`, homeOk);
}
console.log(failures === 0 ? '\nAll jump-gate tests passed ✔' : `\n${failures} test(s) FAILED ✘`);

View File

@ -1,8 +1,9 @@
/**
* One-off audit: do generated galaxies obey the two tether rules?
* Rule 1: every jump gate within 1 tether region (level1Radius) of a
* planet or space station — unless the system is a dead end
* (a barren system: no planets/stations, single gate).
* 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.
@ -34,6 +35,7 @@ for (const seed of ['audit-a', 'audit-b', 'audit-c']) {
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]
for (const rec of g.records) {
const c = g.contentOf(rec.id);
@ -42,16 +44,25 @@ for (const seed of ['audit-a', 'audit-b', 'audit-c']) {
.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 isBarren = objects.length === 0;
// Rule 1
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)
for (const j of c.jumps) {
r1Checked++;
if (isBarren) continue;
let best = Infinity;
for (const o of objects) best = Math.min(best, Math.hypot(j.x - o.x, j.y - o.y));
if (best > T1 + EPS) {
if (best > 5000 + 1e-6) {
r1Bad++;
if (r1Examples.length < 3) r1Examples.push(`${rec.id} gate→${j.to} d=${Math.round(best)}`);
}
let aBand = -1;
for (const a of anchorSet) {
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 (aBand >= 0) anchorDs.push(aBand); // the gate's anchoring planet
}
// Rule 2
if (objects.length >= 2) {
@ -69,7 +80,10 @@ for (const seed of ['audit-a', 'audit-b', 'audit-c']) {
}
}
console.log(`\nseed="${seed}" systems=${g.records.length} (tether L1=${T1}px, 2×L1=${T2}px)`);
console.log(` Rule 1 (gates ≤ ${T1}px from a planet/station, barren exempt): ${r1Checked - r1Bad}/${r1Checked} OK${r1Bad ? ' — BAD: ' + r1Examples.join(', ') : ''}`);
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(', ');
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}`);
}

View File

@ -304,11 +304,13 @@ exit toward a NEARBY star on the 2-D map. Two layers:
attach → swap → last-resort attach, logged) covers pathological pools,
preferring a working network over the cap.
- **The in-system placement** — `SystemGenerator.layoutGates(...)` (pure)
places each gate on the tether circle of one of the system's ANCHORS
(a PLANET — free-space stations are deliberately not anchors, since the
build console lives on a world — or the home world in the starting
system), exactly `anchorTetherLevel` (level 1 = 5120 px, from
`data/tether.json`) from it — so the tether rule is hard by
places each gate on one of the radius steps spanning
`anchorMinDistance`–`anchorMaxDistance` (default 3500–4990 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

View File

@ -77,10 +77,13 @@ const DEG = Math.PI / 180;
* 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 within level-1 tether (5120 px)
* of an anchor — 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
* the starting system) hosts its gates 3500–4990 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
* 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
* system (no planets — a dead-end leaf of the tree) hosts its single gate
* on the ray toward the destination, `barrenDistance` from the center.
* Every gate record carries `active` (default false — the activation
@ -506,15 +509,20 @@ function bestRotation(place, targetAngles) {
* computed in the 2-D map plane, so "an upper-right gate" means "a
* star to the upper right on the map";
* - ANCHORED systems (a planet — or the home world in the starting
* system): within level-`anchorTetherLevel` tether (5120 px for level
* 1) of an anchor — on the anchor's tether circle, chosen in order of
* facing quality: (1) the far ray-circle intersection (the gate
* exactly on the target ray — system center, gate, and destination
* star collinear), (2) the point of the circle aimed exactly at the
* target star, (3) a forward-hemisphere scan of the circle (±75°).
* Every candidate is exactly `range` from its anchor, so
* tether-reachability holds by construction and the facing deviation
* never exceeds 90° (in practice a few degrees). Candidates on the
* 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
* 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
* of the circle aimed exactly at the target star, (3) a
* forward-hemisphere scan of the circle (±75°). Every candidate sits
* EXACTLY on one of the step circles, so BOTH the tether rule and the
* < 5000 px rule hold by construction, and the facing deviation never
* exceeds 90° (in practice a few degrees). The per-gate PREFERRED
* step (a dedicated seeded fork) orders the steps — clearance/gap
* pressure only ever pushes the gate outward from its preference. Candidates on the
* DESTINATION SIDE of the system center sort first — before every
* 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
@ -559,7 +567,16 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
const gap = Math.max(0, g.gateGap ?? 192);
const minR = Math.max(0, g.minRadius ?? 2048);
const maxR = Math.max(minR, g.maxRadius ?? 20480);
const range = anchorTetherRange(g.anchorTetherLevel ?? 1);
// 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
// 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 rangeMin = Math.min(rangeMax, Math.max(1, g.anchorMinDistance ?? 3500));
// Anchors: the PLANETS and (home only) the home world at the origin —
// the bodies a gate's tether may hang from. Free-space stations are
@ -629,38 +646,52 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
// gate exactly ON the target ray (system center, gate, and
// star collinear), outside the anchor. Always faces the
// target (≤ 90° from the anchor's point of view).
// tier 2 — for every anchor, the point on the anchor's tether circle
// that faces the target: a + range·u — exactly `range` from
// the anchor (tether-reachable) and aimed exactly at the
// star (zero deviation from the anchor's point of view).
// tier 2 — for every anchor, the point on the anchor's circle that
// faces the target: a + r·u — exactly `r` from the anchor
// (tether-reachable) and aimed exactly at the star (zero
// deviation from the anchor's point of view).
// tier 3 — a forward-hemisphere scan around each anchor's circle
// (32 points, ± up to 75° from the target direction) — the
// clearance search for tight systems.
// Every candidate is exactly `range` from an anchor, so the level-N
// tether rule (hard) is met by construction. The 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.
// Every candidate sits EXACTLY `r` from its anchor, `r` one of the
// RADIUS STEPS spanning [rangeMin, rangeMax] (default 3500–4990 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.
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);
const cands = [];
const add = (tier, order, px, py) =>
cands.push({ tier, order, side: px * ux + py * uy > 0 ? 0 : 1, 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
if (h <= range) {
const off = Math.sqrt(Math.max(0, range * range - h * h));
add(1, h * 1e6 + ai * 1000, (proj + off) * ux, (proj + off) * uy);
}
add(2, h * 1e6 + ai * 1000, a.x + range * ux, a.y + range * uy);
for (let k = 0; k < 32; k++) {
const phi = -1.3089 + (2.6179 * k) / 31; // ±75° around the target direction
const dx = ux * Math.cos(phi) - uy * Math.sin(phi);
const dy = ux * Math.sin(phi) + uy * Math.cos(phi);
add(3, Math.abs(phi) * 1e6 + h + ai * 1e-3, a.x + range * dx, a.y + range * dy);
}
radii.forEach((r, s) => {
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(2, h * 1e6 + ai * 1000 + rad * 10, a.x + r * ux, a.y + r * uy);
for (let k = 0; k < 32; k++) {
const phi = -1.3089 + (2.6179 * k) / 31; // ±75° around the target direction
const dx = ux * Math.cos(phi) - uy * Math.sin(phi);
const dy = ux * Math.sin(phi) + uy * Math.cos(phi);
add(3, Math.abs(phi) * 1e6 + h + ai * 1e-3 + rad * 1e-4, a.x + r * dx, a.y + r * dy);
}
});
});
cands.sort((p, q) => p.side - q.side || p.tier - q.tier || p.order - q.order);
@ -690,7 +721,7 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
}
if (!chosen) {
// Every candidate failed clearance (nearly impossible — an anchor's
// tether circle is 5120 px across, the discs under a thousand): take
// circles span 3500–4990 px, the discs under a thousand): take
// the first DESTINATION-SIDE aimed point anyway — the tether and
// facing rules outrank cosmetics.
chosen =