diff --git a/dev/jumps.test.mjs b/dev/jumps.test.mjs index 7b3b05a..2dbcce8 100644 --- a/dev/jumps.test.mjs +++ b/dev/jumps.test.mjs @@ -21,9 +21,14 @@ * - 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 - * (soft rule — within 90° of the system→star bearing from the - * anchoring object); stations are NOT anchors (the player must be - * able to build out from a world), but they DO get clearance; + * (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 + * system→star bearing; when the only anchor sits farther than the + * tether opposite the destination — a single far planet — the gate + * takes the best aimed point instead); stations are NOT anchors (the + * player must be able to build out from a world), but they DO get + * clearance; * - every non-barren system holds at least ONE planet (the gate's * anchor is guaranteed to exist); * - BARREN systems (objectCount → 0 — the network's dead-end leaves): @@ -243,9 +248,21 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P tether++; if (!tWhy) tWhy = `${sys.id}: gate ${j.id} is ${Math.round(bestD)} px from its nearest anchor (> ${TETHER})`; } - // FACING (soft): from the anchoring object, the gate is on the - // target side — within 90° of the system→star bearing (some anchor - // must satisfy BOTH the tether and the facing). + // FACING (soft, from the CENTER): the gate sits on the DESTINATION + // side of the system center — dot(gate, destDir) > 0 — whenever + // geometry allows it: some anchor's tether circle reaches that side + // (maxProj + tether > 0). When every anchor sits farther than the + // tether opposite the destination (a single far planet), the gate + // 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) { + facing++; + if (!fWhy) fWhy = `${sys.id}: gate ${j.id} faces AWAY from its destination (wrong side of the center)`; + } + // FACING (soft, from the anchor): from the anchoring object, the + // gate is on the target side — within 90° of the system→star + // bearing (some anchor must satisfy BOTH the tether and the facing). let onSide = false; for (const a of anchors) { const d = Math.hypot(j.x - a.x, j.y - a.y); diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js index 6a27862..7e81f44 100644 --- a/js/galaxy/SystemGenerator.js +++ b/js/galaxy/SystemGenerator.js @@ -458,7 +458,13 @@ function bestRotation(place, targetAngles) { * 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). Anchors are PLANETS + * never exceeds 90° (in practice a few degrees). 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 + * intersection behind the center loses to any destination-side + * aimed/scan point. + * 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 * gate must be tether-reachable from a planet the player can build @@ -575,25 +581,32 @@ 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 is exactly `range` from an anchor, so the level-N - // tether rule (hard) is met by construction; the direction rule (soft) - // is honored by the tier order: on-ray → aimed → near-aimed. + // 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. 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)); - cands.push({ tier: 1, order: h * 1e6 + ai * 1000, px: (proj + off) * ux, py: (proj + off) * uy }); + add(1, h * 1e6 + ai * 1000, (proj + off) * ux, (proj + off) * uy); } - cands.push({ tier: 2, order: h * 1e6 + ai * 1000, px: a.x + range * ux, py: a.y + range * 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); - cands.push({ tier: 3, order: Math.abs(phi) * 1e6 + h + ai * 1e-3, px: a.x + range * dx, py: a.y + range * dy }); + add(3, Math.abs(phi) * 1e6 + h + ai * 1e-3, a.x + range * dx, a.y + range * dy); } }); - cands.sort((p, q) => p.tier - q.tier || p.order - q.order); + cands.sort((p, q) => p.side - q.side || p.tier - q.tier || p.order - q.order); const ok = (c) => { const d2c = c.px * c.px + c.py * c.py; @@ -622,9 +635,12 @@ 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 - // the first anchor's aimed point anyway — the tether and facing rules - // outrank cosmetics. - chosen = cands.find((c) => c.tier === 2) ?? cands[0]; + // the first DESTINATION-SIDE aimed point anyway — the tether and + // facing rules outrank cosmetics. + chosen = + cands.find((c) => c.tier === 2 && c.side === 0) ?? + cands.find((c) => c.tier === 2) ?? + cands[0]; console.warn( `[orbit] ${record.id}: gate ${i + 1} fell back to its first aimed candidate (clearance)`, );