Redesign galaxy as sparse 60-system spanning-tree maze without rendered

- Shrink the roster from 200 to 60 systems and drop spiral arms / core bulge for a uniform-in-area sparse disk (data/galaxy.json)
- Turn the jump network into a pure spanning tree (shortcuts off): exactly one route between any two systems, barren systems become single-gate dead-end leaves
- Remove the rendered Star entity; non-home system centers are now empty space with the star kept only as invisible dossier flavor
- Gate anchors restricted to planets (stations no longer anchor) and every non-barren system guaranteed ≥1 planet so a buildable world always exists
- Give barren dead ends 1–2 asteroid clusters drifting inside their gate's level-1 tether as the stop's only payload
- Bump save format to 2 and reject legacy format-1 saves with a clear toast message
- Update tests, docs, and README to reflect the maze layout, leaf dead ends, empty centers, and new barren cluster rules
This commit is contained in:
Brian Fertig 2026-09-07 14:12:40 -06:00
parent 59ef952d5d
commit 963b2b5bc2
24 changed files with 646 additions and 782 deletions

View File

@ -36,10 +36,11 @@ node dev/server.mjs 8080
(click it and type), and rerollable — and the menu shows what that seed (click it and type), and rerollable — and the menu shows what that seed
builds (the galaxy's name, system count, archetype count) **before** you builds (the galaxy's name, system count, archetype count) **before** you
commit. Same seed ⇒ same galaxy. commit. Same seed ⇒ same galaxy.
- **Procedural galaxy**: 200 star systems in a seeded disk + core + - **Procedural galaxy**: 60 star systems in a sparse seeded disk — no
spiral arms (`data/galaxy.json`), typed into six themed archetypes spiral arms, no core bulge, uniform-in-area (`data/galaxy.json`), typed
(`data/systems.json`) with per-type distribution weights and radial into six themed archetypes (`data/systems.json`) with per-type
bands — the first "how does the galaxy lay itself out" rules. distribution weights and radial bands — the first "how does the galaxy
lay itself out" rules.
- **Two-level generation**: the whole galaxy roster is generated at New - **Two-level generation**: the whole galaxy roster is generated at New
Game (~15 ms); each system's planets/moons/belts/settlements are Game (~15 ms); each system's planets/moons/belts/settlements are
generated lazily on arrival, deterministically (seed + system id), so generated lazily on arrival, deterministically (seed + system id), so
@ -50,9 +51,15 @@ node dev/server.mjs 8080
adrift in open space and beacons, whose odds are per-archetype adrift in open space and beacons, whose odds are per-archetype
(`data/systems.json`) and thin out from the settled core to the wilder (`data/systems.json`) and thin out from the settled core to the wilder
rim (`data/galaxy.json`). **Barren systems** (~10%, the rim (`data/galaxy.json`). **Barren systems** (~10%, the
`objectCount` → 0 stops) hold nothing but their jump gate and report `objectCount` → 0 stops) are the DEAD-END LEAVES of the jump-gate maze:
*charted · unclaimed*. Non-home systems hold 0/2/3/4/5 objects they hold nothing but their single jump gate (and 12 asteroid
clusters drifting inside that gate's tether, their only payload) and
report *charted · unclaimed*; you enter and exit through the same
gate. Non-home systems hold 0/2/3/4/5 objects
(planets + free-space stations, `data/systems.json → objectCount`); (planets + free-space stations, `data/systems.json → objectCount`);
every non-barren system holds at least one planet, and the gate's
tether anchors on one of them (so the player can always build and
expand out from the gate);
the starting system always holds the home world, a gas giant, the starting system always holds the home world, a gas giant,
and a rocky world. Each settlement has a name, population, and an and a rocky world. Each settlement has a name, population, and an
`owner` seam reserved for the factions/pirates to come. `owner` seam reserved for the factions/pirates to come.
@ -71,10 +78,11 @@ node dev/server.mjs 8080
`assets/images/ships-player.png`, see `data/ship.json`): **click anywhere to fly there** `assets/images/ships-player.png`, see `data/ship.json`): **click anywhere to fly there**
in the current system's open space. Every system also holds 13 JUMP in the current system's open space. Every system also holds 13 JUMP
GATES — solid, discoverable exits that face their destination star — GATES — solid, discoverable exits that face their destination star —
wired into a strongly-connected gate network (data/gates.json); every wired into a PURE SPANNING TREE gate network (data/gates.json, no
gate is `active: false` for now — DORMANT (dimmed, field still) — and shortcuts, no closed loops: exactly one route between any two systems —
activation is the seam for the tether mechanic (an activated gate a maze of dead ends and long hauls); every gate is `active: false` for
anchors a level-1 tether); now — DORMANT (dimmed, field still) — and activation is the seam for
the tether mechanic (an activated gate anchors a level-1 tether);
the jump drive itself comes next. the jump drive itself comes next.
Discovered worlds get a screen-edge arrow + a name tag showing the Discovered worlds get a screen-edge arrow + a name tag showing the
world's **name** (e.g. `HOME WORLD · ESHKAELURA`); **clicking the world's **name** (e.g. `HOME WORLD · ESHKAELURA`); **clicking the
@ -153,10 +161,10 @@ orbit/
│ ├── ship.json # the ship: art, feel (thrust, drag, maxSpeed…), base stats (hull, shields, cargo, minerals) │ ├── ship.json # the ship: art, feel (thrust, drag, maxSpeed…), base stats (hull, shields, cargo, minerals)
│ ├── planets.json # home world + system layout + solid-disc rules │ ├── planets.json # home world + system layout + solid-disc rules
│ ├── tether.json # the tether (your range): level radii, barrier line, glitch, contact │ ├── tether.json # the tether (your range): level radii, barrier line, glitch, contact
│ ├── galaxy.json # galaxy scale & shape (count, radius, spiral…) │ ├── galaxy.json # galaxy scale & shape (count, radius, sparse disk…)
│ ├── systems.json # system archetypes: theme, attributes, distribution │ ├── systems.json # system archetypes: theme, attributes, distribution
│ ├── settlements.json # the lived-in layer: settlement kinds & populations │ ├── settlements.json # the lived-in layer: settlement kinds & populations
│ ├── gates.json # JUMP GATES: network (13 gates, local jumps, strong connectivity) + placement (tether anchor, facing, radii, gaps) │ ├── gates.json # JUMP GATES: network (13 gates, local jumps, pure spanning tree — maze, no shortcuts) + placement (tether anchor, facing, radii, gaps)
│ ├── research.json # RESEARCH: global rules (time unit, one at a time) + category registry │ ├── research.json # RESEARCH: global rules (time unit, one at a time) + category registry
│ │ # trees live one-per-file below (section name = file basename) │ │ # trees live one-per-file below (section name = file basename)
│ ├── research/ │ ├── research/

View File

