Compare commits

...

2 Commits

Author SHA1 Message Date
Brian Fertig 963b2b5bc2 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
2026-09-07 14:12:40 -06:00
Brian Fertig 59ef952d5d Fix rocky land-01 shop video to use rocky-shop instead of terran-shop 2026-09-07 13:07:32 -06:00
27 changed files with 647 additions and 783 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
builds (the galaxy's name, system count, archetype count) **before** you
commit. Same seed ⇒ same galaxy.
- **Procedural galaxy**: 200 star systems in a seeded disk + core +
spiral arms (`data/galaxy.json`), typed into six themed archetypes
(`data/systems.json`) with per-type distribution weights and radial
bands — the first "how does the galaxy lay itself out" rules.
- **Procedural galaxy**: 60 star systems in a sparse seeded disk — no
spiral arms, no core bulge, uniform-in-area (`data/galaxy.json`), typed
into six themed archetypes (`data/systems.json`) with per-type
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
Game (~15 ms); each system's planets/moons/belts/settlements are
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
(`data/systems.json`) and thin out from the settled core to the wilder
rim (`data/galaxy.json`). **Barren systems** (~10%, the
`objectCount` → 0 stops) hold nothing but their jump gate and report
*charted · unclaimed*. Non-home systems hold 0/2/3/4/5 objects
`objectCount` → 0 stops) are the DEAD-END LEAVES of the jump-gate maze:
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`);
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,
and a rocky world. Each settlement has a name, population, and an
`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**
in the current system's open space. Every system also holds 13 JUMP
GATES — solid, discoverable exits that face their destination star —
wired into a strongly-connected gate network (data/gates.json); every
gate is `active: false` for now — DORMANT (dimmed, field still) — and
activation is the seam for the tether mechanic (an activated gate
anchors a level-1 tether);
wired into a PURE SPANNING TREE gate network (data/gates.json, no
shortcuts, no closed loops: exactly one route between any two systems —
a maze of dead ends and long hauls); every gate is `active: false` for
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.
Discovered worlds get a screen-edge arrow + a name tag showing 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)
│ ├── planets.json # home world + system layout + solid-disc rules
│ ├── 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
│ ├── 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
│ │ # trees live one-per-file below (section name = file basename)
│ ├── research/

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

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,
"texture": "assets/images/asteroids.png",
"frameWidth": 128,
@ -105,6 +105,10 @@
"maxClusters": 6,
"startingSystemMinClusters": 2
},
"barren": {
"clusters": [1, 2],
"minRadius": 1024
},
"placement": {
"minObjectSpacing": 1024,
"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,
"layout": {
"coreFraction": 0.25,
"coreFraction": 0,
"bulgeSigma": 0.09,
"diskSkew": 1.7,
"diskSkew": 0.5,
"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": {
"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,
"minGates": 1,
"maxGates": 3,
"neighborPool": 8,
"shortcuts": true,
"shortcuts": false,
"size": 96,
"texture": "assets/images/jumpgate.png",
"frameWidth": 256,

View File

@ -49,7 +49,7 @@
{ "land": "gasgiant-land-01.mp4", "surface": "gasgiant-surface-01.mp4", "takeoff": "gasgiant-takeoff-01.mp4", "shop": "gasgiant-shop-01.mp4" },
{ "land": "gasgiant-land-02.mp4", "surface": "gasgiant-surface-02.mp4", "takeoff": "gasgiant-takeoff-02.mp4", "shop": "gasgiant-shop-02.mp4" },
{ "land": "gasgiant-land-03.mp4", "surface": "gasgiant-surface-03.mp4", "takeoff": "gasgiant-takeoff-03.mp4", "shop": "gasgiant-shop-03.mp4" },
{ "land": "rocky-land-01.mp4", "surface": "rocky-surface-01.mp4", "takeoff": "rocky-takeoff-01.mp4", "shop": "terran-shop-01.mp4" },
{ "land": "rocky-land-01.mp4", "surface": "rocky-surface-01.mp4", "takeoff": "rocky-takeoff-01.mp4", "shop": "rocky-shop-01.mp4" },
{ "land": "rocky-land-02.mp4", "surface": "rocky-surface-02.mp4", "takeoff": "rocky-takeoff-02.mp4", "shop": "rocky-shop-02.mp4" },
{ "land": "rocky-land-03.mp4", "surface": "rocky-surface-03.mp4", "takeoff": "rocky-takeoff-03.mp4", "shop": "rocky-shop-03.mp4" }
]

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": {
"syllables": ["ka", "vel", "thu", "ori", "an", "esh", "mar", "dy", "neth", "avi", "cor", "lu", "tan", "ys", "brei", "hal", "ion", "sol", "qua", "ren"],
"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",
"frameWidth": 1024,
"frameHeight": 1024,
@ -35,10 +35,8 @@
"homeName": "Terra",
"homeTypeLabel": "Home World",
"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.",
"size": 1536,
"classColor": { "O": "#9db4ff", "B": "#9db4ff", "A": "#cfd8ff", "F": "#fff4e0", "G": "#ffe9b0", "K": "#ffc07a", "M": "#ff8a64" },
"glow": { "radiusFactor": 1.55, "alpha": 0.5 }
"_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).",
"classColor": { "O": "#9db4ff", "B": "#9db4ff", "A": "#cfd8ff", "F": "#fff4e0", "G": "#ffe9b0", "K": "#ffc07a", "M": "#ff8a64" }
},
"starTypeLabels": {
"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": {
"barren": 0.10,
"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 (±
* jitter, clamped to [minClusters, maxClusters]) so systems with
* more planets get fewer clusters and vice versa (also checked as an
* aggregate correlation across the non-barren sample); barren systems
* (jump-gate-only) hold NO clusters;
* aggregate correlation across the non-barren sample); BARREN
* 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
* those first ones sit INSIDE the initial tether (whole cluster,
* minus placement.tetherMargin);
@ -18,9 +20,10 @@
* every rock pair keeps a gapFactor × (r1+r2) gap (no interpenetration),
* bound = max(offset + size/2);
* - SPACING: no cluster within 1024 px (center-to-center) of ANY other
* object home world (origin), planets, free-space stations, other
* clusters and every cluster sits in the [minRadius, maxRadius]
* annulus around the origin;
* solid object the home world (origin, starting system only),
* planets, free-space stations, other clusters and every non-barren
* 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
* configured slow-spin ranges;
* - 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]
// (and [startingMin, max] for the home system). When the ideal band
// clamps to empty, the clamped range itself is the contract.
// BARREN systems (0 planets + 0 free-space stations — the jump-gate-only
// stops) are the one exception: no clusters by design.
// BARREN systems (0 planets + 0 free-space stations — the gate-only
// 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');
if (barren) {
if (clusters.length !== 0) {
countOk = false; countWhy = `${rec.id}: barren system → ${clusters.length} clusters (want 0)`; break;
const bc = A.barren ?? {};
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 {
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 s of c.settlements ?? []) if (s.name) used.add(s.name);
// Spacing obstacles.
const objects = [{ x: 0, y: 0 }];
// Spacing obstacles — solids only (the center holds the home world in
// 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 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++) {
const cl = clusters[i];
@ -209,7 +224,7 @@ for (const rec of sample) {
}
used.add(cl.name);
// --- Spacing: ≥ 1024 px from EVERY other object ----------------------
// --- Spacing: ≥ 1024 px from every other SOLID ----------------------
for (const o of objects) {
const d = Math.hypot(cl.x - o.x, cl.y - o.y);
if (d < P.minObjectSpacing - 1e-6) {
@ -218,26 +233,37 @@ for (const rec of sample) {
}
if (!spacingOk) break;
// --- Scatter annulus ---------------------------------------------------
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;
// --- Barren payload: inside the gate's tether ------------------------
if (barren) {
if (!gate || typeof gate.x !== 'number') {
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 ---------------------------------
if (isHome && i < D.startingSystemMinClusters) {
if (dist0 + cl.bound + (P.tetherMargin ?? 96) > TETHER + 1e-6) {
homeOk = false; homeWhy = `${rec.id}#${i}: whole cluster not inside the initial tether (${Math.round(dist0 + cl.bound)} + margin > ${TETHER})`; break;
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(Math.hypot(cl.x, cl.y) + cl.bound)} + margin > ${TETHER})`; 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(`spacing: no cluster within ${P.minObjectSpacing} px (center-to-center) of ANY object (home, planets, stations, clusters)${spacingOk ? '' : ' — ' + spacingWhy}`, spacingOk);
check(`scatter: every cluster inside the ${P.minRadius}${P.maxRadius} annulus around the origin${annulusOk ? '' : ' — ' + annulusWhy}`, annulusOk);
check(`spacing: no cluster within ${P.minObjectSpacing} px (center-to-center) of ANY solid (home world, planets, stations, clusters)${spacingOk ? '' : ' — ' + spacingWhy}`, spacingOk);
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('names: synthesised, never repeating a name in the same system', namesOk);
const homeClusters = g.ensureContent(homeId).asteroids;
@ -247,8 +273,9 @@ check(
);
// The inverse rule, in aggregate: rockier systems (few planets) get more
// clusters than planet-rich ones — over the NON-BARREN systems (barren are
// jump-gate-only and hold 0 clusters by design, a separate rule).
// clusters than planet-rich ones — over the NON-BARREN systems (barren
// dead ends carry their fixed 12 payload clusters around the gate, a
// separate rule).
{
const rows = sample.map((r) => {
const c = g.ensureContent(r.id);

View File

@ -13,11 +13,12 @@
* - every generated system obeys its type's attribute bounds;
* - the OBJECT COMPOSITION (data/systems.json objectCount): every
* 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;
* - spatial-hash neighbor queries agree with brute force;
* - 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 { 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);
const emptyShare = emptyCount / nSys;
const barrenExpect = OC.barren ?? 0.1;
const barrenSd = Math.sqrt(barrenExpect * (1 - barrenExpect) / nSys);
check(
`${Math.round(barrenExpect * 100)}% of systems are barren — jump-gate-only (observed ${(emptyShare * 100).toFixed(1)}%)`,
Math.abs(emptyShare - barrenExpect) < 0.08,
`${Math.round(barrenExpect * 100)}% of systems are barren — the gate-only dead-end leaves (observed ${(emptyShare * 100).toFixed(1)}%)`,
Math.abs(emptyShare - barrenExpect) < 4 * barrenSd + 0.004,
);
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.
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);
// OBJECT COMPOSITION: the BARREN systems (objectCount → 0) are the only
// unsettled ones — jump-gate-only stops, deliberately (strong
// connectivity keeps them reachable); every non-barren system is settled
// (all its planets + its free-space stations).
// unsettled ones — the gate-only dead-end leaves of the jump network
// (the spanning tree keeps them reachable: the maze's dead ends, in and
// out the same way); every non-barren system is settled (all its
// planets + its free-space stations).
let barren = 0;
for (const r of sample) {
const c = big2.ensureContent(r.id);
if (c.planets.length === 0 && !c.settlements.some((s) => s.anchor?.type === 'space')) barren++;
}
const barrenShare = barren / sample.length;
const barrenP = config.get('systems.objectCount.barren', 0.1);
check(
`barren systems are jump-gate-only: ≈ ${Math.round((config.get('systems.objectCount.barren', 0.1) * 100))}% (observed ${(barrenShare * 100).toFixed(1)}%)`,
Math.abs(barrenShare - (config.get('systems.objectCount.barren', 0.1))) < 0.08,
`barren systems are the dead-end leaves: ≈ ${Math.round(barrenP * 100)}% (observed ${(barrenShare * 100).toFixed(1)}%)`,
Math.abs(barrenShare - barrenP) < 4 * Math.sqrt(barrenP * (1 - barrenP) / sample.length) + 0.004,
);
check(
'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
* other system is reachable (forward BFS) AND every system can
* 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):
* - content.jumps matches the network (count + destinations);
* - ANCHORED systems (a planet, a free-space station, or the home world):
* every gate is within level-1 tether (tether.level1Radius) of an
* anchor and FACES its destination star on the 2-D map (soft rule
* within 90° of the systemstar bearing from the anchoring object);
* - BARREN systems (objectCount 0 a jump-gate-only stop): the gate
* sits ON the ray toward its destination, gates.barrenDistance from
* the star, within the radius band, facing it;
* - gates stay gates.minRadius..gates.maxRadius from the star;
* - gates keep size+clearance from anchor discs and 2·size+gateGap
* from each other;
* - ANCHORED systems (a planet or the home world in the starting
* system): every gate is within level-1 tether (tether.level1Radius)
* of a planet anchor and FACES its destination star on the 2-D map
* (soft rule within 90° of the systemstar bearing from the
* anchoring object); stations are NOT anchors (the player must be
* able to build out from a world), but they DO get clearance;
* - every non-barren system holds at least ONE planet (the gate's
* anchor is guaranteed to exist);
* - BARREN systems (objectCount 0 the network's dead-end leaves):
* 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;
* - every gate record carries active: false (data/gates.json ACTIVITY);
* - the OBJECT COMPOSITION (data/systems.json objectCount): every
* non-home system holds 0, 2, 3, 4, or 5 objects (planets +
* 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;
* 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);
}
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++;
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 = [
...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' });
// 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) => {
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`;
}
}
// RADIUS band from the star.
// RADIUS band from the center.
if (d0 < MIN_R - 1e-6 || d0 > MAX_R + 1e-6) {
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
// 800 px across, so a size + 100 px floor is a fair check).
for (const a of anchors) {
// CLEARANCE from every solid disc (planets + stations + home world
// — every disc in this game is under 800 px across, so a size +
// 100 px floor is a fair check).
for (const a of discs) {
const d = Math.hypot(j.x - a.x, j.y - a.y);
if (d < SIZE + 100) {
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(`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(`barren gates sit on the target ray, ${BARN_D} px from the star, facing it`, barrenBad === 0, bWhy);
check(`radius band: every gate ${MIN_R}..${MAX_R} px from the star`, radius === 0, rWhy);
check(`clearance: every gate keeps ${SIZE} + 100 px from anchor discs`, clearance === 0, cWhy);
check(`barren gates sit on the target ray, in the band ${MIN_R}..${MAX_R}, facing it`, barrenBad === 0, bWhy);
check(`radius band: every gate ${MIN_R}..${MAX_R} px from the center`, radius === 0, rWhy);
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 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);
@ -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
// non-home system holds 0, 2, 3, 4, or 5 objects (planets +
// 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 expected = { 0: OC.barren ?? 0.1 };
for (const [k, w] of Object.entries(OC.objects ?? {})) expected[Number(k)] = w;
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) {
if (r.id === HOME) continue; // the home system is exempt (fixed 2 planets)
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++;
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;
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: 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 = {}) => ({
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',
seed: SEED,
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: good record accepted', SaveManager.validateRecord(rec) === 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);
// -- 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 = {
name: NAME,
type: 'anchored',
@ -155,41 +157,46 @@ const content = {
jumps: [{ id: `${SYS}-j1`, to: 'S000043' }, { id: `${SYS}-j2`, to: 'S000044' }],
asteroids: [{ id: `${SYS}-a1` }], // clusters are objects, not NAV points
};
check('nav: the NAV points = central body + planets + space stations + gates',
JSON.stringify(navPointIds(content)) === JSON.stringify(['home', 'K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`, `${SYS}-j2`]));
const POINT_IDS = ['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 set = new Set(found);
return { isDiscovered: (_sys, id) => set.has(id) };
};
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',
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);
// 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([
{ id: 'home', kind: 'home' },
{ id: 'K-1', kind: 'planet' }, { id: 'K-2', kind: 'planet' },
{ id: `${SYS}-s1`, kind: 'station' },
{ 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);
check('navChart: reports the discovered/missing split (5 of 6, j2 still out)',
chartMissing.discovered === 5 && chartMissing.total === 6 && chartMissing.complete === false
const chartMissing = navChart(makeDiscovery(['K-1', 'K-2', `${SYS}-s1`, `${SYS}-j1`]), SYS, content);
check('navChart: reports the discovered/missing split (4 of 5, j2 still out)',
chartMissing.discovered === 4 && chartMissing.total === 5 && chartMissing.complete === false
&& JSON.stringify(chartMissing.missing) === JSON.stringify([`${SYS}-j2`])
&& 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);
check('navChart: complete once every NAV point is discovered (6 of 6)',
chartDone.discovered === 6 && chartDone.total === 6 && chartDone.complete === true && chartDone.missing.length === 0);
const chartDone = navChart(makeDiscovery(POINT_IDS), SYS, content);
check('navChart: complete once every NAV point is discovered (5 of 5)',
chartDone.discovered === 5 && chartDone.total === 5 && chartDone.complete === true && chartDone.missing.length === 0);
const chartNoState = navChart(null, SYS, content);
check('navChart: a missing discovery state shows nothing discovered (0 of 6)',
chartNoState.discovered === 0 && chartNoState.total === 6 && chartNoState.complete === false);
check('navChart: a missing discovery state shows nothing discovered (0 of 5)',
chartNoState.discovered === 0 && chartNoState.total === 5 && chartNoState.complete === false);
// 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`

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).
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
wasn't already implied by the seed.
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 =
JSON + a few lines in `SystemGenerator.js`;
- **object count is global, not per-type** — `data/systems.json →
objectCount`: `barren` (10%) of all systems are jump-gate-only (no
planets, no stations — `barren``0`), the rest hold a weighted whole
number of OBJECTS (planets + free-space stations) from the `objects`
table (2/3/4/5). Stations roll first (02, per-type odds × the core→rim
density), then planets fill the remaining budget (N stations). The
**starting system** is the exception: it always holds exactly two
objectCount`: `barren` (10%) of all systems are gate-only DEAD-END
LEAVES of the jump network (no planets, no stations — `barren``0`;
exactly one gate — JumpNetwork keeps them at the tree's leaves), the
rest hold a weighted whole number of OBJECTS (planets + free-space
stations) from the `objects` table (2/3/4/5). Stations roll first
(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
world (the origin, the player's homestead, not a generated planet) makes
its three planets, always.
@ -207,11 +212,12 @@ world** — solid, rendered, flyable-to. Rules and seams:
- **World layout** — `SystemGenerator.layoutSystem(seed, systemId, planets,
freeSpace, isHome, targetAngles)` (pure, `js/galaxy/SystemGenerator.js`)
places each system's planets and free-space stations on a ring (a
regular polygon) around the central body (the home world at the origin
in the starting system, the star elsewhere), enforcing the SOLAR
regular polygon) around the system center (the home world at the
origin in the starting system; every other system's center is EMPTY —
its star is invisible flavor, never rendered), enforcing the SOLAR
SYSTEM BAND in `data/planets.json → solarSystem`: EVERY pair of layout
objects — planets, stations, the central body — sits center-to-center
in `[minSpacing, maxSpacing]` (640015360 px; the starting system's
objects — planets, stations (and the home world, starting system
only) — sits center-to-center in `[minSpacing, maxSpacing]` (640015360 px; the starting system's
band tightens to `[minSpacing, homeMaxSpacing]` = 640010240 px). The
ring radius is chosen inside the band (non-home: `maxSpacing` over the
chord of the N-gon; home: the (N+1)-gon side over the band's tight
@ -229,12 +235,13 @@ world** — solid, rendered, flyable-to. Rules and seams:
testable, save-ready: `toJSON()`/`fromJSON()`). Rule: the ship within
`game.discovery.distance` (data/game.json, default 540 px) of an
object's *edge* (center distance ≤ radius + distance) discovers it,
once, per system. The central body is discovered at spawn in EVERY
system: the home world (the ship starts beside it) in the starting
system — "Home World" exists in exactly one system — and the system's
STAR ("G-type Star" etc., `js/entities/Star.js`) in every other one;
both read the chart's central NAV point (id 'home'). Feedback: rim
ping + "DISCOVERED — NAME · TYPE" toast (GameScene.celebrateDiscovery).
once, per system. The home world is discovered at spawn in the starting
system (the ship starts beside it) — "Home World" exists in exactly one
system and is that system's central NAV point (id 'home'); every other
system's center is EMPTY (the star is invisible dossier flavor —
name/class, never rendered) and carries no central NAV point. Feedback:
rim ping + "DISCOVERED — NAME · TYPE" toast
(GameScene.celebrateDiscovery).
- **Compass**`js/ui/DiscoveryCompass.js` (screen-space Container,
scrollFactor 0). Every frame it refreshes the set of **discovered**
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
first use (`__compass_arrow`).
- **World solids** — the ship collides with *every* solid in the system
(`GameScene.solids`): the central body (the home world in the starting
system, the star elsewhere — both the 'home' NAV point), all system
(`GameScene.solids`): the home world (starting system only — it is the
only central body in the galaxy, and its 'home' NAV point), all system
planets, asteroid clusters, stations and jump gates: you can approach
a rim, never pass through.
- 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
degree-limited spanning tree of the nearest-star graph (each system's
`neighborPool` = 8 closest stars, `galaxy.neighborsOf`), with tree
edges run BOTH ways plus optional one-way `shortcuts` bought with the
spare gate budget. Result: the directed graph is **strongly
connected** (from any star you can reach any other — no closed
systems, no trapped sets), every jump is local, and no system holds
more than `maxGates` gates. A 3-tier repair (local attach → swap →
last-resort attach, logged) covers pathological pools, preferring a
working network over the cap.
edges run BOTH ways; `shortcuts` (one-way extra edges bought with the
spare gate budget) is a dormant code path, OFF in the current config
(`data/gates.json → shortcuts: false`) — so the network 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;
strong connectivity still holds by construction (a bidirected tree —
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)
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
`data/tether.json`) from it — so the tether rule is hard by
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
(same side of the anchor as the target, deviation < 90°; in practice
~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
Gate", "Avidy Gate II" — star names are syllable-generated and
collide). **Barren systems** (no anchors — the `objectCount` → 0 stops)
get their single gate on the star→destination ray at `barrenDistance`
(8192 px) instead. Every gate record carries `active: false` — gates
get their single gate on the center→destination ray at `barrenDistance`
(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);
activation is the SYSTEM research category's jumpgate tech completing
(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
back — activated with its twin per ACTIVITY, its tether anchoring the
landing), offset back along its facing with nose along the travel
direction; one-way SHORTCUT jumps have no return gate (JumpNetwork:
tree edges run both ways, shortcuts don't — ~⅓ of a typical galaxy's
gates) and land on the destination's star, inside its home-tether
zone. THE CLIP: the jump plays a full-screen one-shot between the two
direction; with shortcuts OFF (the current config) the network is a
pure spanning tree, so every jump has a return gate — JumpTravel's
null path (land on the destination's origin, inside its home-tether
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
source system must not show through; `jump.videoVolume` 0..1, 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
NAV System. Discover all NAV points to unlock the system Jumpgates.*
- **`Unlock {System} Jumpgates`** — requires the map; researchable once
**every NAV point of the system is discovered** (the central body, all
planets, all space stations, all jump gates — the scene's discoverable
set minus the asteroid clusters); 45 s
**every NAV point of the system is discovered** (the home world in the
starting system, and otherwise all planets, all space stations, all
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
activates the system's gates **and the return gates in the systems
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`.
- `js/galaxy/JumpTravel.js` — PURE (no Phaser): the jump's arrival
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
side of the gate's facing, nose along the travel direction),
`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
`dev/jump-travel.test.mjs` (contract, geometry, a real galaxy,
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] Galaxy seed on the main menu (displayed, editable, rerollable;
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
(theme + attributes + distribution weight/radius band)
- [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 { buildJumpNetwork } from './JumpNetwork.js';
import { assignPlanetFrames } from './PlanetFrames.js';
import { generateSystemContent } from './SystemGenerator.js';
import { generateSystemContent, rollSystemComposition, settlementDensity } from './SystemGenerator.js';
const TAU = Math.PI * 2;
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:
* `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
* what KIND each one is for the entire galaxy, from the seed alone.
*
@ -177,13 +177,32 @@ export class Galaxy {
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
// gates reach. A bidirected, degree-limited spanning tree of the
// nearest-star graph (plus local shortcuts) — STRONGLY CONNECTED, so
// there are no closed systems and no trapped sets: from any star the
// player can reach any other, and every system holds
// minGatesmaxGates gates (13 in data/gates.json). Deterministic:
// same roster ⇒ same network.
// gates reach. A PURE SPANNING TREE of the nearest-star graph (no
// shortcuts — data/gates.json → shortcuts:false) — the galaxy reads as
// a MAZE: exactly one route between any two systems, no closed loops.
// Tree edges run BOTH ways, so from any system the player can reach
// any other and every jump has a RETURN gate (no trapped sets). Each
// 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)));
this.jumpNetwork = buildJumpNetwork({
records,
@ -191,6 +210,7 @@ export class Galaxy {
minGates: Math.max(0, config.get('gates.minGates', 1) | 0),
maxGates: Math.max(1, config.get('gates.maxGates', 3) | 0),
shortcuts: config.get('gates.shortcuts', true) === true,
barren,
rootId: this.currentSystemId,
});
// 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
* backward reachability from the home system).
* 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
* same network (deterministic across machines and runs).
*
@ -24,19 +30,24 @@
* BFS-outward from the home system with a "keep the frontier open"
* child heuristic: attach the unvisited neighbors with the MOST
* 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
* by construction (the unique tree path between any two systems can
* 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.
* 4. Optional SHORTCUTS: 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.
* Every jump therefore has a RETURN gate (the destination's gate
* pointing back), so the player can always jump back the way they
* came.
* 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
* 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.maxGates=3] hard degree budget per node.
* @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
* system) it then keeps the lowest possible degree.
* @returns {{ gates: Map<string, string[]>, repaired: number }}
* 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).
*/
export function buildJumpNetwork({
@ -62,9 +78,11 @@ export function buildJumpNetwork({
minGates = 1,
maxGates = 3,
shortcuts = true,
barren = null,
rootId = null,
}) {
const n = records.length;
const isBarren = (i) => barren instanceof Set && barren.has(records[i].id);
const idx = new Map();
records.forEach((r, i) => idx.set(r.id, i));
const iOf = (id) => {
@ -110,6 +128,7 @@ export function buildJumpNetwork({
let head = 0;
while (head < queue.length) {
const u = queue[head++];
if (isBarren(u)) continue; // a dead end never adopts children (it keeps its single gate)
const budget = maxGates - deg[u];
if (budget <= 0) continue;
const cands = nbr[u].filter((v) => !visited[v]);
@ -151,9 +170,11 @@ export function buildJumpNetwork({
let repaired = 0;
for (let w = 0; w < n; w++) {
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;
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) {
// (2) swap: best (u, x) pair by dist(w, u), then indices
let bestU = -1, bestX = -1, bestNew = -1, bestD = Infinity;
@ -180,12 +201,16 @@ export function buildJumpNetwork({
}
}
if (u === -1) {
// (3) nearest visited node, budget be damned
// (3) nearest visited node, budget be damned (prefer non-barren)
let bestD = Infinity;
for (let c = 0; c < n; c++) {
if (!visited[c] || c === w) continue;
const d = dist2(w, c);
if (d < bestD) { bestD = d; u = c; }
for (const pass of [1, 0]) {
for (let c = 0; c < n; c++) {
if (!visited[c] || c === w) continue;
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)
console.warn(`[orbit] jump network: forced attach of ${records[w].id} (degree budget exceeded)`);

View File

@ -17,9 +17,12 @@
* jumpArrival(content, fromId, cfg)
* Both, together: { x, y, heading, gateId } or null when the
* destination holds no return gate (a one-way SHORTCUT jump
* JumpNetwork: tree edges run both ways, shortcuts don't). The
* scene falls back to the destination's origin (its home-tether
* zone) when this is null.
* JumpNetwork: tree edges run both ways, shortcuts don't). With
* shortcuts OFF (the current config) the network is a pure spanning
* 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
* 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
* 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}
* 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
* allPlanetsSettled + settledKindByClass): colonies on habitable worlds,
* mining stations over the rest, cloud bases riding gas giants. The
* BARREN systems (objectCount 0) are the deliberate exception: star and
* jump gates, nothing else dead-end stops on the network (strong
* connectivity keeps them reachable and escapable), 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
* BARREN systems (objectCount 0) are the deliberate exception: jump
* gates and, in most, an asteroid cluster drifting inside the gate's
* tether to mine at the stop and nothing else. They are the DEAD-END
* LEAVES of the jump network (a pure spanning tree, no loops the maze's
* 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
* settlement is a reserved seam for the factions and pirates we'll
* introduce later.
@ -56,26 +58,32 @@ const DEG = Math.PI / 180;
* stays a 4-object configuration (see layoutSystem).
*
* LAYOUT (data/planets.json solarSystem) the SOLAR SYSTEM BAND: 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 6400..15360 px apart, center to center; in the home
* system the band tightens to 6400..10240 px. Normal systems lay out as a
* regular N-gon ring around the star; the home system as a regular
* (N+1)-polygon with the home world as one vertex. The rotation is chosen
* to serve the jump gates objects bias toward the directions the system
* jumps (see layoutSystem + layoutGates).
* PAIR of layout objects (planets and free-space stations the central
* body, the home world, sits at the local origin of the STARTING system
* only; every other system has an EMPTY center the star is invisible
* flavor, generated as `star` but never rendered) sits 6400..15360 px
* apart, center to center; in the home system the band tightens to
* 6400..10240 px. Normal systems lay out as a regular N-gon ring around
* the (empty) center; the home system as a regular (N+1)-polygon with the
* home world as one vertex. The rotation is chosen to serve the jump
* gates objects bias toward the directions the system jumps (see
* layoutSystem + layoutGates).
*
* 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
* 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,
* or the home world) hosts its gates within level-1 tether (5120 px) of
* an anchor; a BARREN system (no anchors) hosts its gate(s) on the ray
* toward the destination, `barrenDistance` from the star. 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 strongly connected: no closed systems, no
* trapped sets, the whole galaxy is reachable.
* the upper right). An ANCHORED system (a planet, or the home world in
* the starting system) hosts its gates within level-1 tether (5120 px)
* of an anchor planets ONLY (free-space stations are deliberately not
* anchors: the build console lives on a world, so every gate must be
* tether-reachable from a planet the player can build out from). A BARREN
* system (no planets a dead-end leaf of the tree) hosts its single gate
* on the ray toward the destination, `barrenDistance` from the center.
* Every gate record carries `active` (default false the activation
* 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 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 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 massTable = attr.star?.mass ?? {};
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
* computed in the 2-D map plane, so "an upper-right gate" means "a
* star to the upper right on the map";
* - ANCHORED systems (a planet, a free-space station, or the home world):
* within level-`anchorTetherLevel` tether (5120 px for level 1) of an
* anchor on the anchor's tether circle, chosen in order of facing
* quality: (1) the far ray-circle intersection (the gate exactly on
* the target ray system, gate, and star collinear), (2) the point of
* the circle aimed exactly at the target star, (3) a forward-hemisphere
* scan of the circle (±75°). Every candidate is exactly `range` from
* its anchor, so tether-reachability holds by construction and the
* facing deviation never exceeds 90° (in practice a few degrees);
* - BARREN systems (no anchors objectCount 0): on the ray toward the
* destination, `barrenDistance` from the star (stepped outward within
* the radius band only if the gate gap forces it). The activation
* - ANCHORED systems (a planet or the home world in the starting
* system): within level-`anchorTetherLevel` tether (5120 px for level
* 1) of an anchor on the anchor's tether circle, chosen in order of
* facing quality: (1) the far ray-circle intersection (the gate
* exactly on the target ray system center, gate, and destination
* star collinear), (2) the point of the circle aimed exactly at the
* target star, (3) a forward-hemisphere scan of the circle (±75°).
* Every candidate is exactly `range` from its anchor, so
* tether-reachability holds by construction and the facing deviation
* never exceeds 90° (in practice a few degrees). Anchors are PLANETS
* only (plus the home world at home) free-space stations are
* deliberately excluded: the build console lives on a world, so every
* gate must be tether-reachable from a planet the player can build
* 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
* level-1 tether anchor see data/gates.json ACTIVITY;
* - stays `minRadius..maxRadius` from the star, `size` + `clearance`
* clear of every anchor disc, and 2·`size` + `gateGap` from every
* other gate.
* - stays `minRadius..maxRadius` from the center, `size` + `clearance`
* clear of every anchor disc (planets + free-space stations), and
* 2·`size` + `gateGap` from every other gate.
*
* The target list arrives ordered (the "road home" parent edge first) and
* 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 range = anchorTetherRange(g.anchorTetherLevel ?? 1);
// Anchors: the planets, the free-space stations, and (home only) the
// home world at the origin — the bodies a gate's tether may hang from.
// A BARREN system holds none — its gates use the on-ray rule below.
const anchors = [
...planets.map((p) => ({ x: p.x, y: p.y, r: planetRenderRadius(p) })),
// Anchors: the PLANETS and (home only) the home world at the origin —
// the bodies a gate's tether may hang from. Free-space stations are
// deliberately NOT anchors (the build console lives on a world — the
// player must always be able to reach a planet and build out from the
// 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) })),
];
if (isHome) anchors.push({ x: 0, y: 0, r: homeWorldRadius() });
const jumps = [];
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) {
// BARREN SYSTEM (objectCount → 0) — no anchor to tether to: the gate
// 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
// 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 okB = (px, py) => {
const d2c = px * px + py * py;
@ -568,7 +598,7 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
const ok = (c) => {
const d2c = c.px * c.px + c.py * c.py;
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 dx = c.px - b.x;
const dy = c.py - b.y;
@ -709,30 +739,45 @@ function homeWorldRadius() {
function generateAsteroidClusters(galaxy, record, planets, freeSpace, jumps = []) {
const cfg = config.section('asteroids', {});
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
// against — no clusters either (the scene renders nothing else anyway).
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 dist = cfg.distribution ?? {};
const placement = cfg.placement ?? {};
const isHome = record.id === galaxy?.homeSystemId;
// --- How many clusters: the inverse of the planet count ---------------
const target = dist.targetObjects ?? 9;
const jitter = dist.jitter ?? 1;
// --- How many clusters -------------------------------------------------
// BARREN (dead-end) systems: the stop's payload — a little rock to mine
// 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 maxC = Math.max(minC, Math.floor(dist.maxClusters ?? 6));
const homeMin = isHome ? Math.max(minC, Math.floor(dist.startingSystemMinClusters ?? 2)) : minC;
const rng = Rng.derive(galaxy.seed, 'system', record.id, 'asteroids');
const count = clamp(
Math.round(target - planets.length + rng.range(-jitter, jitter)),
homeMin, maxC,
);
let count;
if (isBarren) {
const bc = cfg.barren ?? {};
const lo = Math.max(0, Math.floor(bc.clusters?.[0] ?? 1));
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 [];
// --- Parameter plumbing (every value steerable from data/asteroids.json)
@ -760,15 +805,25 @@ function generateAsteroidClusters(galaxy, record, planets, freeSpace, jumps = []
const tetherRadius = homeTetherRadius();
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).
const nameRng = Rng.derive(galaxy.seed, 'system', record.id, 'names', 'asteroids');
const usedNames = new Set();
for (const p of planets) if (p.name) usedNames.add(p.name);
for (const s of freeSpace) if (s.name) usedNames.add(s.name);
// Spacing obstacles: the home world (origin) + every placed object
// (planets, free-space stations, and the jump gates).
const placed = [{ x: 0, y: 0 }];
// Spacing obstacles: the home world (origin — a real body in the
// starting system; every other system's center is empty) + every placed
// 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 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 });
@ -838,9 +893,21 @@ function generateAsteroidClusters(galaxy, record, planets, freeSpace, jumps = []
// The starting system's first clusters live INSIDE the initial tether
// (whole group: center + bound + margin ≤ tether radius); the rest —
// and every cluster elsewhere — go anywhere in the scatter annulus.
const zoneMax =
i < homeSlots ? Math.max(rMin, Math.min(rMax, tetherRadius - bound - tetherMargin)) : rMax;
const pos = placeInAnnulus(rng, rMin, zoneMax, placed, minSep, maxAttempts);
// A BARREN (dead-end) system's clusters drift INSIDE the gate's
// level-1 tether (the stop's only payload — mine it, then head back):
// 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);
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
* sampling. If the annulus is hopelessly crowded (it isn't, at these
* 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;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const a = rng.range(0, TAU);
const r = Math.sqrt(rng.range(rMin * rMin, rMax * rMax));
const x = Math.cos(a) * r;
const y = Math.sin(a) * r;
const x = center.x + Math.cos(a) * r;
const y = center.y + Math.sin(a) * r;
let ok = true;
let worst = Infinity;
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 deepSpace = rngS.chance((spec?.deepSpaceStation?.chance ?? 0.12) * 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).
// 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 };
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'));
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
* discoverableObjects() uses for its discoverable set, minus the rocks
* (asteroid clusters are objects, not NAV points): the central body
* (every system the scene's 'home' id, the player's home world in the
* starting system and the system's central world elsewhere), every
* (asteroid clusters are objects, not NAV points): the central body
* the player's home world, STARTING SYSTEM ONLY (content carries
* 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),
* every jump gate (its gate id).
*
@ -152,16 +153,20 @@ export function buildSystemTree(o = {}) {
* @returns {string[]} the discoverable ids to check (order: stable)
*/
/**
* The system's NAV points (with their KIND), in a stable order: the central
* body ('home'), then the planets (by name), the free-space stations
* (settlement id), and the jump gates (gate id). Asteroid clusters are NOT
* NAV points they're objects you can mine, not chart waypoints.
* The system's NAV points (with their KIND), in a stable order: the
* central body ('home') the starting system only, where it is the home
* world then the planets (by name), the free-space stations (settlement
* 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)
* @returns {Array<{id:string, kind:'home'|'planet'|'station'|'gate'>}>
*/
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 s of content?.settlements ?? []) {
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
* 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.
* Everything about the RECORD's shape is owned by js/save/SaveData.js
@ -28,7 +28,12 @@
import { config } from '../config/Config.js';
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 {
/**
@ -163,7 +168,7 @@ export class SaveManager {
/**
* 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)
*/
@ -186,12 +191,20 @@ export class SaveManager {
/**
* 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
*/
static validateRecord(rec) {
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 (!rec.ship || typeof rec.ship.x !== 'number' || typeof rec.ship.y !== 'number') {
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 { Ship } from '../entities/Ship.js';
import { Planet } from '../entities/Planet.js';
import { Star } from '../entities/Star.js';
import { AsteroidCluster } from '../entities/AsteroidCluster.js';
import { Station } from '../entities/Station.js';
import { JumpGate } from '../entities/JumpGate.js';
@ -307,16 +306,15 @@ export class GameScene extends Phaser.Scene {
for (const j of this.systemContent.jumps ?? []) {
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
// system's 'home' NAV point (js/research/SystemCategory.js → navPoints):
// in the STARTING system it is the player's home world (the generator
// deals its name — content.homeName — so it reads as a real place
// rather than a generic "Terra"; the fallback keeps the old configured
// name if the bank is ever unavailable); in every OTHER system it is
// the system's STAR (content.star — name + spectral class). The home
// world is the ONLY central body that is a world: only here is it a
// comms target (settled, landable) and only here does it read "Home
// World" — a star is not a comms object, and never "home".
// The CENTRAL BODY — the home world, present ONLY in the STARTING
// system, at the world origin (the generator deals its name —
// content.homeName — so it reads as a real place rather than a generic
// "Terra"; the fallback keeps the old configured name if the bank is
// ever unavailable). It is the only central body in the galaxy: in
// every other system the center is EMPTY (the star is invisible flavor
// — content.star name/class feeds the dossier and gate names, never
// rendered) and is the ONLY system where the origin is a comms target
// (settled, landable) and reads "Home World".
// NOTE: home is the STARTING system (galaxy.homeSystemId) — stable
// across jumps. Comparing against galaxy.currentSystemId would be
// wrong: that pointer tracks where the player IS NOW, so every
@ -328,7 +326,7 @@ export class GameScene extends Phaser.Scene {
this.createSystemHud();
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
// Terran face it shows comes from the galaxy-wide frame pass
// (content.homeFrame — the same (class, face) spreading as the
@ -342,14 +340,14 @@ export class GameScene extends Phaser.Scene {
: Planet.frameFor(homeName, homeRng);
this.planet = new Planet(this, 0, 0, homeFrame, homeName);
this.planet.discoveryName = this.homeWorldName;
this.planet.setDepth(5); // above the starfield (depths 02), below the ship (10)
} else {
// The system's star — the central body of every other system
// (content.star from the generator: name + spectral class).
const star = this.systemContent.star ?? {};
this.planet = new Star(this, 0, 0, star);
this.planet.discoveryName = star.name ?? 'Star';
// Every other system has NO central body: the star is INVISIBLE
// flavor (content.star — name/class/binary — feeds the dossier and
// gate names, but is never rendered). The origin is empty space;
// the gate tethers are the player's anchors here.
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
// 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
// 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
// against this list.
this.solids = [this.planet, ...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations, ...this.systemGates];
// The central body's discovery id is 'home' in EVERY system — it is
// the chart's central NAV point (SystemCategory.navPoints); its NAME
// differs: the home world in the starting system, the star elsewhere.
this.planet.discoveryId = 'home';
this.solids = [
...(this.planet ? [this.planet] : []),
...this.systemPlanets, ...this.asteroidClusters, ...this.systemStations, ...this.systemGates,
];
// 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
// rim, in a seed-derived direction: same galaxy ⇒ same start.
// The ship — in the starting system, a short hop (~150 px,
// 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)) {
console.warn(
`[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.setDepth(10);
const spawn = this.planet.edgePoint(
Rng.derive(this.galaxy.seed, 'spawn', 'ship').range(0, Math.PI * 2),
config.get('planets.spawnDistanceFromEdge', 150),
this.ship.radius,
);
const spawn = this.planet
? this.planet.edgePoint(
Rng.derive(this.galaxy.seed, 'spawn', 'ship').range(0, Math.PI * 2),
config.get('planets.spawnDistanceFromEdge', 150),
this.ship.radius,
)
: { x: 0, y: 0 };
this.ship.setPosition(spawn.x, spawn.y);
// 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.create();
// The TETHER — the player's range. Starts as one level-1 tether anchored
// on the home world (data/tether.json): the ship may fly anywhere within
// its rim (level 1 = 5120 px from the planet's center); the rim is a
// hard barrier drawn as a thick glitchy dotted line. 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.
// The TETHER — the player's range. In the STARTING system it starts
// as one level-1 tether anchored on the home world (data/tether.json):
// the ship may fly anywhere within its rim (level 1 = 5120 px from the
// planet's center); the rim is a hard barrier drawn as a thick
// glitchy dotted line. In every OTHER system there is no native
// tether — the center is empty — the player's range is the activated
// 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, {
depth: 6, // above planets (5), below the ship (10)
});
this.tetherField.add(
config.get('tether.homeId', 'home'),
0, 0, // the central body's center (home world or star — the system origin)
config.get('tether.homeLevel', 1),
config.get('tether.homeLabel', '') || (this.isHomeSystem ? this.homeWorldName : this.planet.discoveryName),
);
if (this.isHomeSystem) {
this.tetherField.add(
config.get('tether.homeId', 'home'),
0, 0, // the home world's center (the system origin)
config.get('tether.homeLevel', 1),
config.get('tether.homeLabel', '') || this.homeWorldName,
);
}
// Each activated gate anchors its tether (data/gates.json → ACTIVITY:
// 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
@ -1444,23 +1460,23 @@ export class GameScene extends Phaser.Scene {
/** The discoverable objects of the system, with compass metadata. */
discoverableObjects() {
const out = [];
// The central body — the player's home world in the starting system,
// the system's star in every other one (discovery id 'home' either
// way: the chart's central NAV point).
out.push({
id: 'home',
x: this.planet.x,
y: this.planet.y,
radius: this.planet.radius,
typeLabel: this.isHomeSystem
? config.get('planets.homeTypeLabel', 'Home World')
: config.get(`planets.starTypeLabels.${this.systemContent.star?.class}`, 'Star'),
// The compass accent: the home world is a PLANET (green —
// data/planets.json → compassColor, like the system's worlds); the
// star isn't a planet and keeps the compass's default neon cyan.
color: this.isHomeSystem ? config.get('planets.compassColor', '#3dff88') : undefined,
name: this.planet.discoveryName,
});
// The central body — the home world in the STARTING SYSTEM ONLY
// (discovery id 'home': the chart's central NAV point). Every other
// system's center is empty — its star is invisible flavor (content.star
// feeds the dossier, not the chart) — so no central NAV point there.
if (this.planet) {
out.push({
id: 'home',
x: this.planet.x,
y: this.planet.y,
radius: this.planet.radius,
typeLabel: config.get('planets.homeTypeLabel', 'Home World'),
// The compass accent: the home world is a PLANET (green —
// data/planets.json → compassColor, like the system's worlds).
color: config.get('planets.compassColor', '#3dff88'),
name: this.planet.discoveryName,
});
}
for (const p of this.systemPlanets) {
out.push({
id: p.discoveryId,
@ -1666,9 +1682,10 @@ export class GameScene extends Phaser.Scene {
* run moves to the connected system, arriving near that system's
* RETURN gate (the one pointing back activated with the gate per
* the ACTIVITY rule, its tether anchoring the ship's room to move;
* the pure geometry is js/galaxy/JumpTravel.js). One-way shortcut
* jumps have no return gate they land on the destination's star,
* inside its home-tether zone.
* the pure geometry is js/galaxy/JumpTravel.js). With shortcuts OFF
* the network is a pure spanning tree, so every jump has a return
* 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
* WHOLE run (discovery, reputation, research, builds, minerals,
@ -1703,8 +1720,9 @@ export class GameScene extends Phaser.Scene {
return;
}
// Where we arrive: just past the return gate's keepout (JumpTravel).
// Fallback — no return gate (a one-way shortcut): the destination's
// star, inside its home tether (added unconditionally in create()).
// Fallback — no return gate (a one-way shortcut, shortcuts are OFF in
// 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 arrival =
jumpArrival(destContent, from.id, {
@ -2335,18 +2353,23 @@ export class GameScene extends Phaser.Scene {
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 central = this.isHomeSystem
? {
name: this.homeWorldName ?? 'Terra',
isHome: true,
visible: true,
radius: this.planet?.radius ?? 200,
typeLabel: config.get('planets.homeTypeLabel', 'Home World'),
}
: {
name: this.planet?.discoveryName ?? 'Star',
isHome: false,
visible: false,
radius: 240,
typeLabel: config.get(`planets.starTypeLabels.${starClass}`, 'Star'),
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
* 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/
* naming.json banks). A deep pool of hand-picked names colonial