diff --git a/README.md b/README.md index bca6b36..27e4dda 100644 --- a/README.md +++ b/README.md @@ -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 1–2 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 1–3 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 (1–3 gates, local jumps, strong connectivity) + placement (tether anchor, facing, radii, gaps) +│ ├── gates.json # JUMP GATES: network (1–3 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/ diff --git a/data/asteroids.json b/data/asteroids.json index 910f6a5..618445e 100644 --- a/data/asteroids.json +++ b/data/asteroids.json @@ -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, diff --git a/data/galaxy.json b/data/galaxy.json index eb09307..ff485dd 100644 --- a/data/galaxy.json +++ b/data/galaxy.json @@ -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": [] diff --git a/data/gates.json b/data/gates.json index d83b59f..ac6bebc 100644 --- a/data/gates.json +++ b/data/gates.json @@ -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 1–2 asteroid clusters drifting inside the gate's level-1 tether (data/asteroids.json → barren). ACTIVITY: every gate record carries `active` (DEFAULT FALSE — the activation mechanic is future work). When a gate is activated, a level-1 tether (5120 px, data/tether.json) attaches to it: an active gate is a TETHER ANCHOR in its own right. In a barren system that tether is the player's entire room to move (the player arrives AT the gate — the center is empty: the star is invisible flavor, never rendered); in an anchored system it simply adds another anchor circle. A gate keeps `size` + `clearance` (px, center-to-center) from any solid disc (planets + free-space stations + the home world), 2·size + `gateGap` from any other gate, and stays between `minRadius` and `maxRadius` from the center. size = the gate's keepout radius (px). shipClearance = the ship's keepout from the gate (the solid rule, like planets). theme.color = the HUD/compass color (an inactive gate renders dimmed). typeLabel = the discovery/compass label. texture/frameWidth/frameHeight = the spritesheet (assets/images/jumpgate.png, 256×256 frames): frame 0 = the gate body (ring + pylons, drawn static), frame 1 = the active swirl (see swirl); sprite scale = (size×2)/frameWidth puts the ring's outer edge on the keepout disc. A missing sheet falls back to the built-in procedural gate (console note).", "enabled": true, "minGates": 1, "maxGates": 3, "neighborPool": 8, - "shortcuts": true, + "shortcuts": false, "size": 96, "texture": "assets/images/jumpgate.png", "frameWidth": 256, diff --git a/data/naming.json b/data/naming.json index 2b973a6..25034e3 100644 --- a/data/naming.json +++ b/data/naming.json @@ -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, diff --git a/data/planets.json b/data/planets.json index cc171a9..e771170 100644 --- a/data/planets.json +++ b/data/planets.json @@ -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", diff --git a/data/systems.json b/data/systems.json index ac8b696..5c55ac3 100644 --- a/data/systems.json +++ b/data/systems.json @@ -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 1–2 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 } diff --git a/dev/asteroids.test.mjs b/dev/asteroids.test.mjs index 39040ba..6b899ba 100644 --- a/dev/asteroids.test.mjs +++ b/dev/asteroids.test.mjs @@ -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 1–2 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: 1–2 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 targetObjects−planets (±jitter), clamped ${D.minClusters}–${D.maxClusters} (barren ⇒ 0)${countOk ? '' : ' — ' + countWhy}`, countOk); +check(`count: ${sample.length} systems obey targetObjects−planets (±jitter), clamped ${D.minClusters}–${D.maxClusters} (barren ⇒ ${A.barren?.clusters?.join('–') ?? '1–2'})${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 1–2 payload clusters around the gate, a +// separate rule). { const rows = sample.map((r) => { const c = g.ensureContent(r.id); diff --git a/dev/galaxy.test.mjs b/dev/galaxy.test.mjs index b91f4ba..1978032 100644 --- a/dev/galaxy.test.mjs +++ b/dev/galaxy.test.mjs @@ -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)', diff --git a/dev/jumps.test.mjs b/dev/jumps.test.mjs index c1b5a67..7b3b05a 100644 --- a/dev/jumps.test.mjs +++ b/dev/jumps.test.mjs @@ -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 N−1 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 system→star 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 system→star bearing from the + * anchoring object); stations are NOT anchors (the player must be + * able to build out from a world), but they DO get clearance; + * - 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 1–2 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 N−1 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 -j 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 + // 1–2 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); } // ---------------------------------------------------------------------- diff --git a/dev/nav-reach-probe.mjs b/dev/nav-reach-probe.mjs deleted file mode 100644 index 86de4e4..0000000 --- a/dev/nav-reach-probe.mjs +++ /dev/null @@ -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 Y→X 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(' ')}`); -} diff --git a/dev/saves.test.mjs b/dev/saves.test.mjs index 34744ab..4896fc4 100644 --- a/dev/saves.test.mjs +++ b/dev/saves.test.mjs @@ -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))); } // --------------------------------------------------------------------------- diff --git a/dev/star.test.mjs b/dev/star.test.mjs deleted file mode 100644 index 644765f..0000000 --- a/dev/star.test.mjs +++ /dev/null @@ -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-). - */ -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); diff --git a/dev/system-category.test.mjs b/dev/system-category.test.mjs index af6c0dd..c9e5142 100644 --- a/dev/system-category.test.mjs +++ b/dev/system-category.test.mjs @@ -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` diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index d3a63e0..361addb 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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 (0–2, 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 + (0–2, 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]` (6400–15360 px; the starting system's + objects — planets, stations (and the home world, starting system + only) — sits center-to-center in `[minSpacing, maxSpacing]` (6400–15360 px; the starting system's band tightens to `[minSpacing, homeMaxSpacing]` = 6400–10240 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` (2048–20480 - 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 1–2 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 diff --git a/js/entities/Star.js b/js/entities/Star.js deleted file mode 100644 index fcef258..0000000 --- a/js/entities/Star.js +++ /dev/null @@ -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(); - } -} diff --git a/js/galaxy/Galaxy.js b/js/galaxy/Galaxy.js index fbf59c5..a1621b3 100644 --- a/js/galaxy/Galaxy.js +++ b/js/galaxy/Galaxy.js @@ -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 - // minGates–maxGates gates (1–3 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 1–maxGates 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). diff --git a/js/galaxy/JumpNetwork.js b/js/galaxy/JumpNetwork.js index 3326a48..68eac77 100644 --- a/js/galaxy/JumpNetwork.js +++ b/js/galaxy/JumpNetwork.js @@ -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|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, 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)`); diff --git a/js/galaxy/JumpTravel.js b/js/galaxy/JumpTravel.js index 1926cd0..209881b 100644 --- a/js/galaxy/JumpTravel.js +++ b/js/galaxy/JumpTravel.js @@ -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. diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js index c51a430..6a27862 100644 --- a/js/galaxy/SystemGenerator.js +++ b/js/galaxy/SystemGenerator.js @@ -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): * 1–3 gates per system — each placed on the side of the system facing its * destination star on the 2-D map (an upper-right gate jumps to a star in - * the upper right). An ANCHORED system (a planet, 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 1–2 + // 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 1–2). 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 }; } /** diff --git a/js/research/SystemCategory.js b/js/research/SystemCategory.js index d01f5b5..133cde0 100644 --- a/js/research/SystemCategory.js +++ b/js/research/SystemCategory.js @@ -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' }); diff --git a/js/save/SaveManager.js b/js/save/SaveManager.js index f0678d0..d7817d4 100644 --- a/js/save/SaveManager.js +++ b/js/save/SaveManager.js @@ -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'; diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 6db4c16..14ccc3b 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -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 0–2), 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 0–2), 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')), diff --git a/js/utils/NameGenerator.js b/js/utils/NameGenerator.js index 76c0f49..ec5cd8b 100644 --- a/js/utils/NameGenerator.js +++ b/js/utils/NameGenerator.js @@ -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