@ -1,5 +1,5 @@
{ {
"_comment": "ASTEROID CLUSTERS — loose groups of slowly tumbling rocks drifting in the system's void. texture = spritesheet of frameWidth×frameHeight asteroid frames (frameCount frames). cluster = how a group looks & moves: groupSize = rocks per cluster (minFullSize guarantees at least one full-size rock), sizes = per-rock diameter px, spread = how far a rock may sit from the cluster center, gapFactor = the small edge-gap between ANY two rocks, as a multiple of their combined radii (1.0 = touching, 1.12 = a subtle gap — the generator pushes apart just the overlapping pairs, each by half, so groups stay compact), spin = each rock's OWN very slow tumble (deg/s, direction per-rock random), groupSpin = the whole loose group drifts around its center (deg/s), tint = subtle warm/cool starlight per cluster (anchors blended toward white by strength), halo = soft glow behind the group, debris = a halo of fine dust motes orbiting just outside the rocks. distribution = how many clusters a system gets: targetObjects planetCount, ±jitter, clamped to [minClusters, maxClusters] (more planets ⇒ fewer clusters); the starting system always gets at least startingSystemMinClusters, and those first ones are placed INSIDE the player's initial tether so they're reachable from the spawn. placement = where clusters may sit: minObjectSpacing = no cluster may be closer (center-to-center) than this to ANY other object (home world, planets, free-space stations, other clusters); minRadius/maxRadius = the annulus around the origin random clusters live in; tetherMargin = how far inside the tether rim a starting-system cluster must sit (whole group stays reachable). shipClearance = how close (edge-to-edge) the ship may get to a rock — clusters are solid, like worlds.", "_comment": "ASTEROID CLUSTERS — loose groups of slowly tumbling rocks drifting in the system's void. texture = spritesheet of frameWidth×frameHeight asteroid frames (frameCount frames). cluster = how a group looks & moves: groupSize = rocks per cluster (minFullSize guarantees at least one full-size rock), sizes = per-rock diameter px, spread = how far a rock may sit from the cluster center, gapFactor = the small edge-gap between ANY two rocks, as a multiple of their combined radii (1.0 = touching, 1.12 = a subtle gap — the generator pushes apart just the overlapping pairs, each by half, so groups stay compact), spin = each rock's OWN very slow tumble (deg/s, direction per-rock random), groupSpin = the whole loose group drifts around its center (deg/s), tint = subtle warm/cool starlight per cluster (anchors blended toward white by strength), halo = soft glow behind the group, debris = a halo of fine dust motes orbiting just outside the rocks. distribution = how many clusters a NON-barren system gets: targetObjects planetCount, ±jitter, clamped to [minClusters, maxClusters] (more planets ⇒ fewer clusters); the starting system always gets at least startingSystemMinClusters, and those first ones are placed INSIDE the player's initial tether so they're reachable from the spawn. barren = the dead-end (gate-only) systems: the cluster drifts INSIDE the arrival gate's level-1 tether (tether.json → level1Radius, minus the cluster's extent + tetherMargin) — the stop's only payload: mine it, then head back (clusters = how many, minRadius = nearest a cluster center may sit to the gate, px). placement = where clusters may sit: minObjectSpacing = no cluster may be closer (center-to-center) than this to ANY other object (home world, planets, free-space stations, jump gates, other clusters); minRadius/maxRadius = the annulus around the origin random clusters live in (the starting system's tether-inside clusters and the barren gate clusters are exempt — they sit in their own tether annulus); tetherMargin = how far inside the tether rim a tether-bound cluster must sit (whole group stays reachable). shipClearance = how close (edge-to-edge) the ship may get to a rock — clusters are solid, like worlds.",
"enabled": true, "enabled": true,
"texture": "assets/images/asteroids.png", "texture": "assets/images/asteroids.png",
"frameWidth": 128, "frameWidth": 128,
@ -105,6 +105,10 @@
"maxClusters": 6, "maxClusters": 6,
"startingSystemMinClusters": 2 "startingSystemMinClusters": 2
}, },
"barren": {
"clusters": [1, 2],
"minRadius": 1024
},
"placement": { "placement": {
"minObjectSpacing": 1024, "minObjectSpacing": 1024,
"minRadius": 2048, "minRadius": 2048,

View File

@ -1,12 +1,13 @@
{ {
"systemCount": 200, "_comment": "Galaxy shape + scale (js/galaxy/Galaxy.js). systemCount = total star systems (the starting system is one of them). layout = a sparse seeded DISK: no spiral arms (spiral.enabled = false, arms = 0 — the arm snap is skipped), no center bulge (coreFraction = 0), diskSkew = 0.5 so the radial spread is uniform-in-area (rNorm = √x — stars spread out sparsely across the disk instead of crowding the core), flatten = y squash (ellipse). settlements.gradient = free-space settlement density by galactic radius (rNorm 0 = center, 1 = rim): chance = 1 rNorm×falloff, floored at floor (js/galaxy/SystemGenerator.js → settlementDensity).",
"systemCount": 60,
"radius": 20000, "radius": 20000,
"layout": { "layout": {
"coreFraction": 0.25, "coreFraction": 0,
"bulgeSigma": 0.09, "bulgeSigma": 0.09,
"diskSkew": 1.7, "diskSkew": 0.5,
"flatten": 0.62, "flatten": 0.62,
"spiral": { "enabled": true, "arms": 2, "twist": 2.6, "strength": 0.5 } "spiral": { "enabled": false, "arms": 0, "twist": 2.6, "strength": 0.5 }
}, },
"distribution": { "distribution": {
"rules": [] "rules": []

View File

@ -1,10 +1,10 @@
{ {
"_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) plus optional one-way `shortcuts` bought with the spare gate budget — so the directed graph is strongly connected (from any star you can reach any other: no closed systems, no trapped sets, no unreachable stars), every jump is to a nearby star, and no system ever holds more than maxGates gates. Single-gate systems (e.g. the barren ones) read as dead-end corridors — enter from a neighbor, exit through the gate — which is fine as long as they are connected to the main network, which strong connectivity guarantees. PLACEMENT (anchored systems): each gate sits within `anchorTetherLevel` tether range of an ANCHOR — a planet, a free-space station, 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. 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 gate sits ON the ray from the star toward its destination, `barrenDistance` px from the star (stepped outward within the minRadius..maxRadius band if two gates would otherwise violate the gap), facing its destination. 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); in an anchored system it simply adds another anchor circle. A gate keeps `size` + `clearance` (px, center-to-center) from any anchor disc, 2·size + `gateGap` from any other gate, and stays between `minRadius` and `maxRadius` from the star. 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 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 12 asteroid clusters drifting inside the gate's level-1 tether (data/asteroids.json → barren). ACTIVITY: every gate record carries `active` (DEFAULT FALSE — the activation mechanic is future work). When a gate is activated, a level-1 tether (5120 px, data/tether.json) attaches to it: an active gate is a TETHER ANCHOR in its own right. In a barren system that tether is the player's entire room to move (the player arrives AT the gate — the center is empty: the star is invisible flavor, never rendered); in an anchored system it simply adds another anchor circle. A gate keeps `size` + `clearance` (px, center-to-center) from any solid disc (planets + free-space stations + the home world), 2·size + `gateGap` from any other gate, and stays between `minRadius` and `maxRadius` from the center. size = the gate's keepout radius (px). shipClearance = the ship's keepout from the gate (the solid rule, like planets). theme.color = the HUD/compass color (an inactive gate renders dimmed). typeLabel = the discovery/compass label. texture/frameWidth/frameHeight = the spritesheet (assets/images/jumpgate.png, 256×256 frames): frame 0 = the gate body (ring + pylons, drawn static), frame 1 = the active swirl (see swirl); sprite scale = (size×2)/frameWidth puts the ring's outer edge on the keepout disc. A missing sheet falls back to the built-in procedural gate (console note).",
"enabled": true, "enabled": true,
"minGates": 1, "minGates": 1,
"maxGates": 3, "maxGates": 3,
"neighborPool": 8, "neighborPool": 8,
"shortcuts": true, "shortcuts": false,
"size": 96, "size": 96,
"texture": "assets/images/jumpgate.png", "texture": "assets/images/jumpgate.png",
"frameWidth": 256, "frameWidth": 256,

View File

@ -1,5 +1,5 @@
{ {
"_comment": "Names. Stars and the galaxy are still SYNTHEISED from syllable pools (star / galaxy below) — short, consistent, unlimited. PLANETS and STATIONS draw from finite, curated NAME BANKS instead: a deep pool of hand-picked names, dealt out without repeats until the pool is exhausted (see js/utils/NameGenerator.js → planetDeck / stationDeck). Edit the banks freely — names are drawn per-system from the galaxy seed, so changing the banks changes every name, consistently. Within a system a planet never repeats another planet's name and a station never repeats another station's name; across the 200-system galaxy a finite pool must eventually recur (that's the price of lazy, order-independent generation — see docs/PROJECT_NOTES.md).", "_comment": "Names. Stars and the galaxy are still SYNTHEISED from syllable pools (star / galaxy below) — short, consistent, unlimited. PLANETS and STATIONS draw from finite, curated NAME BANKS instead: a deep pool of hand-picked names, dealt out without repeats until the pool is exhausted (see js/utils/NameGenerator.js → planetDeck / stationDeck). Edit the banks freely — names are drawn per-system from the galaxy seed, so changing the banks changes every name, consistently. Within a system a planet never repeats another planet's name and a station never repeats another station's name; across the 60-system galaxy a finite pool must eventually recur (that's the price of lazy, order-independent generation — see docs/PROJECT_NOTES.md).",
"star": { "star": {
"syllables": ["ka", "vel", "thu", "ori", "an", "esh", "mar", "dy", "neth", "avi", "cor", "lu", "tan", "ys", "brei", "hal", "ion", "sol", "qua", "ren"], "syllables": ["ka", "vel", "thu", "ori", "an", "esh", "mar", "dy", "neth", "avi", "cor", "lu", "tan", "ys", "brei", "hal", "ion", "sol", "qua", "ren"],
"minParts": 2, "minParts": 2,

View File

@ -1,5 +1,5 @@
{ {
"_comment": "Planet visuals + home-planet rules + star rules (the central body of every non-home system) + 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 central body is its star (`star` below). 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 star, or the home world in the starting system — at the local origin) — 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 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).",
"texture": "assets/images/planets.png", "texture": "assets/images/planets.png",
"frameWidth": 1024, "frameWidth": 1024,
"frameHeight": 1024, "frameHeight": 1024,
@ -35,10 +35,8 @@
"homeName": "Terra", "homeName": "Terra",
"homeTypeLabel": "Home World", "homeTypeLabel": "Home World",
"star": { "star": {
"_comment": "The central body of a NON-HOME system — the system's star (content.star from the generator; class = the spectral type G/K/M/…). The starting system's central body is the home world (homePlanet) and only there — a star is drawn in every other system. size = the SOLID disc's diameter px (1024 = a Terran world; the halo is visual only, outside the keep-out). classColor = the disc's hue per spectral class (a white core melts into it, js/entities/Star.js). glow.radiusFactor = halo radius as a multiple of the disc radius; glow.alpha = halo strength at the rim.", "_comment": "The system's INVISIBLE star — flavor, not a body. Every non-home system's center is empty (the star is never rendered; js/entities/Star.js is gone); this section feeds the dossier's identity (content.star) and the class colors used by the diagnostics and the map plate tag. classColor = the class's hue (G/K/M/… → hex).",
"size": 1536, "classColor": { "O": "#9db4ff", "B": "#9db4ff", "A": "#cfd8ff", "F": "#fff4e0", "G": "#ffe9b0", "K": "#ffc07a", "M": "#ff8a64" }
"classColor": { "O": "#9db4ff", "B": "#9db4ff", "A": "#cfd8ff", "F": "#fff4e0", "G": "#ffe9b0", "K": "#ffc07a", "M": "#ff8a64" },
"glow": { "radiusFactor": 1.55, "alpha": 0.5 }
}, },
"starTypeLabels": { "starTypeLabels": {
"O": "O-type Star", "O": "O-type Star",

View File

@ -1,5 +1,5 @@
{ {
"_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet class weights, moon/belt chances, habitability, hazard, and free-space settlement odds (settlements: deepSpaceStation / waypoint). objectCount is a GLOBAL rule, not per-type: the starting system is exempt (it always holds exactly two generated planets — gas giant + rocky — beside the home world, plus at most one free-space station). Every other system rolls its TOTAL object count — planets + free-space stations together — from this table: `barren` of systems hold ZERO objects (a jump-gate-only system — star and gates, nothing else), the rest hold `objects` counts. The split is resolved by rolling the free-space stations first (per-type odds in attributes.settlements × the core→rim gradient, at most two — deepSpaceStation + waypoint), then planets = N stations. Every planet is settled by rule (data/settlements.json → allPlanetsSettled + settledKindByClass). distribution.weight sets how common the type is; distribution.radiusBand ([inner, outer] as a fraction of galaxy radius) is the first proximity rule — richer distribution rules slot into galaxy.json `distribution.rules` later.", "_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet class weights, moon/belt chances, habitability, hazard, and free-space settlement odds (settlements: deepSpaceStation / waypoint). objectCount is a GLOBAL rule, not per-type: the starting system is exempt (it always holds exactly two generated planets — gas giant + rocky — beside the home world, plus at most one free-space station). Every other system rolls its TOTAL object count — planets + free-space stations together — from this table: `barren` of systems hold ZERO objects (a gate-only dead-end LEAF of the jump network — the center is empty, the star is invisible flavor, and the only things there are the single gate and 12 asteroid clusters drifting inside its tether, data/asteroids.json → barren), the rest hold `objects` counts. The split is resolved by rolling the free-space stations first (per-type odds in attributes.settlements × the core→rim gradient, at most two — deepSpaceStation + waypoint), then planets = N stations; if that roll would leave the system with NO planet (stations = N), one station is demoted to a planet — every non-barren system keeps ≥ 1 planet, the gate's tether anchor and the world the player can build out from. Every planet is settled by rule (data/settlements.json → allPlanetsSettled + settledKindByClass). distribution.weight sets how common the type is; distribution.radiusBand ([inner, outer] as a fraction of galaxy radius) is the first proximity rule — richer distribution rules slot into galaxy.json `distribution.rules` later.",
"objectCount": { "objectCount": {
"barren": 0.10, "barren": 0.10,
"objects": { "2": 0.15, "3": 0.30, "4": 0.30, "5": 0.15 } "objects": { "2": 0.15, "3": 0.30, "4": 0.30, "5": 0.15 }

View File

@ -8,8 +8,10 @@
* - COUNT vs PLANETS: clusterCount targetObjects planetCount (± * - COUNT vs PLANETS: clusterCount targetObjects planetCount (±
* jitter, clamped to [minClusters, maxClusters]) so systems with * jitter, clamped to [minClusters, maxClusters]) so systems with
* more planets get fewer clusters and vice versa (also checked as an * more planets get fewer clusters and vice versa (also checked as an
* aggregate correlation across the non-barren sample); barren systems * aggregate correlation across the non-barren sample); BARREN
* (jump-gate-only) hold NO clusters; * systems (gate-only dead-end leaves) are the one exception they
* hold 12 clusters (their payload) INSIDE the arrival gate's
* level-1 tether (data/asteroids.json barren);
* - the STARTING system always gets startingSystemMinClusters, and * - the STARTING system always gets startingSystemMinClusters, and
* those first ones sit INSIDE the initial tether (whole cluster, * those first ones sit INSIDE the initial tether (whole cluster,
* minus placement.tetherMargin); * minus placement.tetherMargin);
@ -18,9 +20,10 @@
* every rock pair keeps a gapFactor × (r1+r2) gap (no interpenetration), * every rock pair keeps a gapFactor × (r1+r2) gap (no interpenetration),
* bound = max(offset + size/2); * bound = max(offset + size/2);
* - SPACING: no cluster within 1024 px (center-to-center) of ANY other * - SPACING: no cluster within 1024 px (center-to-center) of ANY other
* object home world (origin), planets, free-space stations, other * solid object the home world (origin, starting system only),
* clusters and every cluster sits in the [minRadius, maxRadius] * planets, free-space stations, other clusters and every non-barren
* annulus around the origin; * cluster sits in the [minRadius, maxRadius] annulus around the origin
* (barren clusters orbit their gate instead);
* - MOTION: each rock's spin and the group drift are within the * - MOTION: each rock's spin and the group drift are within the
* configured slow-spin ranges; * configured slow-spin ranges;
* - NAMES: synthesised, and never repeating a name in the same system; * - NAMES: synthesised, and never repeating a name in the same system;
@ -122,12 +125,16 @@ for (const rec of sample) {
// target — targetObjects planetCount, ±jitter, clamped into [min, max] // target — targetObjects planetCount, ±jitter, clamped into [min, max]
// (and [startingMin, max] for the home system). When the ideal band // (and [startingMin, max] for the home system). When the ideal band
// clamps to empty, the clamped range itself is the contract. // clamps to empty, the clamped range itself is the contract.
// BARREN systems (0 planets + 0 free-space stations — the jump-gate-only // BARREN systems (0 planets + 0 free-space stations — the gate-only
// stops) are the one exception: no clusters by design. // dead-end leaves) are the one exception: 12 clusters (barren.clusters)
// as their payload, placed around the arrival gate.
const barren = c.planets.length === 0 && (c.settlements ?? []).every((s) => s.anchor?.type !== 'space'); const barren = c.planets.length === 0 && (c.settlements ?? []).every((s) => s.anchor?.type !== 'space');
if (barren) { if (barren) {
if (clusters.length !== 0) { const bc = A.barren ?? {};
countOk = false; countWhy = `${rec.id}: barren system → ${clusters.length} clusters (want 0)`; break; const lo = Math.max(0, Math.floor(bc.clusters?.[0] ?? 1));
const hi = Math.max(lo, Math.floor(bc.clusters?.[1] ?? 2));
if (clusters.length < lo || clusters.length > hi) {
countOk = false; countWhy = `${rec.id}: barren system → ${clusters.length} clusters (want ${lo}${hi})`; break;
} }
} else { } else {
const want = D.targetObjects - c.planets.length; const want = D.targetObjects - c.planets.length;
@ -144,11 +151,19 @@ for (const rec of sample) {
for (const p of c.planets) used.add(p.name); for (const p of c.planets) used.add(p.name);
for (const s of c.settlements ?? []) if (s.name) used.add(s.name); for (const s of c.settlements ?? []) if (s.name) used.add(s.name);
// Spacing obstacles. // Spacing obstacles — solids only (the center holds the home world in
const objects = [{ x: 0, y: 0 }]; // the starting system and is EMPTY in every other system).
const objects = isHome ? [{ x: 0, y: 0 }] : [];
for (const p of c.planets) if (typeof p.x === 'number') objects.push({ x: p.x, y: p.y }); for (const p of c.planets) if (typeof p.x === 'number') objects.push({ x: p.x, y: p.y });
for (const s of c.settlements ?? []) if (s.anchor?.type === 'space' && typeof s.x === 'number') objects.push({ x: s.x, y: s.y }); for (const s of c.settlements ?? []) if (s.anchor?.type === 'space' && typeof s.x === 'number') objects.push({ x: s.x, y: s.y });
// Barren payload: the clusters orbit the arrival gate, inside its
// level-1 tether (and at least barren.minRadius out from it).
const gate = (c.jumps ?? [])[0];
const bMin = A.barren?.minRadius ?? 1024;
const gTether = config.get('tether.level1Radius', 5120);
const margin = P.tetherMargin ?? 96;
for (let i = 0; i < clusters.length; i++) { for (let i = 0; i < clusters.length; i++) {
const cl = clusters[i]; const cl = clusters[i];
@ -209,7 +224,7 @@ for (const rec of sample) {
} }
used.add(cl.name); used.add(cl.name);
// --- Spacing: ≥ 1024 px from EVERY other object ---------------------- // --- Spacing: ≥ 1024 px from every other SOLID ----------------------
for (const o of objects) { for (const o of objects) {
const d = Math.hypot(cl.x - o.x, cl.y - o.y); const d = Math.hypot(cl.x - o.x, cl.y - o.y);
if (d < P.minObjectSpacing - 1e-6) { if (d < P.minObjectSpacing - 1e-6) {
@ -218,26 +233,37 @@ for (const rec of sample) {
} }
if (!spacingOk) break; if (!spacingOk) break;
// --- Scatter annulus --------------------------------------------------- // --- Barren payload: inside the gate's tether ------------------------
const dist0 = Math.hypot(cl.x, cl.y); if (barren) {
if (dist0 < P.minRadius - 1e-6 || dist0 > P.maxRadius + 1e-6) { if (!gate || typeof gate.x !== 'number') {
annulusOk = false; annulusWhy = `${rec.id}#${i}: ${Math.round(dist0)} px from origin (want ${P.minRadius}${P.maxRadius})`; break; annulusOk = false; annulusWhy = `${rec.id}#${i}: barren cluster but no gate to anchor on`; break;
}
const dg = Math.hypot(cl.x - gate.x, cl.y - gate.y);
if (dg < bMin - 1e-6 || dg + cl.bound + margin > gTether + 1e-6) {
annulusOk = false; annulusWhy = `${rec.id}#${i}: ${Math.round(dg)} px from the gate (want ${bMin}${gTether}, bound ${Math.round(cl.bound)} + margin ${margin})`; break;
}
} else {
// --- Scatter annulus (non-barren: around the origin) ---------------
const dist0 = Math.hypot(cl.x, cl.y);
if (dist0 < P.minRadius - 1e-6 || dist0 > P.maxRadius + 1e-6) {
annulusOk = false; annulusWhy = `${rec.id}#${i}: ${Math.round(dist0)} px from origin (want ${P.minRadius}${P.maxRadius})`; break;
}
} }
// --- Starting-system tether guarantee --------------------------------- // --- Starting-system tether guarantee ---------------------------------
if (isHome && i < D.startingSystemMinClusters) { if (isHome && i < D.startingSystemMinClusters) {
if (dist0 + cl.bound + (P.tetherMargin ?? 96) > TETHER + 1e-6) { if (Math.hypot(cl.x, cl.y) + cl.bound + margin > TETHER + 1e-6) {
homeOk = false; homeWhy = `${rec.id}#${i}: whole cluster not inside the initial tether (${Math.round(dist0 + cl.bound)} + margin > ${TETHER})`; break; homeOk = false; homeWhy = `${rec.id}#${i}: whole cluster not inside the initial tether (${Math.round(Math.hypot(cl.x, cl.y) + cl.bound)} + margin > ${TETHER})`; break;
} }
} }
} }
if (!shapeOk || !spacingOk || !annulusOk || !spinOk || !namesOk || !homeOk) break; if (!shapeOk || !spacingOk || !annulusOk || !spinOk || !namesOk || !homeOk) break;
} }
check(`count: ${sample.length} systems obey targetObjectsplanets (±jitter), clamped ${D.minClusters}${D.maxClusters} (barren ⇒ 0)${countOk ? '' : ' — ' + countWhy}`, countOk); check(`count: ${sample.length} systems obey targetObjectsplanets (±jitter), clamped ${D.minClusters}${D.maxClusters} (barren ⇒ ${A.barren?.clusters?.join('') ?? '12'})${countOk ? '' : ' — ' + countWhy}`, countOk);
check(`shape: groups of ${CL.groupSize.min}${CL.groupSize.max}, sizes ${CL.sizes.min}${CL.sizes.max}, ≥1 full-size, frames unique per cluster, rocks keep a ${CL.gapFactor ?? 1.12}× gap, bound correct${shapeOk ? '' : ' — ' + shapeWhy}`, shapeOk); check(`shape: groups of ${CL.groupSize.min}${CL.groupSize.max}, sizes ${CL.sizes.min}${CL.sizes.max}, ≥1 full-size, frames unique per cluster, rocks keep a ${CL.gapFactor ?? 1.12}× gap, bound correct${shapeOk ? '' : ' — ' + shapeWhy}`, shapeOk);
check(`spacing: no cluster within ${P.minObjectSpacing} px (center-to-center) of ANY object (home, planets, stations, clusters)${spacingOk ? '' : ' — ' + spacingWhy}`, spacingOk); check(`spacing: no cluster within ${P.minObjectSpacing} px (center-to-center) of ANY solid (home world, planets, stations, clusters)${spacingOk ? '' : ' — ' + spacingWhy}`, spacingOk);
check(`scatter: every cluster inside the ${P.minRadius}${P.maxRadius} annulus around the origin${annulusOk ? '' : ' — ' + annulusWhy}`, annulusOk); check(`scatter: non-barren clusters inside the ${P.minRadius}${P.maxRadius} annulus; barren payload inside the gate's level-1 tether${annulusOk ? '' : ' — ' + annulusWhy}`, annulusOk);
check(`motion: per-rock spins & group drifts within the slow-spin ranges${spinOk ? '' : ' — ' + spinWhy}`, spinOk); check(`motion: per-rock spins & group drifts within the slow-spin ranges${spinOk ? '' : ' — ' + spinWhy}`, spinOk);
check('names: synthesised, never repeating a name in the same system', namesOk); check('names: synthesised, never repeating a name in the same system', namesOk);
const homeClusters = g.ensureContent(homeId).asteroids; const homeClusters = g.ensureContent(homeId).asteroids;
@ -247,8 +273,9 @@ check(
); );
// The inverse rule, in aggregate: rockier systems (few planets) get more // The inverse rule, in aggregate: rockier systems (few planets) get more
// clusters than planet-rich ones — over the NON-BARREN systems (barren are // clusters than planet-rich ones — over the NON-BARREN systems (barren
// jump-gate-only and hold 0 clusters by design, a separate rule). // dead ends carry their fixed 12 payload clusters around the gate, a
// separate rule).
{ {
const rows = sample.map((r) => { const rows = sample.map((r) => {
const c = g.ensureContent(r.id); const c = g.ensureContent(r.id);

View File

@ -13,11 +13,12 @@
* - every generated system obeys its type's attribute bounds; * - every generated system obeys its type's attribute bounds;
* - the OBJECT COMPOSITION (data/systems.json objectCount): every * - the OBJECT COMPOSITION (data/systems.json objectCount): every
* non-home system holds 0, 2, 3, 4, or 5 objects (planets + free-space * non-home system holds 0, 2, 3, 4, or 5 objects (planets + free-space
* stations), 10% barren jump-gate-only stops; * stations), 10% barren the gate-only dead-end leaves of the
* jump network (the maze's dead ends);
* - lazy (on-arrival) content === eager (generateAll) content; * - lazy (on-arrival) content === eager (generateAll) content;
* - spatial-hash neighbor queries agree with brute force; * - spatial-hash neighbor queries agree with brute force;
* - starting system policy works. * - starting system policy works.
* Also reports generation timing for the 200-system galaxy. * Also reports generation timing for the 60-system galaxy.
*/ */
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@ -170,11 +171,15 @@ let big;
check('every non-home system holds 0/2/3/4/5 objects (the global objectCount rule)', boundsOk); check('every non-home system holds 0/2/3/4/5 objects (the global objectCount rule)', boundsOk);
const emptyShare = emptyCount / nSys; const emptyShare = emptyCount / nSys;
const barrenExpect = OC.barren ?? 0.1; const barrenExpect = OC.barren ?? 0.1;
const barrenSd = Math.sqrt(barrenExpect * (1 - barrenExpect) / nSys);
check( check(
`${Math.round(barrenExpect * 100)}% of systems are barren — jump-gate-only (observed ${(emptyShare * 100).toFixed(1)}%)`, `${Math.round(barrenExpect * 100)}% of systems are barren — the gate-only dead-end leaves (observed ${(emptyShare * 100).toFixed(1)}%)`,
Math.abs(emptyShare - barrenExpect) < 0.08, Math.abs(emptyShare - barrenExpect) < 4 * barrenSd + 0.004,
); );
check(`lazy content generation over all ${nSys} systems`, big.generatedCount === nSys); check(`lazy content generation over all ${nSys} systems`, big.generatedCount === nSys);
// (The barren-share check below uses the same ±4σ band as the other
// composition checks — at 60 systems the count is small, so an absolute
// 0.08 band was tighter than the sampling noise.)
// Lazy === eager: fresh galaxy (unopened) vs fully generated one. // Lazy === eager: fresh galaxy (unopened) vs fully generated one.
const fresh = Galaxy.create(SEED); const fresh = Galaxy.create(SEED);
@ -315,18 +320,20 @@ let big;
check('every planet is settled with a class-fitting kind (colonies only on habitable rocky worlds)', allSettledOk); check('every planet is settled with a class-fitting kind (colonies only on habitable rocky worlds)', allSettledOk);
// OBJECT COMPOSITION: the BARREN systems (objectCount → 0) are the only // OBJECT COMPOSITION: the BARREN systems (objectCount → 0) are the only
// unsettled ones — jump-gate-only stops, deliberately (strong // unsettled ones — the gate-only dead-end leaves of the jump network
// connectivity keeps them reachable); every non-barren system is settled // (the spanning tree keeps them reachable: the maze's dead ends, in and
// (all its planets + its free-space stations). // out the same way); every non-barren system is settled (all its
// planets + its free-space stations).
let barren = 0; let barren = 0;
for (const r of sample) { for (const r of sample) {
const c = big2.ensureContent(r.id); const c = big2.ensureContent(r.id);
if (c.planets.length === 0 && !c.settlements.some((s) => s.anchor?.type === 'space')) barren++; if (c.planets.length === 0 && !c.settlements.some((s) => s.anchor?.type === 'space')) barren++;
} }
const barrenShare = barren / sample.length; const barrenShare = barren / sample.length;
const barrenP = config.get('systems.objectCount.barren', 0.1);
check( check(
`barren systems are jump-gate-only: ≈ ${Math.round((config.get('systems.objectCount.barren', 0.1) * 100))}% (observed ${(barrenShare * 100).toFixed(1)}%)`, `barren systems are the dead-end leaves: ≈ ${Math.round(barrenP * 100)}% (observed ${(barrenShare * 100).toFixed(1)}%)`,
Math.abs(barrenShare - (config.get('systems.objectCount.barren', 0.1))) < 0.08, Math.abs(barrenShare - barrenP) < 4 * Math.sqrt(barrenP * (1 - barrenP) / sample.length) + 0.004,
); );
check( check(
'every non-barren system is settled (the home system is exempt)', 'every non-barren system is settled (the home system is exempt)',

View File

@ -11,25 +11,36 @@
* - the network is STRONGLY CONNECTED: from the home system every * - the network is STRONGLY CONNECTED: from the home system every
* other system is reachable (forward BFS) AND every system can * other system is reachable (forward BFS) AND every system can
* reach home (reverse BFS) no closed systems, no trapped sets; * reach home (reverse BFS) no closed systems, no trapped sets;
* - the network is a PURE SPANNING TREE (shortcuts OFF in the config):
* exactly N1 undirected edges no loops, one route between any two
* systems (the maze) and every BARREN system (objectCount 0) is
* a LEAF: exactly one gate, in and out the same way (the dead end);
* *
* the IN-SYSTEM PLACEMENT (js/galaxy/SystemGenerator.js layoutGates): * the IN-SYSTEM PLACEMENT (js/galaxy/SystemGenerator.js layoutGates):
* - content.jumps matches the network (count + destinations); * - content.jumps matches the network (count + destinations);
* - ANCHORED systems (a planet, a free-space station, or the home world): * - ANCHORED systems (a planet or the home world in the starting
* every gate is within level-1 tether (tether.level1Radius) of an * system): every gate is within level-1 tether (tether.level1Radius)
* anchor and FACES its destination star on the 2-D map (soft rule * of a planet anchor and FACES its destination star on the 2-D map
* within 90° of the systemstar bearing from the anchoring object); * (soft rule within 90° of the systemstar bearing from the
* - BARREN systems (objectCount 0 a jump-gate-only stop): the gate * anchoring object); stations are NOT anchors (the player must be
* sits ON the ray toward its destination, gates.barrenDistance from * able to build out from a world), but they DO get clearance;
* the star, within the radius band, facing it; * - every non-barren system holds at least ONE planet (the gate's
* - gates stay gates.minRadius..gates.maxRadius from the star; * anchor is guaranteed to exist);
* - gates keep size+clearance from anchor discs and 2·size+gateGap * - BARREN systems (objectCount 0 the network's dead-end leaves):
* from each other; * the gate sits ON the ray toward its destination, in the radius
* band, facing it and 12 asteroid clusters drift INSIDE the
* gate's level-1 tether (the stop's only payload, data/asteroids.json
* barren);
* - gates stay gates.minRadius..gates.maxRadius from the center;
* - gates keep size+clearance from every solid disc (planets +
* stations + the home world) and 2·size+gateGap from each other;
* - gate ids/names are unique per system; * - gate ids/names are unique per system;
* - every gate record carries active: false (data/gates.json ACTIVITY); * - every gate record carries active: false (data/gates.json ACTIVITY);
* - the OBJECT COMPOSITION (data/systems.json objectCount): every * - the OBJECT COMPOSITION (data/systems.json objectCount): every
* non-home system holds 0, 2, 3, 4, or 5 objects (planets + * non-home system holds 0, 2, 3, 4, or 5 objects (planets +
* free-space stations) in the configured proportions; a barren * free-space stations) in the configured proportions; a barren
* system holds nothing else no asteroid clusters either; * system holds no planets or stations its payload is the gate's
* asteroid cluster;
* *
* and DETERMINISM: same seed same network, same gates, same layout; * and DETERMINISM: same seed same network, same gates, same layout;
* different seed different network (spot check). * different seed different network (spot check).
@ -137,6 +148,32 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
for (const s of radj.get(u)) if (!rev.has(s)) rev.add(s), qr.push(s); for (const s of radj.get(u)) if (!rev.has(s)) rev.add(s), qr.push(s);
} }
check('every system can reach home (no closed systems, no trapped sets)', rev.size === g.records.length, `${rev.size}/${g.records.length}`); check('every system can reach home (no closed systems, no trapped sets)', rev.size === g.records.length, `${rev.size}/${g.records.length}`);
// TREE (shortcuts OFF in data/gates.json): exactly N1 undirected
// edges — no loops, no redundant routes (the maze's single path).
const pairs = new Set();
let treeEdges = 0;
for (const r of g.records) for (const t of g.jumpGatesFor(r.id)) {
const key = [r.id, t.id].sort().join('\u0000');
if (!pairs.has(key)) pairs.add(key), treeEdges++;
}
check(`spanning tree: exactly ${g.records.length - 1} undirected edges (no loops)`,
treeEdges === g.records.length - 1, `${treeEdges} edges over ${g.records.length} systems`);
// MAZE DEAD ENDS: every barren system (objectCount → 0) is a LEAF —
// exactly one gate, in and out the same way. (Home is exempt — it
// always holds planets.)
const leafBad = [];
for (const r of g.records) {
if (r.id === HOME) continue;
const c = g.ensureContent(r.id);
const spaceCount = (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length;
const isBarren = c.planets.length === 0 && spaceCount === 0;
if (!isBarren) continue;
const n = g.jumpGatesFor(r.id).length;
if (n !== 1) leafBad.push(`${r.id}:${n}`);
}
check('every barren system is a leaf — exactly one gate (the dead end)', leafBad.length === 0, leafBad.slice(0, 5).join(', '));
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -166,12 +203,19 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
active++; active++;
if (!bWhy) bWhy = `${sys.id}: a gate is not active:false`; if (!bWhy) bWhy = `${sys.id}: a gate is not active:false`;
} }
// Anchors: planets, free-space stations, and (home) the home world. // ANCHORS: the PLANETS and (home only) the home world — the bodies a
// gate's tether may hang from (stations are NOT anchors — the player
// must be able to build out from a world).
const anchors = [ const anchors = [
...c.planets.map((p) => ({ x: p.x, y: p.y, name: p.name })), ...c.planets.map((p) => ({ x: p.x, y: p.y, name: p.name })),
...((c.settlements ?? []).filter((s) => s.anchor?.type === 'space').map((s) => ({ x: s.x, y: s.y, name: s.name })) ?? []),
]; ];
if (isHome) anchors.push({ x: 0, y: 0, name: 'home world' }); if (isHome) anchors.push({ x: 0, y: 0, name: 'home world' });
// CLEARANCE DISCS: every solid body the gate keeps clear of — the
// anchors plus the free-space stations.
const discs = [
...anchors,
...((c.settlements ?? []).filter((s) => s.anchor?.type === 'space').map((s) => ({ x: s.x, y: s.y, name: s.name })) ?? []),
];
c.jumps.forEach((j, gi) => { c.jumps.forEach((j, gi) => {
const t = recOf.get(j.to); const t = recOf.get(j.to);
@ -213,14 +257,15 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
if (!fWhy) fWhy = `${sys.id}: gate ${j.id} is on the wrong side of every tethering anchor`; if (!fWhy) fWhy = `${sys.id}: gate ${j.id} is on the wrong side of every tethering anchor`;
} }
} }
// RADIUS band from the star. // RADIUS band from the center.
if (d0 < MIN_R - 1e-6 || d0 > MAX_R + 1e-6) { if (d0 < MIN_R - 1e-6 || d0 > MAX_R + 1e-6) {
radius++; radius++;
if (!rWhy) rWhy = `${sys.id}: gate ${j.id} at ${Math.round(d0)} px from the star (band ${MIN_R}..${MAX_R})`; if (!rWhy) rWhy = `${sys.id}: gate ${j.id} at ${Math.round(d0)} px from the center (band ${MIN_R}..${MAX_R})`;
} }
// CLEARANCE from anchor discs (every disc in this game is under // CLEARANCE from every solid disc (planets + stations + home world
// 800 px across, so a size + 100 px floor is a fair check). // — every disc in this game is under 800 px across, so a size +
for (const a of anchors) { // 100 px floor is a fair check).
for (const a of discs) {
const d = Math.hypot(j.x - a.x, j.y - a.y); const d = Math.hypot(j.x - a.x, j.y - a.y);
if (d < SIZE + 100) { if (d < SIZE + 100) {
clearance++; clearance++;
@ -247,11 +292,11 @@ 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('content.jumps matches the gate network (count, destinations, order)', !netMismatch, mWhy);
check(`tether (hard): every anchored-system gate ≤ ${TETHER} px from a planet/station anchor`, tether === 0, tWhy); check(`tether (hard): every anchored-system gate ≤ ${TETHER} px from a planet anchor`, tether === 0, tWhy);
check('facing (soft): every anchored-system gate is on the target side of its anchor (< 90°)', facing === 0, fWhy); 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, ${BARN_D} px from the star, facing it`, barrenBad === 0, bWhy); 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 star`, radius === 0, rWhy); check(`radius band: every gate ${MIN_R}..${MAX_R} px from the center`, radius === 0, rWhy);
check(`clearance: every gate keeps ${SIZE} + 100 px from anchor discs`, clearance === 0, cWhy); check(`clearance: every gate keeps ${SIZE} + 100 px from solid discs (planets + stations + home)`, clearance === 0, cWhy);
check(`gate gap: gates of a system are ≥ ${2 * SIZE + GAP} px apart`, gap === 0, gWhy); check(`gate gap: gates of a system are ≥ ${2 * SIZE + GAP} px apart`, gap === 0, gWhy);
check('gate ids are <systemId>-j<n> and names are unique per system', unique === 0); check('gate ids are <systemId>-j<n> and names are unique per system', unique === 0);
check('every gate record is active:false (inert until activation)', active === 0, bWhy); check('every gate record is active:false (inert until activation)', active === 0, bWhy);
@ -259,12 +304,19 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
// The OBJECT COMPOSITION (data/systems.json → objectCount): every // The OBJECT COMPOSITION (data/systems.json → objectCount): every
// non-home system holds 0, 2, 3, 4, or 5 objects (planets + // non-home system holds 0, 2, 3, 4, or 5 objects (planets +
// free-space stations) in the configured proportions — 0 = a barren, // free-space stations) in the configured proportions — 0 = a barren,
// jump-gate-only stop (no asteroid clusters either). // jump-gate-only dead end (the network's leaves). Every NON-barren
// system holds at least ONE PLANET (the gate's anchor — the player must
// be able to build out from a world), and a barren system's payload is
// 12 asteroid clusters INSIDE its single gate's level-1 tether.
const OC = config.get('systems.objectCount', { barren: 0.1, objects: { 2: 0.15, 3: 0.3, 4: 0.3, 5: 0.15 } }); const OC = config.get('systems.objectCount', { barren: 0.1, objects: { 2: 0.15, 3: 0.3, 4: 0.3, 5: 0.15 } });
const expected = { 0: OC.barren ?? 0.1 }; const expected = { 0: OC.barren ?? 0.1 };
for (const [k, w] of Object.entries(OC.objects ?? {})) expected[Number(k)] = w; for (const [k, w] of Object.entries(OC.objects ?? {})) expected[Number(k)] = w;
const counts = {}; const counts = {};
let shapeBad = 0, whyShape = '', barrenClusters = 0; let shapeBad = 0, whyShape = '';
let noPlanet = 0, whyPlanet = '', barrenCl = 0, whyCl = '';
const AC = config.section('asteroids', {});
const clLo = Math.max(0, Math.floor(AC.barren?.clusters?.[0] ?? 1));
const clHi = Math.max(clLo, Math.floor(AC.barren?.clusters?.[1] ?? 2));
for (const r of g.records) { for (const r of g.records) {
if (r.id === HOME) continue; // the home system is exempt (fixed 2 planets) if (r.id === HOME) continue; // the home system is exempt (fixed 2 planets)
const c = g.ensureContent(r.id); const c = g.ensureContent(r.id);
@ -274,7 +326,34 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
shapeBad++; shapeBad++;
if (!whyShape) whyShape = `${r.id}: ${n} objects`; if (!whyShape) whyShape = `${r.id}: ${n} objects`;
} }
if (n === 0 && (c.asteroids ?? []).length > 0) barrenClusters++; // ≥ 1 PLANET in every non-barren system (the gate's anchor is real).
if (n > 0 && c.planets.length === 0) {
noPlanet++;
if (!whyPlanet) whyPlanet = `${r.id}: ${n} objects, 0 planets`;
}
// BARREN: the dead end's payload — clLo..clHi clusters, each inside
// its single gate's level-1 tether (center + extent ≤ tether rim).
if (n === 0) {
const cl = c.asteroids ?? [];
if (cl.length < clLo || cl.length > clHi) {
barrenCl++;
if (!whyCl) whyCl = `${r.id}: ${cl.length} clusters (want ${clLo}..${clHi})`;
continue;
}
const gate = (c.jumps ?? [])[0];
if (!gate) {
barrenCl++;
if (!whyCl) whyCl = `${r.id}: barren but no gate to hang the cluster on`;
continue;
}
for (const a of cl) {
const d = Math.hypot(a.x - gate.x, a.y - gate.y);
if (d - (a.bound ?? 0) > TETHER + 1e-6) {
barrenCl++;
if (!whyCl) whyCl = `${r.id}: cluster at ${Math.round(d)} px from the gate (+extent > ${TETHER})`;
}
}
}
} }
const nN = g.records.length - 1; const nN = g.records.length - 1;
let distOk = true; let distOk = true;
@ -288,7 +367,8 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
} }
check(`composition: every non-home system holds 0/2/3/4/5 objects — ${g.records.length} systems`, shapeBad === 0, whyShape); check(`composition: every non-home system holds 0/2/3/4/5 objects — ${g.records.length} systems`, shapeBad === 0, whyShape);
check('composition: the object counts match the configured proportions (±4σ)', distOk); check('composition: the object counts match the configured proportions (±4σ)', distOk);
check('barren systems are truly barren — no asteroid clusters', barrenClusters === 0, `${barrenClusters} with clusters`); check('every non-barren system holds ≥ 1 planet (the gate anchor exists)', noPlanet === 0, whyPlanet);
check(`barren dead ends carry ${clLo}${clHi} clusters INSIDE the gate's level-1 tether`, barrenCl === 0, whyCl);
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------

View File

@ -1,132 +0,0 @@
/**
* dev/nav-reach-probe.mjs soft-lock probe (path-aware): which systems'
* NAV charts can NEVER be completed under the game's actual progression?
*
* node dev/nav-reach-probe.mjs [seed ...]
*
* Model (mirrors GameScene + SystemCategory):
* - To jump YX the player must have RESEARCHED Y's gate tech, so every
* system jumped OUT of is researched; arrival at X from Y Y (and
* everything before it on the path, back to home) is researched.
* - On entry, X's ACTIVE gates = the gates from X to any researched
* system (those return gates were flipped by the neighbor's tech).
* - The player's room to move = disc(star, 5120) disc(activeGate, 5120)
* for each active gate. A NAV point is discoverable iff that union
* comes within (radius + 540) of the point.
* - A system is clearable iff SOME researched neighbor lets it chart.
* Clear systems stay clear (they become researched and only add more
* active gates for their neighbors).
*/
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import fs from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const manifest = JSON.parse(fs.readFileSync(join(root, 'data', 'manifest.json'), 'utf8'));
const files = {};
for (const f of manifest.files) files[f.split('/').pop().replace('.json', '')] = JSON.parse(fs.readFileSync(join(root, 'data', f), 'utf8'));
const { config } = await import(join(root, 'js', 'config', 'Config.js'));
config.init(files);
const { Galaxy } = await import(join(root, 'js', 'galaxy', 'Galaxy.js'));
const { navPoints } = await import(join(root, 'js', 'research', 'SystemCategory.js'));
const TETHER = config.get('tether.level1Radius', 5120);
const DISC = config.get('game.discovery.distance', 540);
const GATE_R = config.get('gates.size', 96);
const STAR_R = config.get('planets.star.size', 1536) / 2;
const homeTether = TETHER * Math.pow(config.get('tether.radiusGrowth', 2), Math.max(1, config.get('tether.homeLevel', 1)) - 1);
function systemOf(galaxy, id) {
const content = galaxy.ensureContent(id);
const rec = galaxy.byId.get(id);
const pts = navPoints(content).map((p) => {
let pos = { x: 0, y: 0 };
let r = STAR_R;
if (p.kind === 'planet') {
const q = content.planets.find((x) => x.name === p.id);
pos = q; r = (1024 * (config.get(`planets.classScale.${q.class}`, 1))) / 2;
} else if (p.kind === 'gate') {
pos = content.jumps.find((j) => j.id === p.id); r = GATE_R;
} else if (p.kind === 'station') {
pos = content.settlements.find((s) => s.id === p.id); r = 108;
}
return { id: p.id, kind: p.kind, x: pos.x, y: pos.y, r };
});
const gates = (content.jumps ?? []).filter((j) => typeof j.x === 'number');
return {
id,
name: rec.name,
isHome: id === galaxy.homeSystemId,
planets: content.planets.length,
stations: (content.settlements ?? []).filter((s) => s.anchor?.type === 'space').length,
gates,
asteroids: (content.asteroids ?? []).length,
pts,
neighbors: gates.map((g) => g.to),
};
}
function chartable(sys, researched) {
const active = sys.gates.filter((g) => researched.has(g.to));
const discs = [{ x: 0, y: 0, r: sys.isHome ? homeTether : TETHER }, ...active.map((g) => ({ x: g.x, y: g.y, r: TETHER }))];
const missing = sys.pts.filter((p) => {
let best = Infinity;
for (const c of discs) {
const d = Math.hypot(p.x - c.x, p.y - c.y) - c.r;
if (d < best) best = d;
}
return best > p.r + DISC;
});
return { chartable: missing.length === 0, missing, activeCount: active.length };
}
const seeds = process.argv.slice(2).length ? process.argv.slice(2) : ['probe1'];
for (const seed of seeds) {
const galaxy = Galaxy.create(seed, { systemCount: 200 });
const all = [...galaxy.byId.keys()].map((id) => systemOf(galaxy, id));
const byId = new Map(all.map((s) => [s.id, s]));
// Progression: BFS of clearable systems, researching each as it clears.
const researched = new Set([galaxy.homeSystemId]);
let frontier = [galaxy.homeSystemId];
while (frontier.length) {
const next = [];
for (const id of frontier) {
for (const s of all) {
if (s.isHome || researched.has(s.id)) continue;
if (!s.neighbors.includes(id)) continue; // entry neighbor
const r2 = new Set(researched);
if (!r2.has(id)) r2.add(id);
if (chartable(s, r2).chartable) {
researched.add(s.id);
next.push(s.id);
}
}
}
frontier = next;
}
const stuck = all.filter((s) => !s.isHome && !researched.has(s.id));
console.log(`\n=== seed ${seed}: ${researched.size - 1}/${all.length - 1} non-home systems clearable, ${stuck.length} stuck ===`);
for (const s of stuck.slice(0, 8)) {
// best explanation: via which neighbor, which points remain missing
let best = null;
for (const n of new Set(s.neighbors)) {
if (!byId.has(n)) continue;
const r2 = new Set(researched);
if (!r2.has(n)) r2.add(n);
const c = chartable(s, r2);
if (!best || c.missing.length < best.missing.length) best = { via: n, ...c };
}
console.log(
` ${s.id}: planets=${s.planets} stations=${s.stations} gates=${s.gates.length} asteroids=${s.asteroids}` +
` → best entry via ${best?.via}: missing=[${best?.missing.map((m) => m.kind).join(',')}] activeGates=${best?.activeCount}`
);
}
// Also: systems matching "1 planet, 3 gates" (the user's shape)
const one = all.filter((s) => !s.isHome && s.planets === 1 && s.stations === 0 && s.gates.length === 3);
console.log(` systems with 1 planet + 3 gates: ${one.map((s) => `${s.id}(stuck=${!researched.has(s.id)})`).join(' ')}`);
}

View File

@ -70,7 +70,7 @@ const fakeScene = () => ({
const makeRec = (over = {}) => ({ const makeRec = (over = {}) => ({
app: 'orbit', app: 'orbit',
format: 1, format: SAVE_FORMAT, // a record of the CURRENT format (older builds' saves are rejected)
savedAt: '2026-07-15T00:00:00.000Z', savedAt: '2026-07-15T00:00:00.000Z',
seed: SEED, seed: SEED,
galaxyName: galaxy.name, galaxyName: galaxy.name,
@ -183,6 +183,13 @@ const makeStorage = (fail = false) => {
check('validateRecord: bad ship rejected', SaveManager.validateRecord(makeRec({ ship: { x: 1 } })) !== null); check('validateRecord: bad ship rejected', SaveManager.validateRecord(makeRec({ ship: { x: 1 } })) !== null);
check('validateRecord: good record accepted', SaveManager.validateRecord(rec) === null); check('validateRecord: good record accepted', SaveManager.validateRecord(rec) === null);
check('validateRecord: tolerates missing tethers/discovery', SaveManager.validateRecord(makeRec({ tethers: undefined, discovery: undefined })) === null); check('validateRecord: tolerates missing tethers/discovery', SaveManager.validateRecord(makeRec({ tethers: undefined, discovery: undefined })) === null);
// The galaxy-redesign gate: an old build's save (format 1) references a
// world that no longer exists — rejected with a clear, toast-able message.
const legacy = makeRec({ format: 1 });
const legacyErr = SaveManager.validateRecord(legacy);
check('validateRecord: a legacy (format 1) save is rejected', typeof legacyErr === 'string' && legacyErr.length > 0);
check('validateRecord: the legacy message names the format gap (the UI toasts it)',
typeof legacyErr === 'string' && legacyErr.includes('1') && legacyErr.includes(String(SAVE_FORMAT)));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -1,179 +0,0 @@
/**
* Star test (dev tool, run with Node no browser needed):
*
* node dev/star.test.mjs
*
* Stubs just enough of Phaser to construct the REAL Star from
* js/entities/Star.js against the real data/planets.json config, then
* asserts the central-body rules (the star is the central body of every
* NON-HOME system see js/scenes/GameScene.js):
* - the solid rim is half the configured star.size (1024-class worlds,
* a bigger star: 1536 px across by default);
* - the ship can come to shipClearance px (edge-to-edge) from the rim,
* but never closer, and never moves through the star;
* - a graze keeps its tangential velocity (slides along the rim);
* - edgePoint() places a point exactly `gap` off the rim;
* - one texture per spectral class (generated once, keyed star-<cls>).
*/
import { pathToFileURL, fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// --- Phaser stub: Image + Graphics + Display ------------------------------
class Image {
constructor(scene, x, y, key, frame) {
this.scene = scene; this.x = x; this.y = y;
this.key = key; this.frame = frame;
this.scaleX = 1; this.scaleY = 1;
this.scale = 1;
}
setScale(s) { this.scaleX = s; this.scaleY = s; this.scale = s; return this; }
}
const Sprite = class extends Image {};
class Graphics {
fillStyle() { return this; }
fillCircle() { return this; }
generateTexture(key, w, h) { made.push({ key, w, h }); return this; }
destroy() { this.destroyed = true; }
}
function hexToRgbInt(v) {
const m = String(v).trim().match(/^#?([0-9a-f]{6})$/i);
return m ? parseInt(m[1], 16) : 0xffffff;
}
const made = []; // generateTexture calls
globalThis.window = {
Phaser: {
GameObjects: { Image, Sprite },
Display: { Color: { ValueToColor: (v) => ({ color: hexToRgbInt(v) }) } },
},
};
// --- Load the real config (data/*.json) into the config singleton --------
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);
console.log('config loaded from data/:', Object.keys(configData).join(', '));
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const { Star } = await import(pathToFileURL(join(__dirname, '../js/entities/Star.js')).href);
// A fake scene: textures.exists + make.graphics + add.existing.
const scene = {
add: { existing: (o) => o },
textures: { exists: (k) => made.some((t) => t.key === k) },
make: { graphics: ({ add } = {}) => new Graphics() },
};
// --- 1. Real config: solid rim + keep-out distances -----------------------
// Off-origin on purpose: proves nothing is baked to (0, 0).
const star = new Star(scene, 100, -50, { name: 'Kestrel', class: 'k' });
const wantSize = config.get('planets.star.size', 1536);
const wantRim = wantSize / 2;
check(`star rim is half star.size (got ${star.radius}, want ${wantRim})`, star.radius === wantRim);
check(`default star is 1536 px across (got ${star.size})`, star.size === 1536);
check(`texture key is per-class (got "${star.key}", want "star-k")`, star.key === 'star-k');
check(`class is normalized to uppercase (got "${star.starClass}")`, star.starClass === 'K');
const shipRadius = (config.get('ship.size', 46) * config.get('ship.scale', 1)) / 2;
const clearance = star.clearance;
const minDist = star.minCenterDistance(shipRadius);
const ship = (x, y, vx = 0, vy = 0) => ({ x, y, body: { velocity: { x: vx, y: vy }, acceleration: { x: 0, y: 0 } } });
const dist = (p) => Math.hypot(p.x - star.x, p.y - star.y);
check(`keep-out = rim + clearance + ship radius (got ${minDist}, want ${wantRim + clearance + shipRadius})`,
minDist === wantRim + clearance + shipRadius);
// --- 2. Outside the keep-out circle: untouched ----------------------------
{
const s = ship(star.x + minDist + 10, star.y);
star.constrainShip(s, shipRadius);
check('outside the keep-out circle: ship untouched',
s.x === star.x + minDist + 10 && s.y === star.y);
}
// Exactly on the boundary: allowed.
{
const s = ship(star.x + minDist, star.y, -100, 0); // on the line, moving in
star.constrainShip(s, shipRadius);
check('on the boundary with inward velocity: position held, inward velocity removed',
dist(s) === minDist && s.body.velocity.x >= 0);
}
// Deep inside: pushed out to the rim line.
{
const s = ship(star.x + 10, star.y, -200, 0);
star.constrainShip(s, shipRadius);
check('inside the keep-out circle: pushed out to the rim line',
Math.abs(dist(s) - minDist) < 1e-9);
}
// A graze on the rim keeps its tangential velocity (slides along the rim),
// and the inward part of any velocity is stripped.
{
const s = ship(star.x + minDist, star.y, -50, 300); // on the line, partly along the rim
star.constrainShip(s, shipRadius);
check('graze: tangential velocity kept, inward part stripped (got vx=' + s.body.velocity.x + ', vy=' + s.body.velocity.y + ')',
s.body.velocity.x === 0 && s.body.velocity.y === 300 && dist(s) === minDist);
}
// Dead center: pushed out along +x (deterministic).
{
const s = ship(star.x, star.y, 0, 0);
star.constrainShip(s, shipRadius);
check('dead center: pushed out along +x', s.y === star.y && s.x > star.x);
}
// --- 3. edgePoint / aimPoint ----------------------------------------------
{
const p = star.edgePoint(0.7, 150, shipRadius);
check(`edgePoint sits rim + gap + ship off the rim (got ${dist(p).toFixed(3)})`,
Math.abs(dist(p) - (wantRim + 150 + shipRadius)) < 1e-9);
}
{
const inside = { x: star.x + 10, y: star.y };
const out = star.aimPoint(inside.x, inside.y, shipRadius);
check(`aimPoint projects inside points onto the rim (got ${dist(out).toFixed(3)})`,
Math.abs(dist(out) - minDist) < 1e-9);
const pas = star.aimPoint(star.x + minDist + 5, star.y, shipRadius);
check('aimPoint passes outside points through unchanged',
pas.x === star.x + minDist + 5 && pas.y === star.y);
}
// --- 4. Textures: one per spectral class, generated once ------------------
{
const before = made.length;
new Star(scene, 0, 0, { name: 'Sol', class: 'G' }); // star-g: fresh
check('new class generates its texture (star-g present)', made.some((t) => t.key === 'star-g'));
new Star(scene, 0, 0, { name: 'Betelgeuse', class: 'M' }); // star-m: fresh
new Star(scene, 0, 0, { name: 'Kestrel', class: 'k' }); // star-k: already made
const k = made.filter((t) => t.key === 'star-k').length;
check('existing class reuses its texture (star-k generated exactly once, got ' + k + ')', k === 1);
check('texture size = rim × 2 × glowFactor',
made.filter((t) => t.key === 'star-m')[0].w === Math.ceil(wantRim * config.get('planets.star.glow.radiusFactor', 1.55)) * 2);
check(`texture generation calls total ${before + 2} (star-g + star-m)`, made.length === before + 2);
}
// Unknown spectral class: no crash, its own texture with the G fallback hue.
{
const s = new Star(scene, 0, 0, { name: 'X', class: 'zzz' });
check('unknown class gets its own texture (star-zzz, G fallback hue)',
s.key === 'star-zzz' && made.some((t) => t.key === 'star-zzz'));
const s2 = new Star(scene, 0, 0, { name: 'Y', class: undefined });
check('missing class defaults to G (got "' + s2.key + '")', s2.key === 'star-g');
}
console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`);
process.exit(failures === 0 ? 0 : 1);

View File

@ -144,6 +144,8 @@ check('run: restore keeps the project in flight (25 s left)',
restored.getActive()?.id === gatesId && restored.progress(900_000).fraction < 1 && restored.progress(925_000).fraction >= 1); restored.getActive()?.id === gatesId && restored.progress(900_000).fraction < 1 && restored.progress(925_000).fraction >= 1);
// -- the chart gate: NAV points + completion ------------------------------ // -- the chart gate: NAV points + completion ------------------------------
// A NON-home system (no homeName — its center is empty, the star is
// invisible flavor): NAV points = planets + space stations + gates.
const content = { const content = {
name: NAME, name: NAME,
type: 'anchored', type: 'anchored',
@ -155,41 +157,46 @@ const content = {
jumps: [{ id: `${SYS}-j1`, to: 'S000043' }, { id: `${SYS}-j2`, to: 'S000044' }], jumps: [{ id: `${SYS}-j1`, to: 'S000043' }, { id: `${SYS}-j2`, to: 'S000044' }],
asteroids: [{ id: `${SYS}-a1` }], // clusters are objects, not NAV points asteroids: [{ id: `${SYS}-a1` }], // clusters are objects, not NAV points
}; };
check('nav: the NAV points = central body + planets + space stations + gates', const POINT_IDS = ['K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`, `${SYS}-j2`];
JSON.stringify(navPointIds(content)) === JSON.stringify(['home', 'K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`, `${SYS}-j2`])); check('nav: non-home — the NAV points = planets + space stations + gates (no central body)',
JSON.stringify(navPointIds(content)) === JSON.stringify(POINT_IDS));
check('nav: the home system (homeName present) carries the central NAV point first',
JSON.stringify(navPointIds({ homeName: 'Terra', ...content })) ===
JSON.stringify(['home', ...POINT_IDS]));
const makeDiscovery = (found) => { const makeDiscovery = (found) => {
const set = new Set(found); const set = new Set(found);
return { isDiscovered: (_sys, id) => set.has(id) }; return { isDiscovered: (_sys, id) => set.has(id) };
}; };
check('nav: incomplete while any NAV point is undiscovered', check('nav: incomplete while any NAV point is undiscovered',
isNavComplete(makeDiscovery(['home', 'K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`]), SYS, content) === false); isNavComplete(makeDiscovery(['K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`]), SYS, content) === false);
check('nav: complete once EVERY NAV point is discovered', check('nav: complete once EVERY NAV point is discovered',
isNavComplete(makeDiscovery(['home', 'K-1', 'K-2', `${SYS}-s1`, `${SYS}-j2`, `${SYS}-j1`]), SYS, content) === true); isNavComplete(makeDiscovery(POINT_IDS), SYS, content) === true);
check('nav: a missing discovery state never counts as complete', isNavComplete(null, SYS, content) === false); check('nav: a missing discovery state never counts as complete', isNavComplete(null, SYS, content) === false);
// navPoints / navChart — the diagnostic's pure core (orbitNav builds on this) // navPoints / navChart — the diagnostic's pure core (orbitNav builds on this)
check('navPoints: each NAV point carries its kind (home/planet/station/gate)', check('navPoints: each NAV point carries its kind (planet/station/gate)',
JSON.stringify(navPoints(content)) === JSON.stringify([ JSON.stringify(navPoints(content)) === JSON.stringify([
{ id: 'home', kind: 'home' },
{ id: 'K-1', kind: 'planet' }, { id: 'K-2', kind: 'planet' }, { id: 'K-1', kind: 'planet' }, { id: 'K-2', kind: 'planet' },
{ id: `${SYS}-s1`, kind: 'station' }, { id: `${SYS}-s1`, kind: 'station' },
{ id: `${SYS}-j1`, kind: 'gate' }, { id: `${SYS}-j2`, kind: 'gate' }, { id: `${SYS}-j1`, kind: 'gate' }, { id: `${SYS}-j2`, kind: 'gate' },
])); ]));
check('navPoints: the home system leads with the central NAV point',
JSON.stringify(navPoints({ homeName: 'Terra', ...content })[0]) === JSON.stringify({ id: 'home', kind: 'home' }));
const chartMissing = navChart(makeDiscovery(['home', 'K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`]), SYS, content); const chartMissing = navChart(makeDiscovery(['K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`]), SYS, content);
check('navChart: reports the discovered/missing split (5 of 6, j2 still out)', check('navChart: reports the discovered/missing split (4 of 5, j2 still out)',
chartMissing.discovered === 5 && chartMissing.total === 6 && chartMissing.complete === false chartMissing.discovered === 4 && chartMissing.total === 5 && chartMissing.complete === false
&& JSON.stringify(chartMissing.missing) === JSON.stringify([`${SYS}-j2`]) && JSON.stringify(chartMissing.missing) === JSON.stringify([`${SYS}-j2`])
&& chartMissing.points.find((p) => p.id === `${SYS}-j2`)?.discovered === false); && chartMissing.points.find((p) => p.id === `${SYS}-j2`)?.discovered === false);
const chartDone = navChart(makeDiscovery(['home', 'K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`, `${SYS}-j2`]), SYS, content); const chartDone = navChart(makeDiscovery(POINT_IDS), SYS, content);
check('navChart: complete once every NAV point is discovered (6 of 6)', check('navChart: complete once every NAV point is discovered (5 of 5)',
chartDone.discovered === 6 && chartDone.total === 6 && chartDone.complete === true && chartDone.missing.length === 0); chartDone.discovered === 5 && chartDone.total === 5 && chartDone.complete === true && chartDone.missing.length === 0);
const chartNoState = navChart(null, SYS, content); const chartNoState = navChart(null, SYS, content);
check('navChart: a missing discovery state shows nothing discovered (0 of 6)', check('navChart: a missing discovery state shows nothing discovered (0 of 5)',
chartNoState.discovered === 0 && chartNoState.total === 6 && chartNoState.complete === false); chartNoState.discovered === 0 && chartNoState.total === 5 && chartNoState.complete === false);
// node id <-> system id — the "researched but the gates stay dark" regression. // node id <-> system id — the "researched but the gates stay dark" regression.
// A node OBJECT has no `id` field (its id is the key in the tree's `nodes` // A node OBJECT has no `id` field (its id is the key in the tree's `nodes`

View File

@ -81,7 +81,7 @@ The galaxy is **seeded and two-level**. The seed is chosen on the main
menu (displayed, editable, rerollable; same seed ⇒ same galaxy). menu (displayed, editable, rerollable; same seed ⇒ same galaxy).
1. **Roster**`Galaxy.create(seed)` builds every system's *identity* 1. **Roster**`Galaxy.create(seed)` builds every system's *identity*
(id, name, type, x, y) up front. 200 systems is ~15 ms, so the whole (id, name, type, x, y) up front. 60 systems is ~10 ms, so the whole
galaxy is always known: the player can never "discover" a layout that galaxy is always known: the player can never "discover" a layout that
wasn't already implied by the seed. wasn't already implied by the seed.
2. **Contents** — planets/moons/belts/**settlements**/hazards are 2. **Contents** — planets/moons/belts/**settlements**/hazards are
@ -108,12 +108,17 @@ menu (displayed, editable, rerollable; same seed ⇒ same galaxy).
habitability, hazard, free-space settlement odds). New attribute key = habitability, hazard, free-space settlement odds). New attribute key =
JSON + a few lines in `SystemGenerator.js`; JSON + a few lines in `SystemGenerator.js`;
- **object count is global, not per-type** — `data/systems.json → - **object count is global, not per-type** — `data/systems.json →
objectCount`: `barren` (10%) of all systems are jump-gate-only (no objectCount`: `barren` (10%) of all systems are gate-only DEAD-END
planets, no stations — `barren``0`), the rest hold a weighted whole LEAVES of the jump network (no planets, no stations — `barren``0`;
number of OBJECTS (planets + free-space stations) from the `objects` exactly one gate — JumpNetwork keeps them at the tree's leaves), the
table (2/3/4/5). Stations roll first (02, per-type odds × the core→rim rest hold a weighted whole number of OBJECTS (planets + free-space
density), then planets fill the remaining budget (N stations). The stations) from the `objects` table (2/3/4/5). Stations roll first
**starting system** is the exception: it always holds exactly two (02, per-type odds × the core→rim density), then planets fill the
remaining budget (N stations); if the station roll would consume the
whole budget it is demoted to a planet, so every non-barren system
holds ≥ 1 planet — the gate's tether anchor and the place the player
can build out from. The **starting system** is the exception: it
always holds exactly two
generated planets — a gas giant and a rocky world — which with the home generated planets — a gas giant and a rocky world — which with the home
world (the origin, the player's homestead, not a generated planet) makes world (the origin, the player's homestead, not a generated planet) makes
its three planets, always. its three planets, always.
@ -207,11 +212,12 @@ world** — solid, rendered, flyable-to. Rules and seams:
- **World layout** — `SystemGenerator.layoutSystem(seed, systemId, planets, - **World layout** — `SystemGenerator.layoutSystem(seed, systemId, planets,
freeSpace, isHome, targetAngles)` (pure, `js/galaxy/SystemGenerator.js`) freeSpace, isHome, targetAngles)` (pure, `js/galaxy/SystemGenerator.js`)
places each system's planets and free-space stations on a ring (a places each system's planets and free-space stations on a ring (a
regular polygon) around the central body (the home world at the origin regular polygon) around the system center (the home world at the
in the starting system, the star elsewhere), enforcing the SOLAR 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 SYSTEM BAND in `data/planets.json → solarSystem`: EVERY pair of layout
objects — planets, stations, the central body — sits center-to-center objects — planets, stations (and the home world, starting system
in `[minSpacing, maxSpacing]` (640015360 px; the starting system's only) — sits center-to-center in `[minSpacing, maxSpacing]` (640015360 px; the starting system's
band tightens to `[minSpacing, homeMaxSpacing]` = 640010240 px). The band tightens to `[minSpacing, homeMaxSpacing]` = 640010240 px). The
ring radius is chosen inside the band (non-home: `maxSpacing` over 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 chord of the N-gon; home: the (N+1)-gon side over the band's tight
@ -229,12 +235,13 @@ world** — solid, rendered, flyable-to. Rules and seams:
testable, save-ready: `toJSON()`/`fromJSON()`). Rule: the ship within testable, save-ready: `toJSON()`/`fromJSON()`). Rule: the ship within
`game.discovery.distance` (data/game.json, default 540 px) of an `game.discovery.distance` (data/game.json, default 540 px) of an
object's *edge* (center distance ≤ radius + distance) discovers it, object's *edge* (center distance ≤ radius + distance) discovers it,
once, per system. The central body is discovered at spawn in EVERY once, per system. The home world is discovered at spawn in the starting
system: the home world (the ship starts beside it) in the starting system (the ship starts beside it) — "Home World" exists in exactly one
system — "Home World" exists in exactly one system — and the system's system and is that system's central NAV point (id 'home'); every other
STAR ("G-type Star" etc., `js/entities/Star.js`) in every other one; system's center is EMPTY (the star is invisible dossier flavor —
both read the chart's central NAV point (id 'home'). Feedback: rim name/class, never rendered) and carries no central NAV point. Feedback:
ping + "DISCOVERED — NAME · TYPE" toast (GameScene.celebrateDiscovery). rim ping + "DISCOVERED — NAME · TYPE" toast
(GameScene.celebrateDiscovery).
- **Compass**`js/ui/DiscoveryCompass.js` (screen-space Container, - **Compass**`js/ui/DiscoveryCompass.js` (screen-space Container,
scrollFactor 0). Every frame it refreshes the set of **discovered** scrollFactor 0). Every frame it refreshes the set of **discovered**
objects that are **off-screen**, drawing a themed chevron arrow on the objects that are **off-screen**, drawing a themed chevron arrow on the
@ -243,8 +250,8 @@ world** — solid, rendered, flyable-to. Rules and seams:
exported pure for tests. Arrow texture is generated procedurally on exported pure for tests. Arrow texture is generated procedurally on
first use (`__compass_arrow`). first use (`__compass_arrow`).
- **World solids** — the ship collides with *every* solid in the system - **World solids** — the ship collides with *every* solid in the system
(`GameScene.solids`): the central body (the home world in the starting (`GameScene.solids`): the home world (starting system only — it is the
system, the star elsewhere — both the 'home' NAV point), all system only central body in the galaxy, and its 'home' NAV point), all system
planets, asteroid clusters, stations and jump gates: you can approach planets, asteroid clusters, stations and jump gates: you can approach
a rim, never pass through. a rim, never pass through.
- Verified: `dev/discovery.test.mjs` (rule, boundaries, per-system - Verified: `dev/discovery.test.mjs` (rule, boundaries, per-system
@ -260,16 +267,25 @@ exit toward a NEARBY star on the 2-D map. Two layers:
`galaxy.jumpNetwork` / `galaxy.jumpGatesFor(id)`). It is a `galaxy.jumpNetwork` / `galaxy.jumpGatesFor(id)`). It is a
degree-limited spanning tree of the nearest-star graph (each system's degree-limited spanning tree of the nearest-star graph (each system's
`neighborPool` = 8 closest stars, `galaxy.neighborsOf`), with tree `neighborPool` = 8 closest stars, `galaxy.neighborsOf`), with tree
edges run BOTH ways plus optional one-way `shortcuts` bought with the edges run BOTH ways; `shortcuts` (one-way extra edges bought with the
spare gate budget. Result: the directed graph is **strongly spare gate budget) is a dormant code path, OFF in the current config
connected** (from any star you can reach any other — no closed (`data/gates.json → shortcuts: false`) — so the network is a PURE
systems, no trapped sets), every jump is local, and no system holds SPANNING TREE: exactly one route between any two systems, no closed
more than `maxGates` gates. A 3-tier repair (local attach → swap → loops. The galaxy reads as a **MAZE** of dead ends and long hauls;
last-resort attach, logged) covers pathological pools, preferring a strong connectivity still holds by construction (a bidirected tree —
working network over the cap. from any system you can reach any other — no closed systems, no
trapped sets), every jump is local, no system holds more than
`maxGates` gates, and EVERY jump has a return gate (the destination's
gate pointing back). A BARREN system is a dead-end LEAF of the tree —
exactly one gate, in and out the same way (the function takes the
barren set and never lets a barren node adopt children; the repair
pass prefers non-barren attach targets). A 3-tier repair (local
attach → swap → last-resort attach, logged) covers pathological pools,
preferring a working network over the cap.
- **The in-system placement**`SystemGenerator.layoutGates(...)` (pure) - **The in-system placement**`SystemGenerator.layoutGates(...)` (pure)
places each gate on the tether circle of one of the system's ANCHORS places each gate on the tether circle of one of the system's ANCHORS
(a planet, a free-space station, or the home world in the starting (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 system), exactly `anchorTetherLevel` (level 1 = 5120 px, from
`data/tether.json`) from it — so the tether rule is hard by `data/tether.json`) from it — so the tether rule is hard by
construction. Candidates are tried in facing quality: the ray-circle construction. Candidates are tried in facing quality: the ray-circle
@ -277,12 +293,16 @@ exit toward a NEARBY star on the 2-D map. Two layers:
aimed at the star, then a ±75° scan — so the direction rule is soft 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 (same side of the anchor as the target, deviation < 90°; in practice
~98% are within 75°). Gates stay `minRadius..maxRadius` (204820480 ~98% are within 75°). Gates stay `minRadius..maxRadius` (204820480
px) from the star, keep `size` + `clearance` from anchor discs and 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 2·`size` + `gateGap` from each other, and get unique names ("Avidy
Gate", "Avidy Gate II" — star names are syllable-generated and Gate", "Avidy Gate II" — star names are syllable-generated and
collide). **Barren systems** (no anchors — the `objectCount` → 0 stops) collide). **Barren systems** (no anchors — the `objectCount` → 0 stops)
get their single gate on the star→destination ray at `barrenDistance` get their single gate on the center→destination ray at `barrenDistance`
(8192 px) instead. Every gate record carries `active: false` — gates (8192 px) instead; their payload is 12 asteroid clusters drifting
inside that gate's level-1 tether (`data/asteroids.json → barren` — the
dead end's only thing to mine). Every gate record carries
`active: false` — gates
are DORMANT until activated (the entity renders dim, field still); are DORMANT until activated (the entity renders dim, field still);
activation is the SYSTEM research category's jumpgate tech completing activation is the SYSTEM research category's jumpgate tech completing
(the Research section below): the system's gates go live plus the (the Research section below): the system's gates go live plus the
@ -317,10 +337,10 @@ collide). **Barren systems** (no anchors — the `objectCount` → 0 stops)
just past the destination's RETURN gate's keepout (the gate pointing just past the destination's RETURN gate's keepout (the gate pointing
back — activated with its twin per ACTIVITY, its tether anchoring the back — activated with its twin per ACTIVITY, its tether anchoring the
landing), offset back along its facing with nose along the travel landing), offset back along its facing with nose along the travel
direction; one-way SHORTCUT jumps have no return gate (JumpNetwork: direction; with shortcuts OFF (the current config) the network is a
tree edges run both ways, shortcuts don't — ~⅓ of a typical galaxy's pure spanning tree, so every jump has a return gate — JumpTravel's
gates) and land on the destination's star, inside its home-tether null path (land on the destination's origin, inside its home-tether
zone. THE CLIP: the jump plays a full-screen one-shot between the two zone) is defensive only. THE CLIP: the jump plays a full-screen one-shot between the two
systems (`jump.video`, cover-scaled over an opaque theme backdrop — the systems (`jump.video`, cover-scaled over an opaque theme backdrop — the
source system must not show through; `jump.videoVolume` 0..1, 0 = source system must not show through; `jump.videoVolume` 0..1, 0 =
silent). The clip + backdrop are SCREEN-pinned (`scrollFactor(0)`) — silent). The clip + backdrop are SCREEN-pinned (`scrollFactor(0)`) —
@ -435,9 +455,11 @@ one shared category without collisions:
Copy: *Added the Solar System of {system} to the onboard Copy: *Added the Solar System of {system} to the onboard
NAV System. Discover all NAV points to unlock the system Jumpgates.* NAV System. Discover all NAV points to unlock the system Jumpgates.*
- **`Unlock {System} Jumpgates`** — requires the map; researchable once - **`Unlock {System} Jumpgates`** — requires the map; researchable once
**every NAV point of the system is discovered** (the central body, all **every NAV point of the system is discovered** (the home world in the
planets, all space stations, all jump gates — the scene's discoverable starting system, and otherwise all planets, all space stations, all
set minus the asteroid clusters); 45 s jump gates — the scene's discoverable
set minus the asteroid clusters; the center of a non-home system is
empty, so it carries no central NAV point); 45 s
(`data/gates.json → activation.researchDuration`). Completing it (`data/gates.json → activation.researchDuration`). Completing it
activates the system's gates **and the return gates in the systems activates the system's gates **and the return gates in the systems
they connect to** (pure, from the jump network alone — they connect to** (pure, from the jump network alone —
@ -539,11 +561,12 @@ than follow-on tech. Each node's `unlocks` is the declaration side:
idempotently). Node-tested by `dev/system-category.test.mjs`. idempotently). Node-tested by `dev/system-category.test.mjs`.
- `js/galaxy/JumpTravel.js` — PURE (no Phaser): the jump's arrival - `js/galaxy/JumpTravel.js` — PURE (no Phaser): the jump's arrival
geometry. `returnGateFor(content, fromId)` (the destination's gate geometry. `returnGateFor(content, fromId)` (the destination's gate
pointing back at the system left — one-way shortcuts have none), pointing back at the system left — with shortcuts OFF it always
exists; the null path is defensive),
`arrivalPoint(gate, cfg)` (spawn just past the keepout, on the far `arrivalPoint(gate, cfg)` (spawn just past the keepout, on the far
side of the gate's facing, nose along the travel direction), side of the gate's facing, nose along the travel direction),
`jumpArrival(content, fromId, cfg)` (both; null ⇒ the scene lands on `jumpArrival(content, fromId, cfg)` (both; null ⇒ the scene lands on
the destination's star). Driven by `GameScene.jumpThroughGate` the destination's origin — defensive only). Driven by `GameScene.jumpThroughGate`
(the transport — save pipeline + scene restart); Node-tested by (the transport — save pipeline + scene restart); Node-tested by
`dev/jump-travel.test.mjs` (contract, geometry, a real galaxy, `dev/jump-travel.test.mjs` (contract, geometry, a real galaxy,
determinism). determinism).
@ -899,7 +922,7 @@ The player holds a REPUTATION (standing) on each planet and space station:
- [x] v0.1 foundation — menu → New Game → click-to-fly ship - [x] v0.1 foundation — menu → New Game → click-to-fly ship
- [x] Galaxy seed on the main menu (displayed, editable, rerollable; - [x] Galaxy seed on the main menu (displayed, editable, rerollable;
same seed → same galaxy, shown before you commit) same seed → same galaxy, shown before you commit)
- [x] Two-level worldgen: seeded galaxy roster (200 systems) + lazy, - [x] Two-level worldgen: seeded galaxy roster (60 systems) + lazy,
order-independent system contents; system archetypes in JSON order-independent system contents; system archetypes in JSON
(theme + attributes + distribution weight/radius band) (theme + attributes + distribution weight/radius band)
- [x] The lived-in layer: settlements (colonies, mining stations, cloud - [x] The lived-in layer: settlements (colonies, mining stations, cloud

View File

@ -1,156 +0,0 @@
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor } from '../utils/Color.js';
import { Planet } from './Planet.js';
/**
* A STAR the central body of every NON-HOME system (content.star from
* the generator: name + spectral class). The starting system's central
* body is the player's home world (js/entities/Planet.js) and only there
* "Home World" exists in exactly one system in the galaxy.
*
* Solid like a planet: the keep-out circle is the disc rim (half the
* configured size the halo is visual only), the ship keeps
* `planets.shipClearance` px (edge-to-edge) off it and can never move
* through it (the same plain-circle rule, GameScene.solids). It is
* discoverable (the 'home' NAV point of the chart every system has a
* central body) but NOT a comms target: there is no surface to land on.
*
* The look is procedural (data/planets.json star): an opaque body
* a white core melting into the spectral class's hue plus a soft halo,
* band-filled with Graphics (same pattern as js/visuals/Starfield.js, so
* no canvas API and no art asset). One texture per spectral class,
* generated on first use.
*/
export class Star extends Phaser.GameObjects.Image {
/**
* @param {Phaser.Scene} scene
* @param {number} x world x of the star's center
* @param {number} y world y
* @param {object} [star={}] content.star ({ name, class })
*/
constructor(scene, x, y, star = {}) {
const cls = String(star?.class ?? 'G').toUpperCase();
const key = `star-${cls.toLowerCase()}`;
if (!scene.textures.exists(key)) Star.makeTexture(scene, key, cls);
super(scene, x, y, key);
scene.add.existing(this);
this.starClass = cls;
this.name = star?.name ?? ''; // display name (the scene stamps discoveryName)
// The solid disc: diameter from data/planets.json → star.size (the
// halo extends past the rim but is not part of the keep-out).
this.size = Math.max(64, Number(config.get('planets.star.size', 1536)) || 1536);
this.setScale(1);
this.radius = this.size / 2;
// Edge-to-edge gap the ship may close in on the rim (never less).
this.clearance = config.get('planets.shipClearance', 50);
}
/** Minimum allowed center-to-center distance for a ship of `shipRadius`. */
minCenterDistance(shipRadius = 0) {
return this.radius + this.clearance + shipRadius;
}
/**
* A world point `gap` px (edge-to-edge) off this star's rim at `angle`
* radians e.g. a spawn or approach point.
*/
edgePoint(angle, gap, shipRadius = 0) {
const d = this.radius + gap + shipRadius;
return { x: this.x + Math.cos(angle) * d, y: this.y + Math.sin(angle) * d };
}
/**
* A world point the ship may be sent to: a point inside the keep-out
* circle (e.g. a click on the star itself, or through it) is projected
* out onto the rim, along the ray from the center so the ship always
* has a reachable destination and is never told to go inside.
*/
aimPoint(wx, wy, shipRadius = 0) {
const minDist = this.minCenterDistance(shipRadius);
const dx = wx - this.x;
const dy = wy - this.y;
const dist = Math.hypot(dx, dy);
if (dist >= minDist) return { x: wx, y: wy };
if (dist === 0) return { x: this.x + minDist, y: this.y }; // dead center: +x
return { x: this.x + (dx / dist) * minDist, y: this.y + (dy / dist) * minDist };
}
/**
* Keep a ship out of the star: same plain-circle rule as Planet
* push the center out to `minCenterDistance`, strip the inward part of
* velocity and acceleration (the tangential part slides along the rim).
* Returns true when it actually touched the ship (contact see
* Planet.constrainShip).
*/
constrainShip(ship, shipRadius = 0) {
const body = ship.body;
const r = Planet.resolve(
this.x, this.y, this.minCenterDistance(shipRadius),
ship.x, ship.y,
body.velocity.x, body.velocity.y,
body.acceleration ? body.acceleration.x : 0,
body.acceleration ? body.acceleration.y : 0,
);
const touched =
r.x !== ship.x || r.y !== ship.y ||
r.vx !== body.velocity.x || r.vy !== body.velocity.y ||
(body.acceleration &&
(r.ax !== body.acceleration.x || r.ay !== body.acceleration.y));
ship.x = r.x;
ship.y = r.y;
body.velocity.x = r.vx;
body.velocity.y = r.vy;
if (body.acceleration) {
body.acceleration.x = r.ax;
body.acceleration.y = r.ay;
}
return touched;
}
/**
* The star's texture for one spectral class: an opaque body (a white
* core melting into the class hue, data/planets.json
* star.classColor) + a soft halo (star.glow). Band-filled circles,
* outer inner, via Graphics no canvas API, one texture per class.
*/
static makeTexture(scene, key, cls) {
const coreR = Math.max(32, Math.floor(config.get('planets.star.size', 1536) / 2));
const glowFactor = Math.max(1, Number(config.get('planets.star.glow.radiusFactor', 1.55)) || 1.55);
const glowAlpha = Math.min(1, Math.max(0, Number(config.get('planets.star.glow.alpha', 0.5)) || 0));
const color = toColor(config.get(`planets.star.classColor.${cls}`, '#ffe9b0'));
const R = Math.ceil(coreR * glowFactor);
const T = Math.ceil(R * 2);
const c = T / 2;
// Blend the class hue toward white (t = 1 → white).
const mix = (t) => {
const ch = (sh) => {
const v = (color >> sh) & 255;
return Math.round(v + (255 - v) * t);
};
return (ch(16) << 16) | (ch(8) << 8) | ch(0);
};
const g = scene.make.graphics({ add: false });
// Halo: rim → edge, alpha growing to the rim (0 at the edge).
const H = 14;
for (let i = 0; i < H; i++) {
const t = i / (H - 1); // 0 = edge, 1 = rim
g.fillStyle(color, glowAlpha * t * t);
g.fillCircle(c, c, R - (R - coreR) * t);
}
// Body: rim → core, opaque; the hue melts into a white core.
const B = 24;
for (let i = 0; i < B; i++) {
const t = i / (B - 1); // 0 = rim, 1 = core
g.fillStyle(mix(Math.pow(t, 1.4)), 1);
g.fillCircle(c, c, coreR * (1 - t * 0.999));
}
g.generateTexture(key, T, T);
g.destroy();
}
}

View File

@ -3,7 +3,7 @@ import { Rng } from '../utils/Rng.js';
import { NameGenerator } from '../utils/NameGenerator.js'; import { NameGenerator } from '../utils/NameGenerator.js';
import { buildJumpNetwork } from './JumpNetwork.js'; import { buildJumpNetwork } from './JumpNetwork.js';
import { assignPlanetFrames } from './PlanetFrames.js'; import { assignPlanetFrames } from './PlanetFrames.js';
import { generateSystemContent } from './SystemGenerator.js'; import { generateSystemContent, rollSystemComposition, settlementDensity } from './SystemGenerator.js';
const TAU = Math.PI * 2; const TAU = Math.PI * 2;
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
@ -20,7 +20,7 @@ const wrapPI = (a) => {
* *
* 1. GALAXY ROSTER generated once, up front, when New Game is pressed: * 1. GALAXY ROSTER generated once, up front, when New Game is pressed:
* `systemCount` lightweight records ({ id, name, type, x, y }). * `systemCount` lightweight records ({ id, name, type, x, y }).
* This is cheap: 200 systems is a few ms and a few hundred KB. * This is cheap: the 60-system default is a few ms and a few hundred KB.
* It fixes the shape of the galaxy, where every system sits, and * It fixes the shape of the galaxy, where every system sits, and
* what KIND each one is for the entire galaxy, from the seed alone. * what KIND each one is for the entire galaxy, from the seed alone.
* *
@ -177,13 +177,32 @@ export class Galaxy {
cell.push(rec); cell.push(rec);
} }
// The BARREN set — the gate-only dead ends (objectCount → 0). The
// SAME roll the content generator uses (dedicated per-system forks —
// rollSystemComposition), so the network and the content always agree:
// JumpNetwork keeps every barren system a LEAF of the tree (one gate —
// in and out the same way), the maze's dead ends.
const typeDefs = this.typeDefs ?? {};
const barren = new Set();
for (const record of records) {
if (record.id === this.currentSystemId) continue; // home is exempt
const type = typeDefs[record.type] ?? {};
const density = settlementDensity({ params: this.params }, record);
const composition = rollSystemComposition(
this.seed, record, false, type.attributes?.settlements ?? {}, density, type.attributes ?? {},
);
if (composition.classes.length === 0) barren.add(record.id);
}
// The JUMP NETWORK (data/gates.json): which star each system's jump // The JUMP NETWORK (data/gates.json): which star each system's jump
// gates reach. A bidirected, degree-limited spanning tree of the // gates reach. A PURE SPANNING TREE of the nearest-star graph (no
// nearest-star graph (plus local shortcuts) — STRONGLY CONNECTED, so // shortcuts — data/gates.json → shortcuts:false) — the galaxy reads as
// there are no closed systems and no trapped sets: from any star the // a MAZE: exactly one route between any two systems, no closed loops.
// player can reach any other, and every system holds // Tree edges run BOTH ways, so from any system the player can reach
// minGatesmaxGates gates (13 in data/gates.json). Deterministic: // any other and every jump has a RETURN gate (no trapped sets). Each
// same roster ⇒ same network. // system holds 1maxGates gates (its tree degree); a BARREN system is
// a dead-end LEAF — exactly one gate (in and out the same way).
// Deterministic: same roster ⇒ same network.
const pool = Math.max(1, Math.min(count - 1, (config.get('gates.neighborPool', 8) | 0))); const pool = Math.max(1, Math.min(count - 1, (config.get('gates.neighborPool', 8) | 0)));
this.jumpNetwork = buildJumpNetwork({ this.jumpNetwork = buildJumpNetwork({
records, records,
@ -191,6 +210,7 @@ export class Galaxy {
minGates: Math.max(0, config.get('gates.minGates', 1) | 0), minGates: Math.max(0, config.get('gates.minGates', 1) | 0),
maxGates: Math.max(1, config.get('gates.maxGates', 3) | 0), maxGates: Math.max(1, config.get('gates.maxGates', 3) | 0),
shortcuts: config.get('gates.shortcuts', true) === true, shortcuts: config.get('gates.shortcuts', true) === true,
barren,
rootId: this.currentSystemId, rootId: this.currentSystemId,
}); });
// Defensive repairs that had to fire (0 on a healthy kNN graph). // Defensive repairs that had to fire (0 on a healthy kNN graph).

View File

@ -15,6 +15,12 @@
* any other (dev/jumps.test.mjs verifies forward AND * any other (dev/jumps.test.mjs verifies forward AND
* backward reachability from the home system). * backward reachability from the home system).
* BOUNDED minGates gates(system) maxGates (1..3 in practice). * BOUNDED minGates gates(system) maxGates (1..3 in practice).
* MAZE with shortcuts OFF (the current config, data/gates.json
* shortcuts:false) the graph is a PURE SPANNING TREE: exactly
* one route between any two systems, no closed loops. The
* galaxy reads as a maze of dead ends and long hauls and
* every barren system (the `barren` set, gate-only dead ends)
* is a LEAF: exactly one gate, in and out the same way.
* PURE no Math.random: every tie is broken by index. Same seed * PURE no Math.random: every tie is broken by index. Same seed
* same network (deterministic across machines and runs). * same network (deterministic across machines and runs).
* *
@ -24,19 +30,24 @@
* BFS-outward from the home system with a "keep the frontier open" * BFS-outward from the home system with a "keep the frontier open"
* child heuristic: attach the unvisited neighbors with the MOST * child heuristic: attach the unvisited neighbors with the MOST
* unvisited neighbors first, so rim clusters are absorbed before * unvisited neighbors first, so rim clusters are absorbed before
* their degree budget is spent. * their degree budget is spent. A BARREN node never adopts children
* (it keeps its single gate the maze's dead end).
* 3. Tree edges run BOTH ways. A bidirected tree is strongly connected * 3. Tree edges run BOTH ways. A bidirected tree is strongly connected
* by construction (the unique tree path between any two systems can * by construction (the unique tree path between any two systems can
* be walked in either direction), and every node's gate count is its * be walked in either direction), and every node's gate count is its
* tree degree at least 1 (no isolated node) and at most maxGates. * tree degree at least 1 (no isolated node) and at most maxGates.
* 4. Optional SHORTCUTS: any spare degree budget (nodes under * Every jump therefore has a RETURN gate (the destination's gate
* maxGates) buys extra one-way local edges the web, not just the * pointing back), so the player can always jump back the way they
* roads. Adding edges never removes reachability, so the strong * came.
* connectivity survives. * 4. Optional SHORTCUTS (OFF in the current config): any spare degree
* budget (nodes under maxGates) buys extra one-way local edges the
* web, not just the roads. Adding edges never removes reachability,
* so the strong connectivity survives.
* *
* The repair pass (below) is defensive: it fires only if the neighbor * The repair pass (below) is defensive: it fires only if the neighbor
* graph is disconnected (effectively impossible at this scale), and it * graph is disconnected (effectively impossible at this scale), and it
* still respects the degree budget. * still respects the degree budget (preferring non-barren attach targets
* so a dead end stays a leaf when it can).
*/ */
/** /**
@ -49,11 +60,16 @@
* @param {number} [o.minGates=1] validation bound. * @param {number} [o.minGates=1] validation bound.
* @param {number} [o.maxGates=3] hard degree budget per node. * @param {number} [o.maxGates=3] hard degree budget per node.
* @param {boolean} [o.shortcuts=true] spend spare budget on extra local edges. * @param {boolean} [o.shortcuts=true] spend spare budget on extra local edges.
* @param {Set<string>|null} [o.barren] the gate-only dead-end systems
* (objectCount 0, from the composition roll): a barren node never
* adopts children, so it ends up a tree LEAF exactly one gate, in and
* out the same way (the maze's dead ends). The repair pass prefers to
* attach leftovers to non-barren nodes.
* @param {string|null} [o.rootId] grow the tree from this system (the home * @param {string|null} [o.rootId] grow the tree from this system (the home
* system) it then keeps the lowest possible degree. * system) it then keeps the lowest possible degree.
* @returns {{ gates: Map<string, string[]>, repaired: number }} * @returns {{ gates: Map<string, string[]>, repaired: number }}
* gates: system id ordered list of gate destinations (parent edge * gates: system id ordered list of gate destinations (parent edge
* first "the road home" then shortcuts). * first "the road home" then children/shortcuts).
* repaired: systems that needed the defensive attach (0 on real data). * repaired: systems that needed the defensive attach (0 on real data).
*/ */
export function buildJumpNetwork({ export function buildJumpNetwork({
@ -62,9 +78,11 @@ export function buildJumpNetwork({
minGates = 1, minGates = 1,
maxGates = 3, maxGates = 3,
shortcuts = true, shortcuts = true,
barren = null,
rootId = null, rootId = null,
}) { }) {
const n = records.length; const n = records.length;
const isBarren = (i) => barren instanceof Set && barren.has(records[i].id);
const idx = new Map(); const idx = new Map();
records.forEach((r, i) => idx.set(r.id, i)); records.forEach((r, i) => idx.set(r.id, i));
const iOf = (id) => { const iOf = (id) => {
@ -110,6 +128,7 @@ export function buildJumpNetwork({
let head = 0; let head = 0;
while (head < queue.length) { while (head < queue.length) {
const u = queue[head++]; const u = queue[head++];
if (isBarren(u)) continue; // a dead end never adopts children (it keeps its single gate)
const budget = maxGates - deg[u]; const budget = maxGates - deg[u];
if (budget <= 0) continue; if (budget <= 0) continue;
const cands = nbr[u].filter((v) => !visited[v]); const cands = nbr[u].filter((v) => !visited[v]);
@ -151,9 +170,11 @@ export function buildJumpNetwork({
let repaired = 0; let repaired = 0;
for (let w = 0; w < n; w++) { for (let w = 0; w < n; w++) {
if (visited[w]) continue; if (visited[w]) continue;
// (1) local attach // (1) local attach — prefer a non-barren target (a dead end should
// keep its single gate when the graph lets us).
let u = -1; let u = -1;
for (const c of nbr[w]) if (visited[c] && deg[c] < maxGates) { u = c; break; } for (const c of nbr[w]) if (visited[c] && deg[c] < maxGates && !isBarren(c)) { u = c; break; }
if (u === -1) for (const c of nbr[w]) if (visited[c] && deg[c] < maxGates) { u = c; break; }
if (u === -1) { if (u === -1) {
// (2) swap: best (u, x) pair by dist(w, u), then indices // (2) swap: best (u, x) pair by dist(w, u), then indices
let bestU = -1, bestX = -1, bestNew = -1, bestD = Infinity; let bestU = -1, bestX = -1, bestNew = -1, bestD = Infinity;
@ -180,12 +201,16 @@ export function buildJumpNetwork({
} }
} }
if (u === -1) { if (u === -1) {
// (3) nearest visited node, budget be damned // (3) nearest visited node, budget be damned (prefer non-barren)
let bestD = Infinity; let bestD = Infinity;
for (let c = 0; c < n; c++) { for (const pass of [1, 0]) {
if (!visited[c] || c === w) continue; for (let c = 0; c < n; c++) {
const d = dist2(w, c); if (!visited[c] || c === w) continue;
if (d < bestD) { bestD = d; u = c; } if (Boolean(isBarren(c)) !== (pass === 1)) continue;
const d = dist2(w, c);
if (d < bestD) { bestD = d; u = c; }
}
if (u !== -1) break;
} }
if (u === -1) continue; // nothing to attach to (n === 1 handled above) if (u === -1) continue; // nothing to attach to (n === 1 handled above)
console.warn(`[orbit] jump network: forced attach of ${records[w].id} (degree budget exceeded)`); console.warn(`[orbit] jump network: forced attach of ${records[w].id} (degree budget exceeded)`);

View File

@ -17,9 +17,12 @@
* jumpArrival(content, fromId, cfg) * jumpArrival(content, fromId, cfg)
* Both, together: { x, y, heading, gateId } or null when the * Both, together: { x, y, heading, gateId } or null when the
* destination holds no return gate (a one-way SHORTCUT jump * destination holds no return gate (a one-way SHORTCUT jump
* JumpNetwork: tree edges run both ways, shortcuts don't). The * JumpNetwork: tree edges run both ways, shortcuts don't). With
* scene falls back to the destination's origin (its home-tether * shortcuts OFF (the current config) the network is a pure spanning
* zone) when this is null. * tree, so a return gate always exists and this never returns null;
* the null path is defensive (re-enabled shortcuts, or a malformed
* record). The scene falls back to the destination's origin (its
* home-tether zone) when this is null.
* *
* GameScene.jumpThroughGate supplies the live numbers (the gate's * GameScene.jumpThroughGate supplies the live numbers (the gate's
* radius/clearance, the ship's radius, data/gates.json jump) and * radius/clearance, the ship's radius, data/gates.json jump) and
@ -67,7 +70,9 @@ export function arrivalPoint(gate, cfg = {}) {
/** /**
* The jump's arrival for a destination system: at its return gate (if * The jump's arrival for a destination system: at its return gate (if
* it holds one tree-edge jumps do; one-way shortcut jumps don't). * it holds one tree-edge jumps do; one-way shortcut jumps don't;
* shortcuts are OFF in the current config, so the return gate always
* exists and the null path below is defensive only).
* *
* @returns {{x:number, y:number, heading:number, gateId:string}|null} * @returns {{x:number, y:number, heading:number, gateId:string}|null}
* null when there is no return gate the caller falls back. * null when there is no return gate the caller falls back.

View File

@ -37,11 +37,13 @@ const DEG = Math.PI / 180;
* EVERY planet hosts a settlement (for now data/settlements.json * EVERY planet hosts a settlement (for now data/settlements.json
* allPlanetsSettled + settledKindByClass): colonies on habitable worlds, * allPlanetsSettled + settledKindByClass): colonies on habitable worlds,
* mining stations over the rest, cloud bases riding gas giants. The * mining stations over the rest, cloud bases riding gas giants. The
* BARREN systems (objectCount 0) are the deliberate exception: star and * BARREN systems (objectCount 0) are the deliberate exception: jump
* jump gates, nothing else dead-end stops on the network (strong * gates and, in most, an asteroid cluster drifting inside the gate's
* connectivity keeps them reachable and escapable), where the player's * tether to mine at the stop and nothing else. They are the DEAD-END
* room to move is the activated gate's own level-1 tether (data/gates.json * LEAVES of the jump network (a pure spanning tree, no loops the maze's
* ACTIVITY: every gate carries `active`, default false; the activation * dead ends: one gate, in and out the same way), where the player's room
* to move is the activated gate's own level-1 tether (data/gates.json
* ACTIVITY: every gate carries `active`, default false; the activation
* mechanic is future work). Nothing here is hostile yet: `owner` on every * mechanic is future work). Nothing here is hostile yet: `owner` on every
* settlement is a reserved seam for the factions and pirates we'll * settlement is a reserved seam for the factions and pirates we'll
* introduce later. * introduce later.
@ -56,26 +58,32 @@ const DEG = Math.PI / 180;
* stays a 4-object configuration (see layoutSystem). * stays a 4-object configuration (see layoutSystem).
* *
* LAYOUT (data/planets.json solarSystem) the SOLAR SYSTEM BAND: every * LAYOUT (data/planets.json solarSystem) the SOLAR SYSTEM BAND: every
* PAIR of layout objects (planets, free-space stations, and the central * PAIR of layout objects (planets and free-space stations the central
* body the star, or the home world in the starting system at the * body, the home world, sits at the local origin of the STARTING system
* local origin) sits 6400..15360 px apart, center to center; in the home * only; every other system has an EMPTY center the star is invisible
* system the band tightens to 6400..10240 px. Normal systems lay out as a * flavor, generated as `star` but never rendered) sits 6400..15360 px
* regular N-gon ring around the star; the home system as a regular * apart, center to center; in the home system the band tightens to
* (N+1)-polygon with the home world as one vertex. The rotation is chosen * 6400..10240 px. Normal systems lay out as a regular N-gon ring around
* to serve the jump gates objects bias toward the directions the system * the (empty) center; the home system as a regular (N+1)-polygon with the
* jumps (see layoutSystem + layoutGates). * 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).
* *
* JUMP GATES (data/gates.json; the network in js/galaxy/JumpNetwork.js): * JUMP GATES (data/gates.json; the network in js/galaxy/JumpNetwork.js):
* 13 gates per system each placed on the side of the system facing its * 13 gates per system each placed on the side of the system facing its
* destination star on the 2-D map (an upper-right gate jumps to a star in * destination star on the 2-D map (an upper-right gate jumps to a star in
* the upper right). An ANCHORED system (a planet, a free-space station, * the upper right). An ANCHORED system (a planet, or the home world in
* or the home world) hosts its gates within level-1 tether (5120 px) of * the starting system) hosts its gates within level-1 tether (5120 px)
* an anchor; a BARREN system (no anchors) hosts its gate(s) on the ray * of an anchor planets ONLY (free-space stations are deliberately not
* toward the destination, `barrenDistance` from the star. Every gate * anchors: the build console lives on a world, so every gate must be
* record carries `active` (default false the activation mechanic is * tether-reachable from a planet the player can build out from). A BARREN
* future work; an activated gate anchors a level-1 tether). The * system (no planets a dead-end leaf of the tree) hosts its single gate
* galaxy-wide network is strongly connected: no closed systems, no * on the ray toward the destination, `barrenDistance` from the center.
* trapped sets, the whole galaxy is reachable. * Every gate record carries `active` (default false the activation
* mechanic is future work; an activated gate anchors a level-1 tether).
* The galaxy-wide network is a pure spanning tree (no loops the maze):
* from any system the player can reach any other, and every jump has a
* return gate.
* *
* FRAME DIVERSITY (js/galaxy/PlanetFrames.js): each planet's spritesheet * FRAME DIVERSITY (js/galaxy/PlanetFrames.js): each planet's spritesheet
* frame is assigned by a galaxy-wide pass the system's (class, frame) * frame is assigned by a galaxy-wide pass the system's (class, frame)
@ -103,7 +111,11 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
const attr = type.attributes ?? {}; const attr = type.attributes ?? {};
const rng = Rng.derive(galaxy.seed, 'system', record.id); const rng = Rng.derive(galaxy.seed, 'system', record.id);
// --- Star ------------------------------------------------------------- // --- Star (invisible flavor) ------------------------------------------
// The system's star — generated (name/class/mass/binary) as the
// dossier's identity ("Kepler Reach · star G") and the gate names, but
// never rendered: the central body exists only in the starting system
// (the home world). See data/galaxy.json.
const starClasses = attr.star?.classes ?? { G: 30, K: 35, M: 35 }; const starClasses = attr.star?.classes ?? { G: 30, K: 35, M: 35 };
const massTable = attr.star?.mass ?? {}; const massTable = attr.star?.mass ?? {};
const starClass = rng.weighted(starClasses, 'M'); const starClass = rng.weighted(starClasses, 'M');
@ -437,23 +449,32 @@ function bestRotation(place, targetAngles) {
* - sits on the system's side of its DESTINATION star the bearing is * - sits on the system's side of its DESTINATION star the bearing is
* computed in the 2-D map plane, so "an upper-right gate" means "a * computed in the 2-D map plane, so "an upper-right gate" means "a
* star to the upper right on the map"; * star to the upper right on the map";
* - ANCHORED systems (a planet, a free-space station, or the home world): * - ANCHORED systems (a planet or the home world in the starting
* within level-`anchorTetherLevel` tether (5120 px for level 1) of an * system): within level-`anchorTetherLevel` tether (5120 px for level
* anchor on the anchor's tether circle, chosen in order of facing * 1) of an anchor on the anchor's tether circle, chosen in order of
* quality: (1) the far ray-circle intersection (the gate exactly on * facing quality: (1) the far ray-circle intersection (the gate
* the target ray system, gate, and star collinear), (2) the point of * exactly on the target ray system center, gate, and destination
* the circle aimed exactly at the target star, (3) a forward-hemisphere * star collinear), (2) the point of the circle aimed exactly at the
* scan of the circle (±75°). Every candidate is exactly `range` from * target star, (3) a forward-hemisphere scan of the circle (±75°).
* its anchor, so tether-reachability holds by construction and the * Every candidate is exactly `range` from its anchor, so
* facing deviation never exceeds 90° (in practice a few degrees); * tether-reachability holds by construction and the facing deviation
* - BARREN systems (no anchors objectCount 0): on the ray toward the * never exceeds 90° (in practice a few degrees). Anchors are PLANETS
* destination, `barrenDistance` from the star (stepped outward within * only (plus the home world at home) free-space stations are
* the radius band only if the gate gap forces it). The activation * deliberately excluded: the build console lives on a world, so every
* gate must be tether-reachable from a planet the player can build
* out from (reach the anchor build the next tether level expand).
* (Every non-barren system holds at least one planet the
* composition roll demotes a station if the budget ran out so an
* anchor always exists.)
* - BARREN systems (no planets objectCount 0, the network's
* dead-end leaves): on the ray toward the destination,
* `barrenDistance` from the center (stepped outward within the
* radius band only if the gate gap forces it). The activation
* mechanic (future work) then turns the gate itself into the system's * mechanic (future work) then turns the gate itself into the system's
* level-1 tether anchor see data/gates.json ACTIVITY; * level-1 tether anchor see data/gates.json ACTIVITY;
* - stays `minRadius..maxRadius` from the star, `size` + `clearance` * - stays `minRadius..maxRadius` from the center, `size` + `clearance`
* clear of every anchor disc, and 2·`size` + `gateGap` from every * clear of every anchor disc (planets + free-space stations), and
* other gate. * 2·`size` + `gateGap` from every other gate.
* *
* The target list arrives ordered (the "road home" parent edge first) and * The target list arrives ordered (the "road home" parent edge first) and
* anchors iterate in content order, so the placement is exact for the * anchors iterate in content order, so the placement is exact for the
@ -478,14 +499,21 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
const maxR = Math.max(minR, g.maxRadius ?? 20480); const maxR = Math.max(minR, g.maxRadius ?? 20480);
const range = anchorTetherRange(g.anchorTetherLevel ?? 1); const range = anchorTetherRange(g.anchorTetherLevel ?? 1);
// Anchors: the planets, the free-space stations, and (home only) the // Anchors: the PLANETS and (home only) the home world at the origin —
// home world at the origin — the bodies a gate's tether may hang from. // the bodies a gate's tether may hang from. Free-space stations are
// A BARREN system holds none — its gates use the on-ray rule below. // deliberately NOT anchors (the build console lives on a world — the
const anchors = [ // player must always be able to reach a planet and build out from the
...planets.map((p) => ({ x: p.x, y: p.y, r: planetRenderRadius(p) })), // anchor); the composition roll guarantees a non-barren system holds
// at least one. A BARREN system holds none — its gate uses the on-ray
// rule below.
const anchors = planets.map((p) => ({ x: p.x, y: p.y, r: planetRenderRadius(p) }));
if (isHome) anchors.push({ x: 0, y: 0, r: homeWorldRadius() });
// Clearance discs: every solid body the gate must keep clear of — the
// anchors plus the free-space stations.
const discs = [
...anchors,
...freeSpace.map((f) => ({ x: f.x, y: f.y, r: stationKeepout(f.kind) })), ...freeSpace.map((f) => ({ x: f.x, y: f.y, r: stationKeepout(f.kind) })),
]; ];
if (isHome) anchors.push({ x: 0, y: 0, r: homeWorldRadius() });
const jumps = []; const jumps = [];
const gateNames = new Map(); // star name → how many gates named after it const gateNames = new Map(); // star name → how many gates named after it
@ -499,10 +527,12 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
if (anchors.length === 0) { if (anchors.length === 0) {
// BARREN SYSTEM (objectCount → 0) — no anchor to tether to: the gate // BARREN SYSTEM (objectCount → 0) — no anchor to tether to: the gate
// sits ON the ray toward its destination, `barrenDistance` from the // sits ON the ray toward its destination, `barrenDistance` from the
// star (data/gates.json), stepped outward in 1024 px steps within the // center (data/gates.json), stepped outward in 1024 px steps within the
// radius band if the gate gap forces it (two close targets). When the // radius band if the gate gap forces it (two close targets). When the
// player activates it (future mechanic), the gate itself becomes the // player activates it (future mechanic), the gate itself becomes the
// system's level-1 tether anchor (data/gates.json → ACTIVITY). // system's level-1 tether anchor (data/gates.json → ACTIVITY); its 12
// asteroid clusters drift inside that tether (data/asteroids.json →
// barren).
const base = Math.max(minR, Math.min(maxR, Math.max(1, g.barrenDistance ?? 8192))); const base = Math.max(minR, Math.min(maxR, Math.max(1, g.barrenDistance ?? 8192)));
const okB = (px, py) => { const okB = (px, py) => {
const d2c = px * px + py * py; const d2c = px * px + py * py;
@ -568,7 +598,7 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
const ok = (c) => { const ok = (c) => {
const d2c = c.px * c.px + c.py * c.py; const d2c = c.px * c.px + c.py * c.py;
if (d2c < minR * minR || d2c > maxR * maxR) return false; if (d2c < minR * minR || d2c > maxR * maxR) return false;
for (const b of anchors) { for (const b of discs) {
const need = b.r + size + clearance; const need = b.r + size + clearance;
const dx = c.px - b.x; const dx = c.px - b.x;
const dy = c.py - b.y; const dy = c.py - b.y;
@ -709,30 +739,45 @@ function homeWorldRadius() {
function generateAsteroidClusters(galaxy, record, planets, freeSpace, jumps = []) { function generateAsteroidClusters(galaxy, record, planets, freeSpace, jumps = []) {
const cfg = config.section('asteroids', {}); const cfg = config.section('asteroids', {});
if (cfg.enabled === false) return []; if (cfg.enabled === false) return [];
// A BARREN system (objectCount → 0) is a jump-gate-only stop — star and
// gates, nothing else — so no clusters.
if (planets.length === 0 && freeSpace.length === 0) return [];
// Without the solar-system layout there are no placed objects to space // Without the solar-system layout there are no placed objects to space
// against — no clusters either (the scene renders nothing else anyway). // against — no clusters either (the scene renders nothing else anyway).
if (config.get('planets.solarSystem.enabled', true) === false) return []; if (config.get('planets.solarSystem.enabled', true) === false) return [];
const isBarren = planets.length === 0 && freeSpace.length === 0;
if (isBarren && !jumps.some((j) => typeof j.x === 'number' && typeof j.y === 'number')) {
return []; // no gate to hang the cluster on (a one-system galaxy)
}
const clusterCfg = cfg.cluster ?? {}; const clusterCfg = cfg.cluster ?? {};
const dist = cfg.distribution ?? {}; const dist = cfg.distribution ?? {};
const placement = cfg.placement ?? {}; const placement = cfg.placement ?? {};
const isHome = record.id === galaxy?.homeSystemId; const isHome = record.id === galaxy?.homeSystemId;
// --- How many clusters: the inverse of the planet count --------------- // --- How many clusters -------------------------------------------------
const target = dist.targetObjects ?? 9; // BARREN (dead-end) systems: the stop's payload — a little rock to mine
const jitter = dist.jitter ?? 1; // inside the gate's tether (data/asteroids.json → barren.clusters,
// default 12). Everyone else: the inverse of the planet count — the
// more planets a system has, the fewer asteroid clusters, and vice
// versa. The STARTING system always gets at least
// startingSystemMinClusters, and those first ones are placed inside the
// player's initial tether.
const rng = Rng.derive(galaxy.seed, 'system', record.id, 'asteroids');
const minC = Math.max(1, Math.floor(dist.minClusters ?? 1)); const minC = Math.max(1, Math.floor(dist.minClusters ?? 1));
const maxC = Math.max(minC, Math.floor(dist.maxClusters ?? 6)); const maxC = Math.max(minC, Math.floor(dist.maxClusters ?? 6));
const homeMin = isHome ? Math.max(minC, Math.floor(dist.startingSystemMinClusters ?? 2)) : minC; const homeMin = isHome ? Math.max(minC, Math.floor(dist.startingSystemMinClusters ?? 2)) : minC;
let count;
const rng = Rng.derive(galaxy.seed, 'system', record.id, 'asteroids'); if (isBarren) {
const count = clamp( const bc = cfg.barren ?? {};
Math.round(target - planets.length + rng.range(-jitter, jitter)), const lo = Math.max(0, Math.floor(bc.clusters?.[0] ?? 1));
homeMin, maxC, const hi = Math.max(lo, Math.floor(bc.clusters?.[1] ?? 2));
); count = rng.int(lo, hi);
} else {
const target = dist.targetObjects ?? 9;
const jitter = dist.jitter ?? 1;
count = clamp(
Math.round(target - planets.length + rng.range(-jitter, jitter)),
homeMin, maxC,
);
}
if (count === 0) return []; if (count === 0) return [];
// --- Parameter plumbing (every value steerable from data/asteroids.json) // --- Parameter plumbing (every value steerable from data/asteroids.json)
@ -760,15 +805,25 @@ function generateAsteroidClusters(galaxy, record, planets, freeSpace, jumps = []
const tetherRadius = homeTetherRadius(); const tetherRadius = homeTetherRadius();
const homeSlots = isHome ? Math.min(count, homeMin) : 0; const homeSlots = isHome ? Math.min(count, homeMin) : 0;
// The dead end's anchor — its gate (barren systems are leaves of the
// tree, so exactly one; the cluster drifts inside its level-1 tether
// radius — an activated gate anchors a level-1 tether).
const gateAnchor = isBarren
? (jumps.find((j) => typeof j.x === 'number' && typeof j.y === 'number') ?? null)
: null;
const gateTetherRadius = anchorTetherRange(1);
// Names already taken in this system (planets + stations). // Names already taken in this system (planets + stations).
const nameRng = Rng.derive(galaxy.seed, 'system', record.id, 'names', 'asteroids'); const nameRng = Rng.derive(galaxy.seed, 'system', record.id, 'names', 'asteroids');
const usedNames = new Set(); const usedNames = new Set();
for (const p of planets) if (p.name) usedNames.add(p.name); for (const p of planets) if (p.name) usedNames.add(p.name);
for (const s of freeSpace) if (s.name) usedNames.add(s.name); for (const s of freeSpace) if (s.name) usedNames.add(s.name);
// Spacing obstacles: the home world (origin) + every placed object // Spacing obstacles: the home world (origin — a real body in the
// (planets, free-space stations, and the jump gates). // starting system; every other system's center is empty) + every placed
const placed = [{ x: 0, y: 0 }]; // object (planets, free-space stations, and the jump gates).
const placed = [];
if (isHome) placed.push({ x: 0, y: 0 });
for (const p of planets) if (typeof p.x === 'number' && typeof p.y === 'number') placed.push({ x: p.x, y: p.y }); for (const p of planets) if (typeof p.x === 'number' && typeof p.y === 'number') placed.push({ x: p.x, y: p.y });
for (const s of freeSpace) if (typeof s.x === 'number' && typeof s.y === 'number') placed.push({ x: s.x, y: s.y }); for (const s of freeSpace) if (typeof s.x === 'number' && typeof s.y === 'number') placed.push({ x: s.x, y: s.y });
for (const j of jumps) if (typeof j.x === 'number' && typeof j.y === 'number') placed.push({ x: j.x, y: j.y }); for (const j of jumps) if (typeof j.x === 'number' && typeof j.y === 'number') placed.push({ x: j.x, y: j.y });
@ -838,9 +893,21 @@ function generateAsteroidClusters(galaxy, record, planets, freeSpace, jumps = []
// The starting system's first clusters live INSIDE the initial tether // The starting system's first clusters live INSIDE the initial tether
// (whole group: center + bound + margin ≤ tether radius); the rest — // (whole group: center + bound + margin ≤ tether radius); the rest —
// and every cluster elsewhere — go anywhere in the scatter annulus. // and every cluster elsewhere — go anywhere in the scatter annulus.
const zoneMax = // A BARREN (dead-end) system's clusters drift INSIDE the gate's
i < homeSlots ? Math.max(rMin, Math.min(rMax, tetherRadius - bound - tetherMargin)) : rMax; // level-1 tether (the stop's only payload — mine it, then head back):
const pos = placeInAnnulus(rng, rMin, zoneMax, placed, minSep, maxAttempts); // an annulus around the gate, from barren.minRadius out to the tether
// rim minus the cluster's extent and the margin.
let pos;
if (isBarren && gateAnchor) {
const bc = cfg.barren ?? {};
const bMin = Math.max(0, Math.floor(bc.minRadius ?? 1024));
const bMax = Math.max(bMin, gateTetherRadius - bound - tetherMargin);
pos = placeInAnnulus(rng, bMin, bMax, placed, minSep, maxAttempts, gateAnchor);
} else {
const zoneMax =
i < homeSlots ? Math.max(rMin, Math.min(rMax, tetherRadius - bound - tetherMargin)) : rMax;
pos = placeInAnnulus(rng, rMin, zoneMax, placed, minSep, maxAttempts);
}
placed.push(pos); placed.push(pos);
clusters.push({ clusters.push({
@ -871,18 +938,18 @@ function generateAsteroidClusters(galaxy, record, planets, freeSpace, jumps = []
} }
/** /**
* A random point in the annulus [rMin, rMax] around the origin (uniform in * A random point in the annulus [rMin, rMax] around `center` (uniform in
* AREA) that is minSep from every placed object seeded rejection * AREA) that is minSep from every placed object seeded rejection
* sampling. If the annulus is hopelessly crowded (it isn't, at these * sampling. If the annulus is hopelessly crowded (it isn't, at these
* numbers) it returns the best candidate rather than failing. * numbers) it returns the best candidate rather than failing.
*/ */
function placeInAnnulus(rng, rMin, rMax, placed, minSep, maxAttempts) { function placeInAnnulus(rng, rMin, rMax, placed, minSep, maxAttempts, center = { x: 0, y: 0 }) {
let best = null; let best = null;
for (let attempt = 0; attempt < maxAttempts; attempt++) { for (let attempt = 0; attempt < maxAttempts; attempt++) {
const a = rng.range(0, TAU); const a = rng.range(0, TAU);
const r = Math.sqrt(rng.range(rMin * rMin, rMax * rMax)); const r = Math.sqrt(rng.range(rMin * rMin, rMax * rMax));
const x = Math.cos(a) * r; const x = center.x + Math.cos(a) * r;
const y = Math.sin(a) * r; const y = center.y + Math.sin(a) * r;
let ok = true; let ok = true;
let worst = Infinity; let worst = Infinity;
for (const p of placed) { for (const p of placed) {
@ -1003,12 +1070,23 @@ export function rollSystemComposition(seed, record, isHome, spec, density, attr)
const rngS = Rng.derive(seed, 'system', record.id, 'settlements'); const rngS = Rng.derive(seed, 'system', record.id, 'settlements');
const deepSpace = rngS.chance((spec?.deepSpaceStation?.chance ?? 0.12) * density); const deepSpace = rngS.chance((spec?.deepSpaceStation?.chance ?? 0.12) * density);
const waypoint = rngS.chance((spec?.waypoint?.chance ?? 0.2) * density); const waypoint = rngS.chance((spec?.waypoint?.chance ?? 0.2) * density);
const stations = Number(deepSpace) + Number(waypoint);
// 3) The planets fill the rest of the budget (N stations ≥ 0). // 3) The planets fill the rest of the budget (N stations ≥ 0).
// Every non-barren system keeps AT LEAST ONE PLANET: a gate anchors
// its tether to a world (layoutGates), and the player must be able to
// reach a planet and build out from it — a station-only budget
// (N = 2 stations) demotes one station to a planet.
let stations = Number(deepSpace) + Number(waypoint);
const classWeights = attr?.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 }; const classWeights = attr?.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 };
let wp = waypoint;
let ds = deepSpace;
while (stations >= objects && stations > 0) {
if (wp) wp = false;
else ds = false;
stations = Number(ds) + Number(wp);
}
const classes = Array.from({ length: objects - stations }, () => rngP.weighted(classWeights, 'rocky')); const classes = Array.from({ length: objects - stations }, () => rngP.weighted(classWeights, 'rocky'));
return { objects, deepSpace, waypoint, classes }; return { objects, deepSpace: ds, waypoint: wp, classes };
} }
/** /**

View File

@ -142,9 +142,10 @@ export function buildSystemTree(o = {}) {
/** /**
* A system's NAV points — the discovery ids the scene's * A system's NAV points — the discovery ids the scene's
* discoverableObjects() uses for its discoverable set, minus the rocks * discoverableObjects() uses for its discoverable set, minus the rocks
* (asteroid clusters are objects, not NAV points): the central body * (asteroid clusters are objects, not NAV points): the central body
* (every system the scene's 'home' id, the player's home world in the * the player's home world, STARTING SYSTEM ONLY (content carries
* starting system and the system's central world elsewhere), every * homeName there; every other system has no central body the star is
* invisible flavor, content.star, and the origin is empty space) every
* planet (its name), every free-space station (its settlement id), * planet (its name), every free-space station (its settlement id),
* every jump gate (its gate id). * every jump gate (its gate id).
* *
@ -152,16 +153,20 @@ export function buildSystemTree(o = {}) {
* @returns {string[]} the discoverable ids to check (order: stable) * @returns {string[]} the discoverable ids to check (order: stable)
*/ */
/** /**
* The system's NAV points (with their KIND), in a stable order: the central * The system's NAV points (with their KIND), in a stable order: the
* body ('home'), then the planets (by name), the free-space stations * central body ('home') the starting system only, where it is the home
* (settlement id), and the jump gates (gate id). Asteroid clusters are NOT * world then the planets (by name), the free-space stations (settlement
* NAV points they're objects you can mine, not chart waypoints. * id), and the jump gates (gate id). Asteroid clusters are NOT NAV points
* they're objects you can mine, not chart waypoints. A system without a
* central body (every non-home system the star is invisible flavor)
* simply charts its planets, stations, and gates.
* *
* @param {object} content a generated system content (ensureContent) * @param {object} content a generated system content (ensureContent)
* @returns {Array<{id:string, kind:'home'|'planet'|'station'|'gate'>}> * @returns {Array<{id:string, kind:'home'|'planet'|'station'|'gate'>}>
*/ */
export function navPoints(content) { export function navPoints(content) {
const out = [{ id: 'home', kind: 'home' }]; // the central body — discoverable in every system const out = [];
if (content?.homeName) out.push({ id: 'home', kind: 'home' }); // the home world — starting system only
for (const p of content?.planets ?? []) if (p && typeof p.name === 'string') out.push({ id: p.name, kind: 'planet' }); for (const p of content?.planets ?? []) if (p && typeof p.name === 'string') out.push({ id: p.name, kind: 'planet' });
for (const s of content?.settlements ?? []) { for (const s of content?.settlements ?? []) {
if (s && s.anchor?.type === 'space' && typeof s.id === 'string') out.push({ id: s.id, kind: 'station' }); if (s && s.anchor?.type === 'space' && typeof s.id === 'string') out.push({ id: s.id, kind: 'station' });

View File

@ -4,7 +4,7 @@
* One blob per browser (data/save.json storageKey): the whole bank is * One blob per browser (data/save.json storageKey): the whole bank is
* a single JSON document * a single JSON document
* *
* { "format": 1, "slots": { "3": { save record }, "7": { } } } * { "format": 2, "slots": { "3": { save record }, "7": { } } }
* *
* keyed 1..`save.slots` (10 by default) exactly what the pop-up shows. * keyed 1..`save.slots` (10 by default) exactly what the pop-up shows.
* Everything about the RECORD's shape is owned by js/save/SaveData.js * Everything about the RECORD's shape is owned by js/save/SaveData.js
@ -28,7 +28,12 @@
import { config } from '../config/Config.js'; import { config } from '../config/Config.js';
const DEFAULT_KEY = 'orbit.saves.v1'; const DEFAULT_KEY = 'orbit.saves.v1';
export const SAVE_FORMAT = 1; // FORMAT — bumped 1 → 2 with the galaxy redesign (60-system spanning-tree
// galaxy, no rendered stars, planet-only gate anchors): format-1 saves
// reference a galaxy that no longer exists and are REJECTED on load
// (validateRecord) — they stay listed in the panel but fail with a clear
// toast. New games write format 2.
export const SAVE_FORMAT = 2;
export class SaveManager { export class SaveManager {
/** /**
@ -163,7 +168,7 @@ export class SaveManager {
/** /**
* Every saved game, one pretty-printed JSON document: * Every saved game, one pretty-printed JSON document:
* *
* { "app":"orbit", "format":1, "exportedAt":"…", "slots":{ "1":{} } } * { "app":"orbit", "format":2, "exportedAt":"…", "slots":{ "1":{} } }
* *
* @returns {string} JSON (empty slots are omitted) * @returns {string} JSON (empty slots are omitted)
*/ */
@ -186,12 +191,20 @@ export class SaveManager {
/** /**
* Sanity-check a save record before it's trusted (write or load): * Sanity-check a save record before it's trusted (write or load):
* it must be an object with a seed and a ship position. * it must be an object of the CURRENT format with a seed and a ship
* position.
* *
* @returns {string|null} an error message, or null when the record passes * @returns {string|null} an error message, or null when the record passes
*/ */
static validateRecord(rec) { static validateRecord(rec) {
if (!rec || typeof rec !== 'object') return 'corrupt save data'; if (!rec || typeof rec !== 'object') return 'corrupt save data';
// Format gate — old builds' saves (format 1) predate the galaxy
// redesign (the star was rendered, the gate network had loops and
// shortcuts); their world no longer exists, so the save is a memory,
// not a state: reject with a message the UI can toast.
if (Number(rec.format) !== SAVE_FORMAT) {
return `save is from an older orbit build (format ${rec.format ?? '?'}, this build needs ${SAVE_FORMAT}) — start a new game`;
}
if (!String(rec.seed ?? '').trim()) return 'save is missing its galaxy seed'; if (!String(rec.seed ?? '').trim()) return 'save is missing its galaxy seed';
if (!rec.ship || typeof rec.ship.x !== 'number' || typeof rec.ship.y !== 'number') { if (!rec.ship || typeof rec.ship.x !== 'number' || typeof rec.ship.y !== 'number') {
return 'save is missing its ship position'; return 'save is missing its ship position';

View File

@ -13,7 +13,6 @@ import { playSfxOn, sfxPlayingOn, stopSfxOn } from '../utils/Sfx.js';
import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js'; import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js';
import { Ship } from '../entities/Ship.js'; import { Ship } from '../entities/Ship.js';
import { Planet } from '../entities/Planet.js'; import { Planet } from '../entities/Planet.js';
import { Star } from '../entities/Star.js';
import { AsteroidCluster } from '../entities/AsteroidCluster.js'; import { AsteroidCluster } from '../entities/AsteroidCluster.js';
import { Station } from '../entities/Station.js'; import { Station } from '../entities/Station.js';
import { JumpGate } from '../entities/JumpGate.js'; import { JumpGate } from '../entities/JumpGate.js';
@ -307,16 +306,15 @@ export class GameScene extends Phaser.Scene {
for (const j of this.systemContent.jumps ?? []) { for (const j of this.systemContent.jumps ?? []) {
if (j && this.activatedGates.has(`${this.systemRecord.id}>${j.to}`)) j.active = true; if (j && this.activatedGates.has(`${this.systemRecord.id}>${j.to}`)) j.active = true;
} }
// The CENTRAL BODY — one per system, always at the origin, always the // The CENTRAL BODY — the home world, present ONLY in the STARTING
// system's 'home' NAV point (js/research/SystemCategory.js → navPoints): // system, at the world origin (the generator deals its name —
// in the STARTING system it is the player's home world (the generator // content.homeName — so it reads as a real place rather than a generic
// deals its name — content.homeName — so it reads as a real place // "Terra"; the fallback keeps the old configured name if the bank is
// rather than a generic "Terra"; the fallback keeps the old configured // ever unavailable). It is the only central body in the galaxy: in
// name if the bank is ever unavailable); in every OTHER system it is // every other system the center is EMPTY (the star is invisible flavor
// the system's STAR (content.star — name + spectral class). The home // — content.star name/class feeds the dossier and gate names, never
// world is the ONLY central body that is a world: only here is it a // rendered) and is the ONLY system where the origin is a comms target
// comms target (settled, landable) and only here does it read "Home // (settled, landable) and reads "Home World".
// World" — a star is not a comms object, and never "home".
// NOTE: home is the STARTING system (galaxy.homeSystemId) — stable // NOTE: home is the STARTING system (galaxy.homeSystemId) — stable
// across jumps. Comparing against galaxy.currentSystemId would be // across jumps. Comparing against galaxy.currentSystemId would be
// wrong: that pointer tracks where the player IS NOW, so every // wrong: that pointer tracks where the player IS NOW, so every
@ -328,7 +326,7 @@ export class GameScene extends Phaser.Scene {
this.createSystemHud(); this.createSystemHud();
if (this.isHomeSystem) { if (this.isHomeSystem) {
// The home planet — the player's Terran world, present ONLY in the // The home world — the player's Terran world, present ONLY in the
// system the player starts in. It sits at the world origin. Which // system the player starts in. It sits at the world origin. Which
// Terran face it shows comes from the galaxy-wide frame pass // Terran face it shows comes from the galaxy-wide frame pass
// (content.homeFrame — the same (class, face) spreading as the // (content.homeFrame — the same (class, face) spreading as the
@ -342,14 +340,14 @@ export class GameScene extends Phaser.Scene {
: Planet.frameFor(homeName, homeRng); : Planet.frameFor(homeName, homeRng);
this.planet = new Planet(this, 0, 0, homeFrame, homeName); this.planet = new Planet(this, 0, 0, homeFrame, homeName);
this.planet.discoveryName = this.homeWorldName; this.planet.discoveryName = this.homeWorldName;
this.planet.setDepth(5); // above the starfield (depths 02), below the ship (10)
} else { } else {
// The system's star — the central body of every other system // Every other system has NO central body: the star is INVISIBLE
// (content.star from the generator: name + spectral class). // flavor (content.star — name/class/binary — feeds the dossier and
const star = this.systemContent.star ?? {}; // gate names, but is never rendered). The origin is empty space;
this.planet = new Star(this, 0, 0, star); // the gate tethers are the player's anchors here.
this.planet.discoveryName = star.name ?? 'Star'; this.planet = null;
} }
this.planet.setDepth(5); // above the starfield (depths 02), below the ship (10)
// The rest of the solar system — the generated worlds, placed by the // The rest of the solar system — the generated worlds, placed by the
// generator (data/planets.json → solarSystem) on orbits around the // generator (data/planets.json → solarSystem) on orbits around the
@ -427,17 +425,27 @@ export class GameScene extends Phaser.Scene {
} }
// Every solid in the system — worlds first (their keep-out circles are // Every solid in the system — worlds first (their keep-out circles are
// disjoint), then the clusters, then the stations, then the gates. // disjoint), then the clusters, then the stations, then the gates. The
// central body (the home world — the starting system only; other
// systems have no star) leads when it exists.
// Ship constraint, click-to-fly clamping and autopilot all run // Ship constraint, click-to-fly clamping and autopilot all run
// against this list. // against this list.
this.solids = [this.planet, ...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations, ...this.systemGates]; this.solids = [
// The central body's discovery id is 'home' in EVERY system — it is ...(this.planet ? [this.planet] : []),
// the chart's central NAV point (SystemCategory.navPoints); its NAME ...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations, ...this.systemGates,
// differs: the home world in the starting system, the star elsewhere. ];
this.planet.discoveryId = 'home'; // The central body's discovery id is 'home' — the chart's central NAV
// point (SystemCategory.navPoints) — in the STARTING SYSTEM ONLY (the
// home world). Every other system's center is empty: no 'home' NAV
// point there (its star is invisible flavor, not a chart waypoint).
if (this.planet) this.planet.discoveryId = 'home';
// The ship — a short hop (~150 px, edge-to-edge) from the home world's // The ship — in the starting system, a short hop (~150 px,
// rim, in a seed-derived direction: same galaxy ⇒ same start. // edge-to-edge) from the home world's rim, in a seed-derived
// direction: same galaxy ⇒ same start. Everywhere else the ship's
// position comes from the jump/save restore (the arrival gate), so
// the origin (an empty center — no star) is only a defensive
// fallback.
if (config.get('ship.texture', '') && !this.textures.exists(Ship.TEXTURE_KEY)) { if (config.get('ship.texture', '') && !this.textures.exists(Ship.TEXTURE_KEY)) {
console.warn( console.warn(
`[orbit] ship spritesheet "${config.get('ship.texture')}" did not load — using the built-in dart.`, `[orbit] ship spritesheet "${config.get('ship.texture')}" did not load — using the built-in dart.`,
@ -445,11 +453,13 @@ export class GameScene extends Phaser.Scene {
} }
this.ship = new Ship(this, 0, 0); this.ship = new Ship(this, 0, 0);
this.ship.setDepth(10); this.ship.setDepth(10);
const spawn = this.planet.edgePoint( const spawn = this.planet
Rng.derive(this.galaxy.seed, 'spawn', 'ship').range(0, Math.PI * 2), ? this.planet.edgePoint(
config.get('planets.spawnDistanceFromEdge', 150), Rng.derive(this.galaxy.seed, 'spawn', 'ship').range(0, Math.PI * 2),
this.ship.radius, config.get('planets.spawnDistanceFromEdge', 150),
); this.ship.radius,
)
: { x: 0, y: 0 };
this.ship.setPosition(spawn.x, spawn.y); this.ship.setPosition(spawn.x, spawn.y);
// Center the camera on the ship from the very first frame. // Center the camera on the ship from the very first frame.
@ -462,22 +472,28 @@ export class GameScene extends Phaser.Scene {
this.starfield = new Starfield(this); this.starfield = new Starfield(this);
this.starfield.create(); this.starfield.create();
// The TETHER — the player's range. Starts as one level-1 tether anchored // The TETHER — the player's range. In the STARTING system it starts
// on the home world (data/tether.json): the ship may fly anywhere within // as one level-1 tether anchored on the home world (data/tether.json):
// its rim (level 1 = 5120 px from the planet's center); the rim is a // the ship may fly anywhere within its rim (level 1 = 5120 px from the
// hard barrier drawn as a thick glitchy dotted line. The field owns the // planet's center); the rim is a hard barrier drawn as a thick
// tether list (add/remove/setLevel are the seam the build system will // glitchy dotted line. In every OTHER system there is no native
// use later); where multiple tethers' zones overlap there is no line // tether — the center is empty — the player's range is the activated
// and no wall — the union is the player's space. // GATE tethers (the arrival gate anchors a level-1 tether on landing,
// data/gates.json → ACTIVITY). The field owns the tether list
// (add/remove/setLevel are the seam the build system will use later);
// where multiple tethers' zones overlap there is no line and no wall
// — the union is the player's space.
this.tetherField = new TetherField(this, { this.tetherField = new TetherField(this, {
depth: 6, // above planets (5), below the ship (10) depth: 6, // above planets (5), below the ship (10)
}); });
this.tetherField.add( if (this.isHomeSystem) {
config.get('tether.homeId', 'home'), this.tetherField.add(
0, 0, // the central body's center (home world or star — the system origin) config.get('tether.homeId', 'home'),
config.get('tether.homeLevel', 1), 0, 0, // the home world's center (the system origin)
config.get('tether.homeLabel', '') || (this.isHomeSystem ? this.homeWorldName : this.planet.discoveryName), config.get('tether.homeLevel', 1),
); config.get('tether.homeLabel', '') || this.homeWorldName,
);
}
// Each activated gate anchors its tether (data/gates.json → ACTIVITY: // Each activated gate anchors its tether (data/gates.json → ACTIVITY:
// an active gate is a TETHER ANCHOR in its own right — the room to // an active gate is a TETHER ANCHOR in its own right — the room to
// move in a barren system). Idempotent: add() replaces by id, and a // move in a barren system). Idempotent: add() replaces by id, and a
@ -1444,23 +1460,23 @@ export class GameScene extends Phaser.Scene {
/** The discoverable objects of the system, with compass metadata. */ /** The discoverable objects of the system, with compass metadata. */
discoverableObjects() { discoverableObjects() {
const out = []; const out = [];
// The central body — the player's home world in the starting system, // The central body — the home world in the STARTING SYSTEM ONLY
// the system's star in every other one (discovery id 'home' either // (discovery id 'home': the chart's central NAV point). Every other
// way: the chart's central NAV point). // system's center is empty — its star is invisible flavor (content.star
out.push({ // feeds the dossier, not the chart) — so no central NAV point there.
id: 'home', if (this.planet) {
x: this.planet.x, out.push({
y: this.planet.y, id: 'home',
radius: this.planet.radius, x: this.planet.x,
typeLabel: this.isHomeSystem y: this.planet.y,
? config.get('planets.homeTypeLabel', 'Home World') radius: this.planet.radius,
: config.get(`planets.starTypeLabels.${this.systemContent.star?.class}`, 'Star'), typeLabel: config.get('planets.homeTypeLabel', 'Home World'),
// The compass accent: the home world is a PLANET (green — // The compass accent: the home world is a PLANET (green —
// data/planets.json → compassColor, like the system's worlds); the // data/planets.json → compassColor, like the system's worlds).
// star isn't a planet and keeps the compass's default neon cyan. color: config.get('planets.compassColor', '#3dff88'),
color: this.isHomeSystem ? config.get('planets.compassColor', '#3dff88') : undefined, name: this.planet.discoveryName,
name: this.planet.discoveryName, });
}); }
for (const p of this.systemPlanets) { for (const p of this.systemPlanets) {
out.push({ out.push({
id: p.discoveryId, id: p.discoveryId,
@ -1666,9 +1682,10 @@ export class GameScene extends Phaser.Scene {
* run moves to the connected system, arriving near that system's * run moves to the connected system, arriving near that system's
* RETURN gate (the one pointing back activated with the gate per * RETURN gate (the one pointing back activated with the gate per
* the ACTIVITY rule, its tether anchoring the ship's room to move; * the ACTIVITY rule, its tether anchoring the ship's room to move;
* the pure geometry is js/galaxy/JumpTravel.js). One-way shortcut * the pure geometry is js/galaxy/JumpTravel.js). With shortcuts OFF
* jumps have no return gate they land on the destination's star, * the network is a pure spanning tree, so every jump has a return
* inside its home-tether zone. * gate; the origin fallback below is defensive only (the destination
* center is empty its star is invisible flavor).
* *
* HOW: the save pipeline, in miniature. captureState() snapshots the * HOW: the save pipeline, in miniature. captureState() snapshots the
* WHOLE run (discovery, reputation, research, builds, minerals, * WHOLE run (discovery, reputation, research, builds, minerals,
@ -1703,8 +1720,9 @@ export class GameScene extends Phaser.Scene {
return; return;
} }
// Where we arrive: just past the return gate's keepout (JumpTravel). // Where we arrive: just past the return gate's keepout (JumpTravel).
// Fallback — no return gate (a one-way shortcut): the destination's // Fallback — no return gate (a one-way shortcut, shortcuts are OFF in
// star, inside its home tether (added unconditionally in create()). // the current config): the destination's origin (its center is empty —
// the star is invisible flavor, so this is just open space).
const destContent = this.galaxy.ensureContent(destId); const destContent = this.galaxy.ensureContent(destId);
const arrival = const arrival =
jumpArrival(destContent, from.id, { jumpArrival(destContent, from.id, {
@ -2335,18 +2353,23 @@ export class GameScene extends Phaser.Scene {
label: t.label ?? '', label: t.label ?? '',
})); }));
// central body: the home world in the starting system, else the star // central body: the home world in the starting system; every other
// system's center is EMPTY — the star is invisible dossier flavor
// (name/class feed the plate tag, never a rendered disc: `visible`
// is false for the map's central-body draw)
const starClass = String(content.star?.class ?? '').toUpperCase(); const starClass = String(content.star?.class ?? '').toUpperCase();
const central = this.isHomeSystem const central = this.isHomeSystem
? { ? {
name: this.homeWorldName ?? 'Terra', name: this.homeWorldName ?? 'Terra',
isHome: true, isHome: true,
visible: true,
radius: this.planet?.radius ?? 200, radius: this.planet?.radius ?? 200,
typeLabel: config.get('planets.homeTypeLabel', 'Home World'), typeLabel: config.get('planets.homeTypeLabel', 'Home World'),
} }
: { : {
name: this.planet?.discoveryName ?? 'Star', name: this.planet?.discoveryName ?? 'Star',
isHome: false, isHome: false,
visible: false,
radius: 240, radius: 240,
typeLabel: config.get(`planets.starTypeLabels.${starClass}`, 'Star'), typeLabel: config.get(`planets.starTypeLabels.${starClass}`, 'Star'),
color: toCss(config.get(`planets.star.classColor.${starClass}`, '#ffe9b0')), color: toCss(config.get(`planets.star.classColor.${starClass}`, '#ffe9b0')),

View File

@ -7,7 +7,7 @@ import { config } from '../config/Config.js';
* *
* STARS & THE GALAXY synthesised from syllable pools (data/naming.json * STARS & THE GALAXY synthesised from syllable pools (data/naming.json
* star / galaxy). Short, consistent, and effectively unlimited: there are * star / galaxy). Short, consistent, and effectively unlimited: there are
* 200 systems and no reason to run out of star names. * 60 systems and no reason to run out of star names.
* *
* PLANETS & STATIONS drawn from finite, curated NAME BANKS (data/ * PLANETS & STATIONS drawn from finite, curated NAME BANKS (data/
* naming.json banks). A deep pool of hand-picked names colonial * naming.json banks). A deep pool of hand-picked names colonial