diff --git a/data/gates.json b/data/gates.json index 0689aaa..d987f6c 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 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" }, diff --git a/dev/jumps.test.mjs b/dev/jumps.test.mjs index 4eab8c3..4755464 100644 --- a/dev/jumps.test.mjs +++ b/dev/jumps.test.mjs @@ -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 ✘`); diff --git a/dev/tether-rules.audit.mjs b/dev/tether-rules.audit.mjs index 8ac01b7..5cfdfb5 100644 --- a/dev/tether-rules.audit.mjs +++ b/dev/tether-rules.audit.mjs @@ -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}`); } diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 75ca214..7fe4ea5 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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 diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js index c9fecb1..cf3bc6e 100644 --- a/js/galaxy/SystemGenerator.js +++ b/js/galaxy/SystemGenerator.js @@ -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 =