Galaxy refactor: 200 systems, object-composition, dormant gates, frame diversity
- Galaxy shrunk to 200 systems (data/galaxy.json) — always fully known, generation ~15 ms; roster/content two-level design unchanged. - Object composition replaces planetCount (data/systems.json → objectCount): 10% barren (jump-gate-only), else 2/3/4/5 objects (planets + free-space stations) at 15/30/30/15. One unified roll (rollSystemComposition) shared by the content generator and the frame pass. - Barren systems: no planets, no stations, no asteroid clusters; their single gate sits on the star→destination ray at barrenDistance (8192 px). Strong connectivity keeps them reachable. - Jump gates are DORMANT by default: every gate record carries active:false, the entity renders dim with a still field. Activation is the seam for the tether mechanic (an activated gate anchors a level-1 tether). data/gates.json gains barrenDistance. - Frame diversity (js/galaxy/PlanetFrames.js): one galaxy-wide pass spreads (class, frame) across the galaxy — fixed spatial order, least-used face among already-assigned nearest stars, seeded tie-breaks. Stamps planet.frame + content.homeFrame, read by GameScene. - Tests: dev/frames.test.mjs added; jumps/galaxy/discovery/jumpgate/asteroids suites updated to the new rules (composition buckets, barren gates, active flags, frame determinism/spread, 5-object max, index clamps). - Docs (README, PROJECT_NOTES) and stale 40k references updated.
This commit is contained in:
parent
a1aa25b5ba
commit
0b92cb7b98
22
README.md
22
README.md
|
|
@ -36,12 +36,12 @@ 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**: 40,000 star systems in a seeded disk + core +
|
||||
- **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.
|
||||
- **Two-level generation**: the whole galaxy roster is generated at New
|
||||
Game (~70 ms); each system's planets/moons/belts/settlements are
|
||||
Game (~15 ms); each system's planets/moons/belts/settlements are
|
||||
generated lazily on arrival, deterministically (seed + system id), so
|
||||
lazy and eager give identical results.
|
||||
- **A lived-in galaxy**: the galaxy was settled long before you arrive.
|
||||
|
|
@ -49,9 +49,11 @@ node dev/server.mjs 8080
|
|||
stations over the rest, cloud bases riding gas giants — plus stations
|
||||
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 with nothing adrift report
|
||||
*charted · unclaimed*. Systems hold 2–4 planets, or none (~20% are
|
||||
barren); the starting system always holds the home world, a gas giant,
|
||||
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
|
||||
(planets + free-space stations, `data/systems.json → objectCount`);
|
||||
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.
|
||||
The current system's dossier (name, identity, what's there) shows
|
||||
|
|
@ -69,7 +71,10 @@ 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);
|
||||
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);
|
||||
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
|
||||
|
|
@ -238,9 +243,10 @@ runtime data — loaders and tests ignore them.
|
|||
```sh
|
||||
node dev/ship-behavior.test.mjs # runs the real Ship.update() loop in Node
|
||||
node dev/starfield.test.mjs # runs the real Starfield.create() in Node
|
||||
node dev/galaxy.test.mjs # galaxy determinism, distribution, lazy vs eager, gate anchors + report
|
||||
node dev/galaxy.test.mjs # galaxy determinism, composition, lazy vs eager, gradient + report
|
||||
node dev/frames.test.mjs # the galaxy-wide (class, frame) pass: in-pool, spread, determinism
|
||||
node dev/jumps.test.mjs # the gate network: 1–3 gates, locality, strong connectivity + placement invariants
|
||||
node dev/jumpgate.test.mjs # the JumpGate entity: discovery fields + the solid contract (Node)
|
||||
node dev/jumpgate.test.mjs # the JumpGate entity: discovery fields, dormant/active, the solid contract (Node)
|
||||
node dev/discovery.test.mjs # discovery rules + compass geometry + chip hit test
|
||||
node dev/tether.test.mjs # tether range math: union, clamp, visible arcs (no line in overlaps)
|
||||
node dev/research-builds.test.mjs # data contract: research/builds/actionbar shapes + manifest
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"systemCount": 40000,
|
||||
"systemCount": 200,
|
||||
"radius": 20000,
|
||||
"layout": {
|
||||
"coreFraction": 0.25,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"_comment": "JUMP GATES — the galaxy's highway layer (js/galaxy/JumpNetwork.js for the network, js/galaxy/SystemGenerator.js → layoutGates for the in-system placement). NETWORK: every system holds between minGates and maxGates jump gates; every gate jumps to one of the system's `neighborPool` nearest stars on the 2-D map (the roster's x/y plane). The network is a degree-limited spanning tree of that neighbor graph (tree edges run BOTH ways) 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), every jump is to a nearby star, and no system ever holds more than maxGates gates. PLACEMENT: 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. 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. typeLabel = the discovery/compass label.",
|
||||
"_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.",
|
||||
"enabled": true,
|
||||
"minGates": 1,
|
||||
"maxGates": 3,
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
"minRadius": 2048,
|
||||
"maxRadius": 20480,
|
||||
"anchorTetherLevel": 1,
|
||||
"barrenDistance": 8192,
|
||||
"typeLabel": "Jump Gate",
|
||||
"theme": { "color": "#5fd4ff" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 40,000-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 200-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,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
{
|
||||
"_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). planetCount is a GLOBAL rule, not per-type: noneChance of all systems are barren (no planets), the rest hold a uniform whole number in [min, max]; the STARTING system always holds exactly two generated planets (gas giant + rocky) beside the home world. 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.",
|
||||
"planetCount": { "noneChance": 0.2, "min": 2, "max": 4 },
|
||||
"_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.",
|
||||
"objectCount": {
|
||||
"barren": 0.10,
|
||||
"objects": { "2": 0.15, "3": 0.30, "4": 0.30, "5": 0.15 }
|
||||
},
|
||||
"types": {
|
||||
"main": {
|
||||
"label": "Main Sequence",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
* - 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 sample);
|
||||
* aggregate correlation across the non-barren sample); barren systems
|
||||
* (jump-gate-only) hold NO clusters;
|
||||
* - the STARTING system always gets ≥ startingSystemMinClusters, and
|
||||
* those first ones sit INSIDE the initial tether (whole cluster,
|
||||
* minus placement.tetherMargin);
|
||||
|
|
@ -121,12 +122,21 @@ 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.
|
||||
const want = D.targetObjects - c.planets.length;
|
||||
let loW = Math.max(D.minClusters, isHome ? D.startingSystemMinClusters : -Infinity, Math.round(want - D.jitter));
|
||||
let hiW = Math.min(D.maxClusters, Math.round(want + D.jitter));
|
||||
if (loW > hiW) { loW = D.minClusters; hiW = D.maxClusters; }
|
||||
if (clusters.length < loW || clusters.length > hiW) {
|
||||
countOk = false; countWhy = `${rec.id}: ${c.planets.length} planets → ${clusters.length} clusters (want ${loW}–${hiW})`; break;
|
||||
// BARREN systems (0 planets + 0 free-space stations — the jump-gate-only
|
||||
// stops) are the one exception: no clusters by design.
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
const want = D.targetObjects - c.planets.length;
|
||||
let loW = Math.max(D.minClusters, isHome ? D.startingSystemMinClusters : -Infinity, Math.round(want - D.jitter));
|
||||
let hiW = Math.min(D.maxClusters, Math.round(want + D.jitter));
|
||||
if (loW > hiW) { loW = D.minClusters; hiW = D.maxClusters; }
|
||||
if (clusters.length < loW || clusters.length > hiW) {
|
||||
countOk = false; countWhy = `${rec.id}: ${c.planets.length} planets → ${clusters.length} clusters (want ${loW}–${hiW})`; break;
|
||||
}
|
||||
}
|
||||
|
||||
// Names already used in this system (planets + stations + clusters).
|
||||
|
|
@ -224,7 +234,7 @@ for (const rec of sample) {
|
|||
if (!shapeOk || !spacingOk || !annulusOk || !spinOk || !namesOk || !homeOk) break;
|
||||
}
|
||||
|
||||
check(`count: ${sample.length} systems obey targetObjects−planets (±jitter), clamped ${D.minClusters}–${D.maxClusters}${countOk ? '' : ' — ' + countWhy}`, countOk);
|
||||
check(`count: ${sample.length} systems obey targetObjects−planets (±jitter), clamped ${D.minClusters}–${D.maxClusters} (barren ⇒ 0)${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);
|
||||
|
|
@ -237,17 +247,19 @@ check(
|
|||
);
|
||||
|
||||
// The inverse rule, in aggregate: rockier systems (few planets) get more
|
||||
// clusters than planet-rich ones.
|
||||
// clusters than planet-rich ones — over the NON-BARREN systems (barren are
|
||||
// jump-gate-only and hold 0 clusters by design, a separate rule).
|
||||
{
|
||||
const rows = sample.map((r) => {
|
||||
const c = g.ensureContent(r.id);
|
||||
return { planets: c.planets.length, clusters: c.asteroids.length };
|
||||
});
|
||||
const avg = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length;
|
||||
const barren = c.planets.length === 0 && (c.settlements ?? []).every((s) => s.anchor?.type !== 'space');
|
||||
return { planets: c.planets.length, clusters: c.asteroids.length, barren };
|
||||
}).filter((r) => !r.barren);
|
||||
const avg = (xs) => xs.reduce((s, x) => s + x, 0) / (xs.length || 1);
|
||||
const rich = rows.filter((r) => r.planets >= 4);
|
||||
const poor = rows.filter((r) => r.planets <= 2);
|
||||
check(
|
||||
`inverse rule (aggregate): avg clusters — ≤2 planets: ${avg(poor.map((r) => r.clusters)).toFixed(2)} > ≥4 planets: ${avg(rich.map((r) => r.clusters)).toFixed(2)}`,
|
||||
`inverse rule (aggregate, non-barren): avg clusters — ≤2 planets: ${avg(poor.map((r) => r.clusters)).toFixed(2)} > ≥4 planets: ${avg(rich.map((r) => r.clusters)).toFixed(2)}`,
|
||||
rich.length > 0 && poor.length > 0 && avg(poor.map((r) => r.clusters)) > avg(rich.map((r) => r.clusters)),
|
||||
);
|
||||
}
|
||||
|
|
@ -273,7 +285,7 @@ check(
|
|||
// Lazy === eager: a FRESH galaxy's on-arrival content matches the cached
|
||||
// one, including every cluster's position, rocks, spins and debris.
|
||||
const fresh = Galaxy.create(SEED);
|
||||
const ids = [homeId, g.records[999].id, g.records[g.records.length - 1].id];
|
||||
const ids = [homeId, g.records[Math.floor(g.records.length / 2)].id, g.records[g.records.length - 1].id];
|
||||
const lazyEager = ids.every((id) => {
|
||||
const f = fresh.ensureContent(id);
|
||||
const cached = g.ensureContent(id);
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@
|
|||
* elsewhere) — sits in the band [minSpacing, maxSpacing] px
|
||||
* center-to-center (the home system's band tightens to
|
||||
* [minSpacing, homeMaxSpacing] and it holds ≤ 3 objects — 5 points
|
||||
* cannot sit 6400..10240 px apart), planets scaled by class —
|
||||
* deterministically (same seed ⇒ same layout, different seed ⇒
|
||||
* cannot sit 6400..10240 px apart), planets scaled by class, the
|
||||
* OBJECT COMPOSITION holding (data/systems.json → objectCount: every
|
||||
* non-home system 0/2/3/4/5 objects, in the configured proportions)
|
||||
* — deterministically (same seed ⇒ same layout, different seed ⇒
|
||||
* different);
|
||||
* - the COMPASS geometry (js/ui/DiscoveryCompass.js): edgeAnchor lands on
|
||||
* the screen-edge rect (edges AND corners), circleInView is exact,
|
||||
|
|
@ -117,7 +119,7 @@ const HOME_MAX = band.homeMaxSpacing; // the home system's tighter maximum
|
|||
const g = Galaxy.create('discovery-layout-test');
|
||||
let layoutOk = true;
|
||||
let layoutWhy = '';
|
||||
let sawMaxObjects = false; // the N = 6 maximum (4 planets + 2 stations)
|
||||
let sawMaxObjects = false; // the N = 5 maximum (3 planets + 2 stations)
|
||||
let maxObjectsSeen = 0;
|
||||
let homeOk = true;
|
||||
const probe = (systems) => {
|
||||
|
|
@ -140,7 +142,9 @@ const probe = (systems) => {
|
|||
// Layout objects: the central body (origin — the home world in the
|
||||
// starting system, the star elsewhere) + planets + free-space
|
||||
// stations (planet-bound settlements sit ON their planet — not
|
||||
// layout objects of their own).
|
||||
// layout objects of their own). A BARREN system (objectCount → 0)
|
||||
// holds none — its only objects are the jump gates (not layout
|
||||
// objects here; the band check degenerates to nothing).
|
||||
const objs = [{ x: 0, y: 0 }];
|
||||
for (const p of c.planets) objs.push({ x: p.x, y: p.y });
|
||||
for (const s of c.settlements ?? []) {
|
||||
|
|
@ -154,7 +158,11 @@ const probe = (systems) => {
|
|||
}
|
||||
const nObjects = objs.length - 1;
|
||||
if (nObjects > maxObjectsSeen) maxObjectsSeen = nObjects;
|
||||
if (nObjects === 6) sawMaxObjects = true;
|
||||
if (nObjects === 5) sawMaxObjects = true;
|
||||
if (!isHome && !(nObjects === 0 || (nObjects >= 2 && nObjects <= 5))) {
|
||||
layoutOk = false;
|
||||
if (!layoutWhy) layoutWhy = `${rec.id}: holds ${nObjects} objects (composition wants 0/2/3/4/5)`;
|
||||
}
|
||||
// The home system holds at most 3 objects (its 2 fixed planets + at
|
||||
// most one free-space station) — 5 points cannot sit 6400..10240 px
|
||||
// apart (the tightest 5-point spacing needs ratio ≥ φ > 1.6).
|
||||
|
|
@ -178,13 +186,14 @@ const probe = (systems) => {
|
|||
}
|
||||
}
|
||||
};
|
||||
probe(g.records.slice(0, 5000));
|
||||
probe(g.records);
|
||||
const nProbe = g.records.length;
|
||||
check(
|
||||
`layout: 5000 systems obey the band — every pair (incl. the central body) in [${MIN_SEP}, ${MAX_SP}] px, home in [${MIN_SEP}, ${HOME_MAX}] px${layoutOk ? '' : ' — ' + layoutWhy}`,
|
||||
`layout: ${nProbe} systems obey the band — every pair (incl. the central body) in [${MIN_SEP}, ${MAX_SP}] px, home in [${MIN_SEP}, ${HOME_MAX}] px${layoutOk ? '' : ' — ' + layoutWhy}`,
|
||||
layoutOk && homeOk,
|
||||
);
|
||||
check(`layout: no system exceeds the 6-object maximum (4 planets + 2 stations) — max in sample: ${maxObjectsSeen}`, maxObjectsSeen <= 6);
|
||||
check('layout: the 6-object maximum (4 planets + 2 stations) occurs in the sample', sawMaxObjects);
|
||||
check(`layout: no system exceeds the 5-object maximum (3 planets + 2 stations) — max in galaxy: ${maxObjectsSeen}`, maxObjectsSeen <= 5);
|
||||
check('layout: the 5-object maximum occurs in the galaxy', sawMaxObjects);
|
||||
|
||||
// Determinism on a system that has BOTH planet and station objects.
|
||||
const rec0 = g.records.find((r) =>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
/**
|
||||
* Frame-diversity test (dev tool, run with Node — no browser needed):
|
||||
*
|
||||
* node dev/frames.test.mjs
|
||||
*
|
||||
* Asserts the galaxy-wide (class, frame) pass (js/galaxy/PlanetFrames.js,
|
||||
* wired in js/galaxy/Galaxy.js, data/planets.json → frames):
|
||||
* - every planet carries a sheet frame inside its class's pool, and the
|
||||
* starting system's home world carries a terran-pool frame
|
||||
* (content.homeFrame);
|
||||
* - two planets of the SAME class in one system never wear the same
|
||||
* face (intra-system diversity);
|
||||
* - the pass beats a naive random pick: the (class, frame) collision
|
||||
* rate among each planet's 8-nearest stars is LOWER than the same
|
||||
* metric for an independent random-per-planet assignment;
|
||||
* - determinism: same seed ⇒ identical frames (galaxy and content),
|
||||
* different seed ⇒ different assignment (spot check);
|
||||
* - lazy === eager: the stamped frames equal a fresh galaxy's.
|
||||
*/
|
||||
|
||||
process.env.NODE_ENV = 'dev';
|
||||
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
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);
|
||||
|
||||
const { Galaxy } = await import(pathToFileURL(join(__dirname, '../js/galaxy/Galaxy.js')).href);
|
||||
const { Rng } = await import(pathToFileURL(join(__dirname, '../js/utils/Rng.js')).href);
|
||||
|
||||
let failures = 0;
|
||||
const check = (label, cond, extra = '') => {
|
||||
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}${cond ? '' : ' — ' + extra}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
const SEED = 'frames-test-seed';
|
||||
const g = Galaxy.create(SEED);
|
||||
const POOL = Math.max(1, Math.floor(g.params.neighbors ?? 8));
|
||||
const poolFor = (cls) => {
|
||||
const p = config.get(`planets.frames.${cls}`);
|
||||
return Array.isArray(p) && p.length > 0 ? p : [0];
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 1. Frames are present and in-pool
|
||||
// ----------------------------------------------------------------------
|
||||
{
|
||||
let inPool = true;
|
||||
let why = '';
|
||||
for (const rec of g.records) {
|
||||
const c = g.ensureContent(rec.id);
|
||||
for (const p of c.planets) {
|
||||
if (typeof p.frame !== 'number' || !Number.isInteger(p.frame) || !poolFor(p.class).includes(p.frame)) {
|
||||
inPool = false;
|
||||
if (!why) why = `${rec.id}: ${p.class} planet frame ${p.frame} ∉ ${poolFor(p.class)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
const home = g.ensureContent(g.currentSystemId);
|
||||
if (typeof home.homeFrame !== 'number' || !poolFor('terran').includes(home.homeFrame)) {
|
||||
inPool = false;
|
||||
if (!why) why = `home world frame ${home.homeFrame} ∉ terran pool`;
|
||||
}
|
||||
check('every planet frame is an integer in its class pool (home world: terran pool)', inPool, why);
|
||||
|
||||
// Intra-system diversity: same-class worlds wear different faces UNLESS
|
||||
// the system holds more of that class than the class pool has faces
|
||||
// (pigeonhole — then every face is used, and the lexicographic pass
|
||||
// guarantees exactly pool-size distinct faces).
|
||||
let distinct = true;
|
||||
let whyD = '';
|
||||
for (const rec of g.records) {
|
||||
const c = g.ensureContent(rec.id);
|
||||
const byClass = new Map();
|
||||
for (const p of c.planets) {
|
||||
if (!byClass.has(p.class)) byClass.set(p.class, []);
|
||||
byClass.get(p.class).push(p.frame);
|
||||
}
|
||||
for (const [cls, fs2] of byClass) {
|
||||
const m = fs2.length;
|
||||
const poolN = poolFor(cls).length;
|
||||
const used = new Set(fs2).size;
|
||||
if (used !== Math.min(m, poolN)) {
|
||||
distinct = false;
|
||||
if (!whyD) whyD = `${rec.id}: ${m} ${cls} worlds use ${used} faces (want ${Math.min(m, poolN)})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
check('same-class worlds in one system wear distinct faces (pigeonhole-permitting)', distinct, whyD);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 2. The pass beats a naive random assignment
|
||||
// ----------------------------------------------------------------------
|
||||
{
|
||||
// The pass's (class, frame) collision rate: for each planet, count its
|
||||
// 8-nearest stars' planets wearing the SAME class AND frame (directed).
|
||||
const collisionRate = (frameOf) => {
|
||||
let coll = 0;
|
||||
let checks = 0;
|
||||
for (const rec of g.records) {
|
||||
const c = g.ensureContent(rec.id);
|
||||
c.planets.forEach((p, i) => {
|
||||
for (const nb of g.neighborsOf(rec.id, POOL)) {
|
||||
checks++;
|
||||
const nbC = g.ensureContent(nb.id);
|
||||
if (nbC.planets.some((q, j) => q.class === p.class && frameOf(nb.id, q, j) === p.frame)) coll++;
|
||||
}
|
||||
});
|
||||
}
|
||||
return { coll, checks, rate: coll / checks };
|
||||
};
|
||||
|
||||
const passed = collisionRate((id, p, i) => g.planetFrames.get(id)[i]);
|
||||
|
||||
// Naive: independent random pool picks (seeded, per planet).
|
||||
const naive = new Map();
|
||||
for (const rec of g.records) {
|
||||
const c = g.ensureContent(rec.id);
|
||||
naive.set(rec.id, c.planets.map((p, i) => {
|
||||
const pool = poolFor(p.class);
|
||||
return pool.length === 1 ? pool[0] : pool[Math.floor(Rng.derive(SEED, 'naive', rec.id, String(i), p.class).next() * pool.length)];
|
||||
}));
|
||||
}
|
||||
const na = collisionRate((id, p, i) => naive.get(id)[i]);
|
||||
|
||||
check(
|
||||
`(class, frame) collisions vs the ${POOL}-nearest pool: pass ${(passed.rate * 100).toFixed(1)}% < naive ${(na.rate * 100).toFixed(1)}%`,
|
||||
passed.rate < na.rate,
|
||||
`${passed.coll}/${passed.checks} vs ${na.coll}/${na.checks} (density sets the floor — same-class neighbors dominate)`,
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 3. Determinism + lazy === eager
|
||||
// ----------------------------------------------------------------------
|
||||
{
|
||||
const g2 = Galaxy.create(SEED);
|
||||
let same = true;
|
||||
for (const rec of g.records) {
|
||||
if (JSON.stringify(g.planetFrames.get(rec.id)) !== JSON.stringify(g2.planetFrames.get(rec.id))) {
|
||||
same = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
check('same seed ⇒ same (class, frame) assignment galaxy-wide', same);
|
||||
check('same seed ⇒ same home-world frame', g.homeWorldFrame === g2.homeWorldFrame);
|
||||
|
||||
// Content frames === galaxy frames (the stamping contract).
|
||||
let stamped = true;
|
||||
for (const rec of g.records) {
|
||||
const c = g.ensureContent(rec.id);
|
||||
if (JSON.stringify(c.planets.map((p) => p.frame)) !== JSON.stringify(g.planetFrames.get(rec.id))) {
|
||||
stamped = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
check('content planet frames === the galaxy pass (lazy === eager stamping)', stamped);
|
||||
|
||||
const g3 = Galaxy.create('frames-test-OTHER');
|
||||
let diff = false;
|
||||
for (const rec of g.records) {
|
||||
if (JSON.stringify(g.planetFrames.get(rec.id)) !== JSON.stringify(g3.planetFrames.get(rec.id))) {
|
||||
diff = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
check('different seed ⇒ different assignment (spot check)', diff || g3.records[0].id !== g.records[0].id);
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? '\nAll frame-diversity tests passed ✔' : `\n${failures} test(s) FAILED ✘`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
|
|
@ -11,10 +11,13 @@
|
|||
* - type distribution matches the weights in data/systems.json;
|
||||
* - type radius bands (the first proximity rule) are respected;
|
||||
* - 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;
|
||||
* - 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 default 40,000-system galaxy.
|
||||
* Also reports generation timing for the 200-system galaxy.
|
||||
*/
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
|
@ -146,33 +149,36 @@ let big;
|
|||
}
|
||||
check('radius bands (proximity rule) respected by every system', bandOk);
|
||||
|
||||
// Lazy contents: the global planet-count rule across the WHOLE galaxy.
|
||||
// Lazy contents: the global OBJECT-COMPOSITION rule across the WHOLE
|
||||
// galaxy (data/systems.json → objectCount: 0/2/3/4/5 objects, ≈ 10%
|
||||
// barren — jump-gate-only stops).
|
||||
const t1 = performance.now();
|
||||
const pc = config.get('systems.planetCount', { noneChance: 0, min: 0, max: 99 });
|
||||
const OC = config.get('systems.objectCount', { barren: 0.1, objects: { 2: 0.15, 3: 0.3, 4: 0.3, 5: 0.15 } });
|
||||
let boundsOk = true;
|
||||
let emptyCount = 0;
|
||||
for (const r of big.records) {
|
||||
const content = big.ensureContent(r.id);
|
||||
const n = content.planets.length;
|
||||
const n = content.planets.length + (content.settlements ?? []).filter((s) => s.anchor?.type === 'space').length;
|
||||
if (n === 0) emptyCount++;
|
||||
if (!(n === 0 || (n >= pc.min && n <= pc.max))) {
|
||||
if (r.id !== big.currentSystemId && !(n === 0 || (n >= 2 && n <= 5))) {
|
||||
boundsOk = false;
|
||||
break;
|
||||
}
|
||||
if (!content.star || typeof content.star.class !== 'string') boundsOk = false;
|
||||
}
|
||||
const tGen = performance.now() - t1;
|
||||
check(`every system has 0 or [${pc.min}, ${pc.max}] planets (the global planetCount rule)`, boundsOk);
|
||||
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;
|
||||
check(
|
||||
`≈ ${Math.round((pc.noneChance ?? 0) * 100)}% of systems are barren (observed ${(emptyShare * 100).toFixed(1)}%)`,
|
||||
Math.abs(emptyShare - (pc.noneChance ?? 0.2)) < 0.01,
|
||||
`≈ ${Math.round(barrenExpect * 100)}% of systems are barren — jump-gate-only (observed ${(emptyShare * 100).toFixed(1)}%)`,
|
||||
Math.abs(emptyShare - barrenExpect) < 0.08,
|
||||
);
|
||||
check('lazy content generation over all 40k systems', big.generatedCount === nSys);
|
||||
check(`lazy content generation over all ${nSys} systems`, big.generatedCount === nSys);
|
||||
|
||||
// Lazy === eager: fresh galaxy (unopened) vs fully generated one.
|
||||
const fresh = Galaxy.create(SEED);
|
||||
const sample = [big.records[0].id, big.records[999].id, big.records[nSys - 1].id];
|
||||
const sample = [big.records[0].id, big.records[Math.floor(nSys / 2)].id, big.records[nSys - 1].id];
|
||||
const lazyEager = sample.every((id) => deepEq(fresh.ensureContent(id), big.ensureContent(id)));
|
||||
check('lazy (on-arrival) content === content already generated', lazyEager);
|
||||
const eager = Galaxy.create(SEED).generateAll();
|
||||
|
|
@ -308,25 +314,43 @@ let big;
|
|||
}
|
||||
check('every planet is settled with a class-fitting kind (colonies only on habitable rocky worlds)', allSettledOk);
|
||||
|
||||
// JUMP-GATE ANCHOR GUARANTEE: every system holds at least one planet or
|
||||
// free-space station — the jump gates sit within level-1 tether of an
|
||||
// anchor, so a system with neither would be closed (unreachable).
|
||||
let anchored = 0;
|
||||
// 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).
|
||||
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')) anchored++;
|
||||
if (c.planets.length === 0 && !c.settlements.some((s) => s.anchor?.type === 'space')) barren++;
|
||||
}
|
||||
check(`every system holds a gate anchor — a planet or space station (${anchored}/${sample.length} in sample)`, anchored === sample.length);
|
||||
check('no system is left unclaimed (the anchor guarantee settled the barren ones)', sample.every((r) => big2.ensureContent(r.id).settlements.length > 0));
|
||||
const barrenShare = barren / sample.length;
|
||||
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,
|
||||
);
|
||||
check(
|
||||
'every non-barren system is settled (the home system is exempt)',
|
||||
sample.every((r) => {
|
||||
const c = big2.ensureContent(r.id);
|
||||
const barren2 = c.planets.length === 0 && !c.settlements.some((s) => s.anchor?.type === 'space');
|
||||
return barren2 || r.id === big2.currentSystemId || c.settlements.length > 0;
|
||||
}),
|
||||
);
|
||||
|
||||
// Core→rim gradient: the settled heart is denser than the wilder rim.
|
||||
const withCount = sample.map((r) => ({ rNorm: r.rNorm, n: big2.ensureContent(r.id).settlements.length }));
|
||||
// Core→rim gradient: the free-space STATION odds scale with the core→rim
|
||||
// density (data/galaxy.json → settlements.gradient) — the planets are
|
||||
// settled regardless, so the gradient lives in how many of the objects
|
||||
// are stations: the settled heart is denser in stations than the rim.
|
||||
const withCount = sample.map((r) => ({
|
||||
rNorm: r.rNorm,
|
||||
n: (big2.ensureContent(r.id).settlements ?? []).filter((s) => s.anchor?.type === 'space').length,
|
||||
}));
|
||||
withCount.sort((a, b) => a.rNorm - b.rNorm);
|
||||
const third = Math.floor(withCount.length / 3);
|
||||
const inner = withCount.slice(0, third);
|
||||
const outer = withCount.slice(-third);
|
||||
const avg = (arr) => arr.reduce((s, x) => s + x.n, 0) / arr.length;
|
||||
check(`core→rim settlement gradient (inner ${avg(inner).toFixed(2)}/system > outer ${avg(outer).toFixed(2)}/system)`, avg(inner) > avg(outer));
|
||||
check(`core→rim station gradient (inner ${avg(inner).toFixed(2)}/system > outer ${avg(outer).toFixed(2)}/system)`, avg(inner) > avg(outer));
|
||||
|
||||
// Station + planet naming now draws from the curated BANKS (no repeats
|
||||
// within a system until the pool is exhausted). The deck is a seeded
|
||||
|
|
@ -349,8 +373,9 @@ let big;
|
|||
check('report population sums match', report.population === report.settlements.reduce((s, x) => s + x.population, 0));
|
||||
check('formatPop() scales (1.2k / 9.0M)', formatPop(1234) === '1.2k' && formatPop(9000000) === '9.0M' && formatPop(12) === '12');
|
||||
|
||||
// The anchor guarantee settled every system, so the "unclaimed" report
|
||||
// branch is a defensive fallback (content with zero settlements).
|
||||
// The BARREN systems (objectCount → 0) are genuinely unclaimed — that
|
||||
// is the "unclaimed · N jump gates" report branch in the wild now (not
|
||||
// just a defensive fallback).
|
||||
|
||||
// No-duplicate guarantee: within a system, planet names, station names
|
||||
// and jump-gate names never repeat (drawn without replacement from the
|
||||
|
|
|
|||
|
|
@ -10,11 +10,15 @@
|
|||
* - discovery identity: discoveryId/discoveryName from the record,
|
||||
* size from data/gates.json, bound = size (compass/toast scale),
|
||||
* clearance = gates.shipClearance, rotation = the record's bearing;
|
||||
* - ACTIVITY (data/gates.json → ACTIVITY): gate.active mirrors the
|
||||
* record (false by default — the gate is dormant: dimmed, field
|
||||
* still), an active:true gate breathes its field;
|
||||
* - the SOLID contract (same as Planet/Station): minCenterDistance,
|
||||
* edgePoint exactly `gap` past the surface, aimPoint clamping, and
|
||||
* constrainShip pushing the ship out of the keepout circle
|
||||
* (through the shared Planet.resolve);
|
||||
* - update() breathes the field without throwing;
|
||||
* - update() breathes the field (active) or keeps it still (dormant)
|
||||
* without throwing;
|
||||
* - destroy() tears the children down.
|
||||
*/
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
|
|
@ -100,6 +104,24 @@ check('position comes from the record', gate.x === gateRec.x && gate.y === gateR
|
|||
check('size comes from data/gates.json', gate.size === config.get('gates.size', 96) && gate.bound === gate.size);
|
||||
check('clearance comes from gates.shipClearance', gate.clearance === config.get('gates.shipClearance', 50));
|
||||
check('rotation = the record’s bearing (facing the destination star)', gate.rotation === gateRec.rotation);
|
||||
check('active mirrors the record (false by default — dormant)', gateRec.active === false && gate.active === false);
|
||||
check('a dormant gate renders dim (alpha 0.4)', Math.abs(gate.alpha - 0.4) < 1e-9);
|
||||
|
||||
// --- ACTIVITY: a dormant gate's field stays still; an active one breathes
|
||||
{
|
||||
const before = alphas.map((c) => c.alpha);
|
||||
gate.update(1000);
|
||||
gate.update(2000);
|
||||
check('dormant gate: update() leaves the field still (no breathing)', alphas.every((c, i) => c.alpha === before[i]));
|
||||
const alphasB = alphas.length;
|
||||
const gateB = new JumpGate(scene, { ...gateRec, active: true }, { depth: 5 });
|
||||
const alphas2 = alphas.slice(alphasB);
|
||||
gateB.update(0);
|
||||
const a0 = alphas2.map((c) => c.alpha);
|
||||
gateB.update(873); // t ≈ π/2 of the pulse → maximum breathing
|
||||
check('active gate: the field breathes (alpha changes over time)', alphas2.some((c, i) => Math.abs(c.alpha - a0[i]) > 1e-6));
|
||||
check('an active gate is not dimmed', gateB.alpha === 1);
|
||||
}
|
||||
|
||||
// --- The solid contract (same rules as Planet / Station) ------------------
|
||||
const shipRadius = (config.get('ship.size', 46) * config.get('ship.scale', 1)) / 2;
|
||||
|
|
@ -134,8 +156,8 @@ check('minCenterDistance = radius + clearance + shipRadius', Math.abs(minDist -
|
|||
}
|
||||
|
||||
// --- Animation + teardown --------------------------------------------------
|
||||
gate.update(1000); // must not throw; the field breathes
|
||||
check('update() breathes the field discs (alpha set)', alphas.length === 2 && alphas.every((c) => typeof c.alpha === 'number' && c.alpha > 0 && c.alpha < 0.5));
|
||||
gate.update(1000); // must not throw; a dormant gate keeps the field still
|
||||
check('update() runs without throwing (dormant gate)', alphas.slice(0, 2).every((c) => typeof c.alpha === 'number' && c.alpha > 0 && c.alpha < 0.5));
|
||||
const childCount = gate.children.length;
|
||||
gate.destroy();
|
||||
check('destroy() clears the children', gate.children.length === 0 && childCount > 0);
|
||||
|
|
|
|||
|
|
@ -14,16 +14,22 @@
|
|||
*
|
||||
* the IN-SYSTEM PLACEMENT (js/galaxy/SystemGenerator.js → layoutGates):
|
||||
* - content.jumps matches the network (count + destinations);
|
||||
* - every gate is within level-1 tether (tether.level1Radius) of an
|
||||
* ANCHOR — a planet, a free-space station, or the home world in the
|
||||
* starting system;
|
||||
* - every gate FACES its destination star on the 2-D map: the bearing
|
||||
* from the anchor to the gate is within 90° of the system→star
|
||||
* bearing (soft rule — same side, never opposite);
|
||||
* - 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;
|
||||
* - 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;
|
||||
*
|
||||
* and DETERMINISM: same seed ⇒ same network, same gates, same layout;
|
||||
* different seed ⇒ different network (spot check).
|
||||
|
|
@ -65,6 +71,7 @@ const CLEAR = GATES.clearance ?? 256;
|
|||
const GAP = GATES.gateGap ?? 192;
|
||||
const MIN_R = GATES.minRadius ?? 2048;
|
||||
const MAX_R = GATES.maxRadius ?? 20480;
|
||||
const BARN_D = GATES.barrenDistance ?? 8192;
|
||||
|
||||
const SEED = 'jumps-test-seed';
|
||||
const g = Galaxy.create(SEED);
|
||||
|
|
@ -137,12 +144,14 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
|
|||
// ----------------------------------------------------------------------
|
||||
{
|
||||
const sample = [recOf.get(HOME), ...g.records.slice(0, 3000).filter((r) => r.id !== HOME)];
|
||||
let tether = 0, facing = 0, radius = 0, clearance = 0, gap = 0, unique = 0;
|
||||
let tWhy = '', fWhy = '', rWhy = '', cWhy = '', gWhy = '', mWhy = '';
|
||||
let tether = 0, facing = 0, radius = 0, clearance = 0, gap = 0, unique = 0, barrenBad = 0, active = 0;
|
||||
let tWhy = '', fWhy = '', rWhy = '', cWhy = '', gWhy = '', mWhy = '', bWhy = '';
|
||||
let netMismatch = false;
|
||||
for (const sys of sample) {
|
||||
const c = g.ensureContent(sys.id);
|
||||
const isHome = sys.id === HOME;
|
||||
const spaceCount = (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length;
|
||||
const isBarren = c.planets.length === 0 && spaceCount === 0;
|
||||
// content.jumps matches the network (count + destinations, in order).
|
||||
const net = g.jumpGatesFor(sys.id);
|
||||
if (
|
||||
|
|
@ -152,10 +161,15 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
|
|||
netMismatch = true;
|
||||
if (!mWhy) mWhy = `${sys.id}: content.jumps ≠ jumpGatesFor`;
|
||||
}
|
||||
// ACTIVITY (data/gates.json → ACTIVITY): every gate starts inert.
|
||||
if (c.jumps.some((j) => j.active !== false)) {
|
||||
active++;
|
||||
if (!bWhy) bWhy = `${sys.id}: a gate is not active:false`;
|
||||
}
|
||||
// Anchors: planets, free-space stations, and (home) the home 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 })),
|
||||
...((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' });
|
||||
|
||||
|
|
@ -167,28 +181,39 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
|
|||
return;
|
||||
}
|
||||
const th = Math.atan2(t.y - sys.y, t.x - sys.x);
|
||||
// TETHER (hard): within level-1 range of some anchor.
|
||||
let bestD = Infinity;
|
||||
for (const a of anchors) bestD = Math.min(bestD, Math.hypot(j.x - a.x, j.y - a.y));
|
||||
if (bestD > TETHER + 1e-6) {
|
||||
tether++;
|
||||
if (!tWhy) tWhy = `${sys.id}: gate ${j.id} is ${Math.round(bestD)} px from its nearest anchor (> ${TETHER})`;
|
||||
}
|
||||
// FACING (soft): from the anchoring object, the gate is on the
|
||||
// target side — within 90° of the system→star bearing (some anchor
|
||||
// must satisfy BOTH the tether and the facing).
|
||||
let onSide = false;
|
||||
for (const a of anchors) {
|
||||
const d = Math.hypot(j.x - a.x, j.y - a.y);
|
||||
if (d > TETHER + 1e-6) continue;
|
||||
if (Math.abs(norm(Math.atan2(j.y - a.y, j.x - a.x) - th)) < Math.PI / 2) onSide = true;
|
||||
}
|
||||
if (!onSide) {
|
||||
facing++;
|
||||
if (!fWhy) fWhy = `${sys.id}: gate ${j.id} is on the wrong side of every tethering anchor`;
|
||||
const d0 = Math.hypot(j.x, j.y);
|
||||
if (isBarren) {
|
||||
// BARREN: on the ray toward the destination, ≥ barrenDistance from
|
||||
// the star (stepped outward within the band if the gap forced it),
|
||||
// facing it (the bearing nudge, if any, stays within 90°).
|
||||
const dev = Math.abs(norm(Math.atan2(j.y, j.x) - th));
|
||||
if (d0 < BARN_D - 1e-6 || d0 > MAX_R + 1e-6 || dev >= Math.PI / 2) {
|
||||
barrenBad++;
|
||||
if (!bWhy) bWhy = `${sys.id}: barren gate ${j.id} at ${Math.round(d0)} px, ${(dev * 57.3).toFixed(1)}° off the target ray`;
|
||||
}
|
||||
} else {
|
||||
// TETHER (hard): within level-1 range of some anchor.
|
||||
let bestD = Infinity;
|
||||
for (const a of anchors) bestD = Math.min(bestD, Math.hypot(j.x - a.x, j.y - a.y));
|
||||
if (bestD > TETHER + 1e-6) {
|
||||
tether++;
|
||||
if (!tWhy) tWhy = `${sys.id}: gate ${j.id} is ${Math.round(bestD)} px from its nearest anchor (> ${TETHER})`;
|
||||
}
|
||||
// FACING (soft): from the anchoring object, the gate is on the
|
||||
// target side — within 90° of the system→star bearing (some anchor
|
||||
// must satisfy BOTH the tether and the facing).
|
||||
let onSide = false;
|
||||
for (const a of anchors) {
|
||||
const d = Math.hypot(j.x - a.x, j.y - a.y);
|
||||
if (d > TETHER + 1e-6) continue;
|
||||
if (Math.abs(norm(Math.atan2(j.y - a.y, j.x - a.x) - th)) < Math.PI / 2) onSide = true;
|
||||
}
|
||||
if (!onSide) {
|
||||
facing++;
|
||||
if (!fWhy) fWhy = `${sys.id}: gate ${j.id} is on the wrong side of every tethering anchor`;
|
||||
}
|
||||
}
|
||||
// RADIUS band from the star.
|
||||
const d0 = Math.hypot(j.x, j.y);
|
||||
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})`;
|
||||
|
|
@ -222,21 +247,48 @@ 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 gate ≤ ${TETHER} px from a planet/station anchor`, tether === 0, tWhy);
|
||||
check('facing (soft): every gate is on the target side of its anchor (< 90°)', facing === 0, fWhy);
|
||||
check(`tether (hard): every anchored-system gate ≤ ${TETHER} px from a planet/station 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(`gate gap: gates of a system are ≥ ${2 * SIZE + GAP} px apart`, gap === 0, gWhy);
|
||||
check('gate ids are <systemId>-j<n> and names are unique per system', unique === 0);
|
||||
check('every gate record is active:false (inert until activation)', active === 0, bWhy);
|
||||
|
||||
// The anchor guarantee: every system holds a planet or space station
|
||||
// (a gate must be tether-reachable from one).
|
||||
let noAnchor = 0;
|
||||
// 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).
|
||||
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;
|
||||
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);
|
||||
if (c.planets.length === 0 && !(c.settlements ?? []).some((s) => s.anchor?.type === 'space')) noAnchor++;
|
||||
const n = c.planets.length + (c.settlements ?? []).filter((s) => s.anchor?.type === 'space').length;
|
||||
counts[n] = (counts[n] ?? 0) + 1;
|
||||
if (!(n in expected)) {
|
||||
shapeBad++;
|
||||
if (!whyShape) whyShape = `${r.id}: ${n} objects`;
|
||||
}
|
||||
if (n === 0 && (c.asteroids ?? []).length > 0) barrenClusters++;
|
||||
}
|
||||
check(`anchor guarantee: every one of the ${g.records.length} systems holds a planet or space station`, noAnchor === 0, `${noAnchor} without`);
|
||||
const nN = g.records.length - 1;
|
||||
let distOk = true;
|
||||
for (const [n, p] of Object.entries(expected)) {
|
||||
const obs = (counts[Number(n)] ?? 0) / nN;
|
||||
const sd = Math.sqrt(p * (1 - p) / nN);
|
||||
if (Math.abs(obs - p) > 4 * sd + 0.004) {
|
||||
distOk = false;
|
||||
console.log(` ${n} objects: observed ${(obs * 100).toFixed(1)}% vs expected ${(p * 100).toFixed(1)}%`);
|
||||
}
|
||||
}
|
||||
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`);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
@ -254,7 +306,8 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P
|
|||
const b = g2.ensureContent(g2.records[i].id);
|
||||
if (JSON.stringify(a.jumps) !== JSON.stringify(b.jumps)) placement = false;
|
||||
if (
|
||||
a.planets.some((p, k) => p.x !== b.planets[k].x || p.y !== b.planets[k].y) ||
|
||||
a.planets.length !== b.planets.length ||
|
||||
a.planets.some((p, k) => p.x !== b.planets[k].x || p.y !== b.planets[k].y || p.frame !== b.planets[k].frame) ||
|
||||
JSON.stringify((a.settlements ?? []).map((s) => [s.x, s.y])) !== JSON.stringify((b.settlements ?? []).map((s) => [s.x, s.y]))
|
||||
)
|
||||
placement = false;
|
||||
|
|
|
|||
|
|
@ -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. 40,000 systems is ~70 ms, so the whole
|
||||
(id, name, type, x, y) up front. 200 systems is ~15 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
|
||||
|
|
@ -107,13 +107,26 @@ menu (displayed, editable, rerollable; same seed ⇒ same galaxy).
|
|||
classes, binary chance, planet class weights, moon/belt chances,
|
||||
habitability, hazard, free-space settlement odds). New attribute key =
|
||||
JSON + a few lines in `SystemGenerator.js`;
|
||||
- **planet count is global, not per-type** — `data/systems.json →
|
||||
planetCount`: noneChance (20%) of all systems are barren (no planets
|
||||
at all), the rest hold a uniform whole number in [min, max] (2–4).
|
||||
Exception: the **starting system** 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.
|
||||
- **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
|
||||
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.
|
||||
- **planet frames are spread galaxy-wide** — `data/planets.json → frames`
|
||||
maps each class to its spritesheet face pool. A random per-system pick
|
||||
would let neighboring stars wear the same face, so `Galaxy._generate()`
|
||||
runs one galaxy-wide pass (`js/galaxy/PlanetFrames.js →
|
||||
assignPlanetFrames`): systems in a FIXED spatial order (x, y — a pure
|
||||
function of the seeded roster, so it never depends on visit order)
|
||||
each pick the least-used (class, frame) among their already-assigned
|
||||
nearest stars, tie-broken by a derived seeded Rng. The stamps land on
|
||||
each planet record (`planet.frame`) and the home world's face on
|
||||
`content.homeFrame`, read by `GameScene`. Verified: `dev/frames.test.mjs`.
|
||||
- **distributed** — `distribution.weight` (how common) and
|
||||
`distribution.radiusBand` (first proximity rule: e.g. `void` systems
|
||||
live in the outer rim). Richer galaxy-level rules (clustering,
|
||||
|
|
@ -135,11 +148,11 @@ rocky world, `miningStation` over every other rocky/ice/lava world,
|
|||
`cloudBase` riding every gas giant. Plus the free-space kinds:
|
||||
`deepSpaceStation` (adrift in open space) and `waypoint` (a small beacon
|
||||
— the faint trace of a crossed galaxy), still rolled per archetype and
|
||||
thinned core→rim. The **anchor guarantee** (jump gates must be
|
||||
reachable on a level-1 tether) settles the rest: a system that rolls no
|
||||
planet and no free-space station still gets a `deepSpaceStation`, so no
|
||||
system is left unclaimed (the "charted · unclaimed" report branch is a
|
||||
defensive fallback). Model & seams:
|
||||
thinned core→rim. **Barren systems** (the `objectCount` → 0 stops) are
|
||||
the one exception to "lived in": they hold no planets and no stations —
|
||||
just the jump gate — and are reachable only by jumping in (strong
|
||||
connectivity keeps them on the network). Their "charted · unclaimed" report
|
||||
branch is now the normal case, not a defensive fallback. Model & seams:
|
||||
- **Kinds vocabulary** — `data/settlements.json` (label, description,
|
||||
theme color, population range, anchor type). Add a kind = JSON + naming
|
||||
pool; the generator picks it up by name.
|
||||
|
|
@ -262,7 +275,13 @@ exit toward a NEARBY star on the 2-D map. Two layers:
|
|||
px) from the star, keep `size` + `clearance` from anchor discs and
|
||||
2·`size` + `gateGap` from each other, and get unique names ("Avidy
|
||||
Gate", "Avidy Gate II" — star names are syllable-generated and
|
||||
collide). A gate's `rotation` is the bearing from the gate to its
|
||||
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
|
||||
are DORMANT until activated (the entity renders dim, field still);
|
||||
activation is the seam for the tether mechanic: an activated gate
|
||||
anchors a level-1 tether so the player can leave. A gate's `rotation`
|
||||
is the bearing from the gate to its
|
||||
destination star — the art (twin-pylon portal, `js/entities/JumpGate.js`
|
||||
— procedural, Station.js-style) faces where it jumps.
|
||||
- **In the scene** — GameScene builds the gates as solid world objects
|
||||
|
|
@ -273,10 +292,11 @@ collide). A gate's `rotation` is the bearing from the gate to its
|
|||
— standing at a gate is where the jump happens; the jump mechanic
|
||||
itself is the follow-up.
|
||||
- **Determinism** — same seed ⇒ same network, same gates, same
|
||||
placements. Verified: `dev/jumps.test.mjs` (network invariants,
|
||||
placement invariants, determinism), `dev/jumpgate.test.mjs` (the
|
||||
entity's solid contract), and the layout band in
|
||||
`dev/discovery.test.mjs`.
|
||||
placements, same `active` flags. Verified: `dev/jumps.test.mjs`
|
||||
(network invariants, placement invariants, barren gates on-ray,
|
||||
`active: false` everywhere, composition buckets, determinism),
|
||||
`dev/jumpgate.test.mjs` (the entity's solid contract + dormant/active
|
||||
rendering), and the layout band in `dev/discovery.test.mjs`.
|
||||
|
||||
## The tether — the player's range (important)
|
||||
|
||||
|
|
@ -680,7 +700,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 (40k systems) + lazy,
|
||||
- [x] Two-level worldgen: seeded galaxy roster (200 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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ import { Planet } from './Planet.js';
|
|||
* standing at a gate is where the jump happens; that arrives with the
|
||||
* jump mechanic.
|
||||
*
|
||||
* ACTIVITY (data/gates.json → ACTIVITY): `gate.active` defaults to false
|
||||
* — the gate is inert until the player activates it (future mechanic),
|
||||
* and an activated gate anchors a level-1 tether at its own position —
|
||||
* the room to move in a barren system. Until then the gate reads as
|
||||
* DORMANT: dimmed overall (alpha 0.4), the field still and faint (the
|
||||
* pulse is the "this one is live" tell).
|
||||
*
|
||||
* The container itself carries `rotation` (the art is drawn facing
|
||||
* +x), so the whole gate — pylons, mouth, chevrons — faces its
|
||||
* destination. The collision API is circular and rotation-blind.
|
||||
|
|
@ -35,6 +42,8 @@ export class JumpGate extends Phaser.GameObjects.Container {
|
|||
super(scene, gate.x, gate.y);
|
||||
this.scene.add.existing(this); // v4: new'd containers are not on the display list
|
||||
this.gate = gate;
|
||||
// Inert until activated (data/gates.json → ACTIVITY) — see the header.
|
||||
this.active = gate?.active === true;
|
||||
this.discoveryId = gate.id;
|
||||
this.discoveryName = gate.name;
|
||||
|
||||
|
|
@ -135,12 +144,17 @@ export class JumpGate extends Phaser.GameObjects.Container {
|
|||
chev(S * 0.24, S * 0.2, S * 0.05, 0.95);
|
||||
chev(S * 0.48, S * 0.2, S * 0.04, 0.6);
|
||||
this.add(top);
|
||||
|
||||
// DORMANT look — inactive gates are dim (the pulse below is the live tell).
|
||||
if (!this.active) this.setAlpha(0.4);
|
||||
}
|
||||
|
||||
/** The ring drifts; the field breathes. (GameScene.update drives this.) */
|
||||
update(time) {
|
||||
const t = time / 1000;
|
||||
if (this.ringBody) this.ringBody.rotation = t * 0.12;
|
||||
if (this.ringBody) this.ringBody.rotation = t * (this.active ? 0.12 : 0.06);
|
||||
// A dormant gate keeps a still, faint field — no breathing.
|
||||
if (!this.active) return;
|
||||
const pulse = 0.5 + 0.5 * Math.sin(t * 1.8);
|
||||
if (this.field) this.field.setAlpha(0.1 + 0.14 * pulse);
|
||||
if (this.fieldCore) this.fieldCore.setAlpha(0.14 + 0.2 * pulse);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { config } from '../config/Config.js';
|
|||
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';
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
|
@ -19,8 +20,8 @@ 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: ~40,000 systems is a fraction of a second and a few
|
||||
* MB. It fixes the shape of the galaxy, where every system sits, and
|
||||
* This is cheap: 200 systems 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.
|
||||
*
|
||||
* 2. SYSTEM CONTENTS — planets, moons, belts, hazards… generated LAZILY,
|
||||
|
|
@ -50,6 +51,8 @@ export class Galaxy {
|
|||
this.byId = new Map();
|
||||
this.contentCache = new Map();
|
||||
this.currentSystemId = null;
|
||||
this.planetFrames = new Map(); // id → [frame, ...] — the frame-diversity pass
|
||||
this.homeWorldFrame = null; // the starting system's home world frame
|
||||
this.name = NameGenerator.galaxy(Rng.derive(seed, 'galaxy', 'name'));
|
||||
}
|
||||
|
||||
|
|
@ -67,7 +70,7 @@ export class Galaxy {
|
|||
if (Object.keys(typeDefs).length === 0) {
|
||||
throw new Error('No system types found — is data/systems.json listed in data/manifest.json?');
|
||||
}
|
||||
const count = Math.floor(params.systemCount ?? 40000);
|
||||
const count = Math.floor(params.systemCount ?? 200);
|
||||
if (!(count >= 1)) {
|
||||
throw new Error(`galaxy.systemCount must be a whole number >= 1 (got ${params.systemCount})`);
|
||||
}
|
||||
|
|
@ -184,6 +187,24 @@ export class Galaxy {
|
|||
if (this.jumpNetworkRepaired > 0) {
|
||||
console.warn(`[orbit] jump network: ${this.jumpNetworkRepaired} system(s) needed a repair attach`);
|
||||
}
|
||||
|
||||
// FRAME DIVERSITY (js/galaxy/PlanetFrames.js): the galaxy-wide
|
||||
// (class, frame) assignment — each planet's sheet frame avoids what
|
||||
// the NEAREST stars already wear for that class, so the same face is
|
||||
// spread across the galaxy instead of clustering in one region. Fixed
|
||||
// roster order ⇒ visit-order independent; read back by the content
|
||||
// generator (planet.frame / content.homeFrame) — the lazy === eager
|
||||
// contract is preserved.
|
||||
const pool8 = Math.max(1, Math.floor(this.params.neighbors ?? 8));
|
||||
const { frames, homeFrame } = assignPlanetFrames({
|
||||
seed: this.seed,
|
||||
records,
|
||||
homeId: this.currentSystemId,
|
||||
params: this.params,
|
||||
neighborsOf: (id) => this.neighborsOf(id, pool8),
|
||||
});
|
||||
this.planetFrames = frames; // Map id → [frame, ...] (ordinal order)
|
||||
this.homeWorldFrame = homeFrame;
|
||||
}
|
||||
|
||||
/** Center-weighted radius sample in [0,1]: core bulge + disk. */
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
* connectivity survives.
|
||||
*
|
||||
* The repair pass (below) is defensive: it fires only if the neighbor
|
||||
* graph is disconnected (effectively impossible at 40k points), and it
|
||||
* graph is disconnected (effectively impossible at this scale), and it
|
||||
* still respects the degree budget.
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import { config } from '../config/Config.js';
|
||||
import { Rng } from '../utils/Rng.js';
|
||||
import { rollSystemComposition, settlementDensity } from './SystemGenerator.js';
|
||||
|
||||
/**
|
||||
* FRAME DIVERSITY — the galaxy-wide (class, frame) assignment.
|
||||
*
|
||||
* data/planets.json → frames maps each planet class to a pool of
|
||||
* spritesheet frames (terran/ice/lava share 0–2, gas 3–5, rocky 6–8 —
|
||||
* the same face can be worn by different classes; that's fine). Picking
|
||||
* a random frame per system at render time would let neighboring stars
|
||||
* wear the same face; this pass spreads each (class, frame) across the
|
||||
* galaxy instead: when a system's class-C planet needs a frame, it
|
||||
* AVOIDS frames class C already wears among the NEAREST stars (the
|
||||
* galaxy's neighbor pool, data/galaxy.json → neighbors), so the same
|
||||
* face reappears only far away.
|
||||
*
|
||||
* Why the pass is order-independent (determinism): systems are assigned
|
||||
* in a FIXED order — sorted by (x, y), a pure function of the seeded
|
||||
* roster — and each system only counts frames ALREADY assigned to its
|
||||
* neighbors (plus its own earlier planets). Nothing depends on generation
|
||||
* timing or visit order, so lazy (on-arrival) content generation and
|
||||
* eager generateAll stamp the same frames. The spatial order also makes
|
||||
* the "already assigned" set spatially consistent, which spreads the
|
||||
* faces a little better than roster order. Ties are broken by a
|
||||
* derived, per-pick Rng (seeded).
|
||||
*
|
||||
* Shared rolls: the pass re-derives each system's planet classes via the
|
||||
* same exported roll and forks as the content generator
|
||||
* (SystemGenerator.rollSystemComposition) — guaranteed to agree, since
|
||||
* the forks are pure functions of (seed, id, type, density).
|
||||
*
|
||||
* Cost: one weighted draw + a few pool scans per planet — trivial at this
|
||||
* galaxy size (data/galaxy.json → systemCount), computed once at galaxy
|
||||
* build time (js/galaxy/Galaxy.js).
|
||||
*/
|
||||
export function assignPlanetFrames({ seed, records, homeId, params, neighborsOf, typeDefs = null }) {
|
||||
const defs = typeDefs ?? config.get('systems.types', {});
|
||||
const shim = { params: params ?? {} }; // settlementDensity's shape
|
||||
|
||||
const poolFor = (cls) => {
|
||||
const pool = config.get(`planets.frames.${cls}`);
|
||||
return Array.isArray(pool) && pool.length > 0 ? pool : [0];
|
||||
};
|
||||
|
||||
const assigned = new Map(); // id → [[class, frame], ...] (assigned so far)
|
||||
const frames = new Map(); // id → [frame, ...] (one per planet, ordinal order)
|
||||
let homeFrame = null;
|
||||
|
||||
// FIXED spatial order (x, then y): a pure function of the seeded roster,
|
||||
// so the assignment never depends on generation timing or visit order.
|
||||
const ordered = records.slice().sort((a, b) => (a.x - b.x) || (a.y - b.y));
|
||||
|
||||
for (const rec of ordered) {
|
||||
const isHome = rec.id === homeId;
|
||||
const attr = defs[rec.type]?.attributes ?? {};
|
||||
const density = settlementDensity(shim, rec);
|
||||
const { classes } = rollSystemComposition(
|
||||
seed, rec, isHome, attr.settlements ?? {}, density, attr,
|
||||
);
|
||||
const neighbors = (typeof neighborsOf === 'function' ? neighborsOf(rec.id) : []) ?? [];
|
||||
|
||||
// usage(class, frame) = [own, nb]: same-class planets already wearing
|
||||
// that frame in THIS system (own) and among the ALREADY-ASSIGNED
|
||||
// neighbor systems (nb — the spread objective).
|
||||
const list = [];
|
||||
const usage = (cls, f) => {
|
||||
let own = 0;
|
||||
let nb = 0;
|
||||
for (const [c2, f2] of list) if (c2 === cls && f2 === f) own++;
|
||||
for (const n2 of neighbors) {
|
||||
for (const [c2, f2] of assigned.get(n2.id) ?? []) if (c2 === cls && f2 === f) nb++;
|
||||
}
|
||||
return [own, nb];
|
||||
};
|
||||
const less = (a, b) => a[0] < b[0] || (a[0] === b[0] && a[1] < b[1]);
|
||||
|
||||
// Pick the frame with the lowest (own, nb) usage — lexicographic: a
|
||||
// free face in this system always beats a used-but-neighborly one —
|
||||
// ties broken by a deterministic derived pick (seed, id, ordinal, class).
|
||||
const pick = (cls, idx) => {
|
||||
const pool = poolFor(cls);
|
||||
if (pool.length === 1) return pool[0];
|
||||
let best = null;
|
||||
for (const f of pool) {
|
||||
const u = usage(cls, f);
|
||||
if (best === null || less(u, best)) best = u;
|
||||
}
|
||||
const tied = pool.filter((f) => {
|
||||
const u = usage(cls, f);
|
||||
return u[0] === best[0] && u[1] === best[1];
|
||||
});
|
||||
if (tied.length === 1) return tied[0];
|
||||
return Rng.derive(seed, 'frames', rec.id, String(idx), cls).pick(tied);
|
||||
};
|
||||
|
||||
classes.forEach((cls, i) => {
|
||||
const f = pick(cls, i);
|
||||
list.push([cls, f]);
|
||||
});
|
||||
frames.set(rec.id, list.map(([, f]) => f));
|
||||
if (isHome) {
|
||||
// The home world (the origin) is a class-terran body — it takes a
|
||||
// frame from the same pass so its face is spread too.
|
||||
const homeCls = config.get('planets.homePlanet', 'terran');
|
||||
homeFrame = pick(homeCls, 99);
|
||||
list.push([homeCls, homeFrame]);
|
||||
}
|
||||
assigned.set(rec.id, list);
|
||||
}
|
||||
|
||||
return { frames, homeFrame };
|
||||
}
|
||||
|
|
@ -22,21 +22,29 @@ const DEG = Math.PI / 180;
|
|||
* data/systems.json (`types.<id>.attributes`): star classes, binary
|
||||
* chance, planet class weights, moon/belt chances, habitability, hazard,
|
||||
* and — the lived-in layer — `settlements` (per-type odds for the
|
||||
* free-space kinds). Planet COUNT is a global rule (data/systems.json →
|
||||
* `planetCount`): noneChance of systems are barren, the rest hold 2–4
|
||||
* worlds. Settlement kinds and their population ranges live in
|
||||
* data/settlements.json; the core→rim density gradient in data/galaxy.json
|
||||
* (`settlements.gradient`).
|
||||
* free-space kinds). The system's OBJECT COUNT is a global rule
|
||||
* (data/systems.json → `objectCount`): the starting system is exempt
|
||||
* (fixed two planets beside the home world + at most one station);
|
||||
* every other system rolls its TOTAL object count — planets +
|
||||
* free-space stations together — from the table (default: 10% barren —
|
||||
* a jump-gate-only system — then 2/3/4/5 objects at 15/30/30/15%).
|
||||
* Stations roll first (per-type odds × the core→rim gradient), planets
|
||||
* fill the rest of the budget. Settlement kinds and their population
|
||||
* ranges live in data/settlements.json; the core→rim density gradient in
|
||||
* data/galaxy.json (`settlements.gradient`).
|
||||
*
|
||||
* The galaxy is ALREADY LIVED IN: it was settled long before the player.
|
||||
* 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. Every
|
||||
* system holds at least one planet or free-space station — a barren
|
||||
* system that rolls no station gets a gate station — because the jump
|
||||
* gates (below) must be tether-reachable from an anchor. Nothing here is
|
||||
* hostile yet: `owner` on every settlement is a reserved seam for the
|
||||
* factions and pirates we'll introduce later.
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* The STARTING system is special: the player's home world sits at the
|
||||
* origin (not a generated planet, fixed key 'home'), and the system always
|
||||
|
|
@ -60,9 +68,22 @@ const DEG = Math.PI / 180;
|
|||
* 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), within level-1 tether (5120 px) of a planet or space
|
||||
* station. The galaxy-wide network is strongly connected: no closed
|
||||
* systems, no trapped sets.
|
||||
* 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.
|
||||
*
|
||||
* FRAME DIVERSITY (js/galaxy/PlanetFrames.js): each planet's spritesheet
|
||||
* frame is assigned by a galaxy-wide pass — the system's (class, frame)
|
||||
* avoids what the NEAREST stars already wear for that class — so the same
|
||||
* face (e.g. terran frame 0) is spread across the galaxy instead of
|
||||
* clustering in one region. The pass runs once at galaxy build (fixed
|
||||
* roster order ⇒ visit-order independent) and stamps `planet.frame` /
|
||||
* `content.homeFrame` here; the renderer prefers those over a random pick.
|
||||
*
|
||||
* Place identity (the reputation/trading/faction keys): every planet and
|
||||
* settlement carries a stable `id`, seed-deterministic because it is
|
||||
|
|
@ -101,19 +122,26 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
|||
};
|
||||
}
|
||||
|
||||
// --- Planets ----------------------------------------------------------
|
||||
// How many worlds the system holds (data/systems.json → planetCount):
|
||||
// noneChance of systems are barren (ZERO planets); the rest get a
|
||||
// uniform whole number in [min, max]. The STARTING system is the one
|
||||
// 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.
|
||||
// --- Composition: the system's object budget --------------------------
|
||||
// data/systems.json → objectCount. The STARTING system is exempt: it
|
||||
// always holds exactly TWO generated planets — a gas giant and a rocky
|
||||
// world — beside the home world (the origin, the player's homestead,
|
||||
// not a generated planet), plus at most one free-space station.
|
||||
// Every other system rolls its TOTAL object count N — planets +
|
||||
// free-space stations together — from the configured table (default:
|
||||
// 10% barren — a jump-gate-only stop — then 2/3/4/5 objects at
|
||||
// 15/30/30/15%). Stations roll next (per-type odds × the core→rim
|
||||
// density gradient, ≤ 2), the planets fill the rest: N − stations.
|
||||
// The roll lives on dedicated forks (rollSystemComposition), so the
|
||||
// galaxy-wide frame pass can reproduce it exactly.
|
||||
const isHome = record.id === galaxy?.currentSystemId;
|
||||
const pc = attr.planetCount ?? config.get('systems.planetCount', { noneChance: 0.2, min: 2, max: 4 });
|
||||
const count = isHome
|
||||
? 2
|
||||
: rng.chance(pc.noneChance ?? 0.2) ? 0 : rng.int(pc.min ?? 2, pc.max ?? 4);
|
||||
const classWeights = attr.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 };
|
||||
const spec = attr.settlements ?? {};
|
||||
const composition = rollSystemComposition(
|
||||
galaxy.seed, record, isHome, spec, settlementDensity(galaxy, record), attr,
|
||||
);
|
||||
const classes = composition.classes;
|
||||
const planetN = classes.length;
|
||||
// --- Planets ----------------------------------------------------------
|
||||
// Planet names come from a curated bank (data/naming.json → banks.planet),
|
||||
// dealt out per-system without repeats (see NameGenerator.planetDeck).
|
||||
// A dedicated derived stream keeps this order-independent (lazy === eager).
|
||||
|
|
@ -126,10 +154,8 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
|||
// from the rest of the deck (all distinct within the system).
|
||||
const homeName = isHome ? planetDeck[0] : null;
|
||||
const planets = [];
|
||||
for (let i = 1; i <= count; i++) {
|
||||
// The starting system's two worlds are fixed (gas giant, then rocky);
|
||||
// everywhere else the class rolls from the type's weights.
|
||||
const pclass = isHome ? (i === 1 ? 'gas' : 'rocky') : rng.weighted(classWeights, 'rocky');
|
||||
for (let i = 1; i <= planetN; i++) {
|
||||
const pclass = classes[i - 1];
|
||||
let moons = 0;
|
||||
if (rng.chance(attr.moonChance ?? 0.3)) {
|
||||
// Jovian/ice worlds drag moon systems; terrestrials mostly don't.
|
||||
|
|
@ -144,17 +170,27 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
|||
habitable: pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1),
|
||||
});
|
||||
}
|
||||
// FRAME DIVERSITY (js/galaxy/PlanetFrames.js — the galaxy-wide
|
||||
// (class, frame) pass): stamp the assigned sheet frame on each world so
|
||||
// the same class+face is spread across the galaxy (the renderer
|
||||
// prefers planet.frame over a random pick).
|
||||
const frames = galaxy?.planetFrames?.get(record.id);
|
||||
if (Array.isArray(frames)) {
|
||||
for (let i = 0; i < planets.length; i++) planets[i].frame = frames[i];
|
||||
}
|
||||
|
||||
// --- Settlements (the lived-in layer) ---------------------------------
|
||||
const settlements = generateSettlements({
|
||||
rng,
|
||||
systemId: record.id,
|
||||
kindDefs: config.get('settlements.kinds', {}),
|
||||
spec: attr.settlements ?? {},
|
||||
spec,
|
||||
planets,
|
||||
stationDeck: NameGenerator.stationDeck(
|
||||
Rng.derive(galaxy.seed, 'system', record.id, 'names', 'stations')
|
||||
),
|
||||
density: settlementDensity(galaxy, record),
|
||||
stations: { deepSpace: composition.deepSpace, waypoint: composition.waypoint },
|
||||
isHome,
|
||||
});
|
||||
|
||||
|
|
@ -207,7 +243,12 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
|||
hazard,
|
||||
jumps, // the system's jump gates (1–3; [] for a one-system galaxy)
|
||||
};
|
||||
if (isHome) content.homeName = homeName; // the player's home world (starting system only)
|
||||
if (isHome) {
|
||||
content.homeName = homeName; // the player's home world (starting system only)
|
||||
// The home world's sheet frame (the galaxy-wide frame pass stamped it
|
||||
// on the galaxy — js/galaxy/PlanetFrames.js).
|
||||
if (typeof galaxy?.homeWorldFrame === 'number') content.homeFrame = galaxy.homeWorldFrame;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
|
|
@ -396,15 +437,20 @@ 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";
|
||||
* - is within level-`anchorTetherLevel` tether (5120 px for level 1) of
|
||||
* an ANCHOR — a planet or space station (the home world counts) — 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
|
||||
* - 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
|
||||
* 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.
|
||||
|
|
@ -415,7 +461,10 @@ function bestRotation(place, targetAngles) {
|
|||
*
|
||||
* Record shape (one per gate):
|
||||
* { id: `<systemId>-j<n>`, name: `<Star> Gate`, to, toName,
|
||||
* x, y, size, rotation }
|
||||
* x, y, size, rotation, active: false }
|
||||
* `active` defaults to false — the gate is inert until the player
|
||||
* activates it (data/gates.json → ACTIVITY); an activated gate anchors a
|
||||
* level-1 tether at its own position.
|
||||
*/
|
||||
function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
|
||||
const g = config.section('gates', {});
|
||||
|
|
@ -431,7 +480,7 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
|
|||
|
||||
// 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.
|
||||
// (Every system holds at least one — generateSettlements guarantees it.)
|
||||
// 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) })),
|
||||
...freeSpace.map((f) => ({ x: f.x, y: f.y, r: stationKeepout(f.kind) })),
|
||||
|
|
@ -446,73 +495,110 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
|
|||
const ux = Math.cos(th);
|
||||
const uy = Math.sin(th);
|
||||
|
||||
// Candidate points, best first:
|
||||
// tier 1 — for every anchor, the FAR ray-circle intersection: the
|
||||
// gate exactly ON the target ray (system center, gate, and
|
||||
// star collinear), outside the anchor. Always faces the
|
||||
// target (≤ 90° from the anchor's point of view).
|
||||
// tier 2 — for every anchor, the point on the anchor's tether circle
|
||||
// that faces the target: a + range·u — exactly `range` from
|
||||
// the anchor (tether-reachable) and aimed exactly at the
|
||||
// star (zero deviation from the anchor's point of view).
|
||||
// tier 3 — a forward-hemisphere scan around each anchor's circle
|
||||
// (32 points, ± up to 75° from the target direction) — the
|
||||
// clearance search for tight systems.
|
||||
// Every candidate is exactly `range` from an anchor, so the level-N
|
||||
// tether rule (hard) is met by construction; the direction rule (soft)
|
||||
// is honored by the tier order: on-ray → aimed → near-aimed.
|
||||
const cands = [];
|
||||
anchors.forEach((a, ai) => {
|
||||
const proj = a.x * ux + a.y * uy; // signed distance along the ray
|
||||
const h = Math.abs(a.x * uy - a.y * ux); // perpendicular distance
|
||||
if (h <= range) {
|
||||
const off = Math.sqrt(Math.max(0, range * range - h * h));
|
||||
cands.push({ tier: 1, order: h * 1e6 + ai * 1000, px: (proj + off) * ux, py: (proj + off) * uy });
|
||||
}
|
||||
cands.push({ tier: 2, order: h * 1e6 + ai * 1000, px: a.x + range * ux, py: a.y + range * uy });
|
||||
for (let k = 0; k < 32; k++) {
|
||||
const phi = -1.3089 + (2.6179 * k) / 31; // ±75° around the target direction
|
||||
const dx = ux * Math.cos(phi) - uy * Math.sin(phi);
|
||||
const dy = ux * Math.sin(phi) + uy * Math.cos(phi);
|
||||
cands.push({ tier: 3, order: Math.abs(phi) * 1e6 + h + ai * 1e-3, px: a.x + range * dx, py: a.y + range * dy });
|
||||
}
|
||||
});
|
||||
cands.sort((p, q) => p.tier - q.tier || p.order - q.order);
|
||||
|
||||
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) {
|
||||
const need = b.r + size + clearance;
|
||||
const dx = c.px - b.x;
|
||||
const dy = c.py - b.y;
|
||||
if (dx * dx + dy * dy < need * need) return false;
|
||||
}
|
||||
for (const j of jumps) {
|
||||
const need = 2 * size + gap;
|
||||
const dx = c.px - j.x;
|
||||
const dy = c.py - j.y;
|
||||
if (dx * dx + dy * dy < need * need) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
let chosen = null;
|
||||
for (const c of cands) {
|
||||
if (ok(c)) {
|
||||
chosen = c;
|
||||
break;
|
||||
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
|
||||
// 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).
|
||||
const base = Math.max(minR, Math.min(maxR, Math.max(1, g.barrenDistance ?? 8192)));
|
||||
const okB = (px, py) => {
|
||||
const d2c = px * px + py * py;
|
||||
if (d2c < minR * minR || d2c > maxR * maxR) return false;
|
||||
for (const j of jumps) {
|
||||
const need = 2 * size + gap;
|
||||
const dx = px - j.x;
|
||||
const dy = py - j.y;
|
||||
if (dx * dx + dy * dy < need * need) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
for (let D = base; D <= maxR + 1e-6 && !chosen; D += 1024) {
|
||||
if (okB(D * ux, D * uy)) chosen = { px: D * ux, py: D * uy };
|
||||
}
|
||||
for (const eps of [0.05, -0.05, 0.1, -0.1, 0.2, -0.2]) {
|
||||
// Last resort — a 1024 px step over the band should always clear a
|
||||
// 384 px gap; angle-nudge if not (the facing rule is soft).
|
||||
if (chosen) break;
|
||||
const a = th + eps;
|
||||
if (okB(base * Math.cos(a), base * Math.sin(a))) {
|
||||
chosen = { px: base * Math.cos(a), py: base * Math.sin(a) };
|
||||
}
|
||||
}
|
||||
if (!chosen) {
|
||||
chosen = { px: base * ux, py: base * uy };
|
||||
console.warn(`[orbit] ${record.id}: gate ${i + 1} could not clear the gate gap (barren)`);
|
||||
}
|
||||
} else {
|
||||
// Candidate points, best first:
|
||||
// tier 1 — for every anchor, the FAR ray-circle intersection: the
|
||||
// gate exactly ON the target ray (system center, gate, and
|
||||
// star collinear), outside the anchor. Always faces the
|
||||
// target (≤ 90° from the anchor's point of view).
|
||||
// tier 2 — for every anchor, the point on the anchor's tether circle
|
||||
// that faces the target: a + range·u — exactly `range` from
|
||||
// the anchor (tether-reachable) and aimed exactly at the
|
||||
// star (zero deviation from the anchor's point of view).
|
||||
// tier 3 — a forward-hemisphere scan around each anchor's circle
|
||||
// (32 points, ± up to 75° from the target direction) — the
|
||||
// clearance search for tight systems.
|
||||
// Every candidate is exactly `range` from an anchor, so the level-N
|
||||
// tether rule (hard) is met by construction; the direction rule (soft)
|
||||
// is honored by the tier order: on-ray → aimed → near-aimed.
|
||||
const cands = [];
|
||||
anchors.forEach((a, ai) => {
|
||||
const proj = a.x * ux + a.y * uy; // signed distance along the ray
|
||||
const h = Math.abs(a.x * uy - a.y * ux); // perpendicular distance
|
||||
if (h <= range) {
|
||||
const off = Math.sqrt(Math.max(0, range * range - h * h));
|
||||
cands.push({ tier: 1, order: h * 1e6 + ai * 1000, px: (proj + off) * ux, py: (proj + off) * uy });
|
||||
}
|
||||
cands.push({ tier: 2, order: h * 1e6 + ai * 1000, px: a.x + range * ux, py: a.y + range * uy });
|
||||
for (let k = 0; k < 32; k++) {
|
||||
const phi = -1.3089 + (2.6179 * k) / 31; // ±75° around the target direction
|
||||
const dx = ux * Math.cos(phi) - uy * Math.sin(phi);
|
||||
const dy = ux * Math.sin(phi) + uy * Math.cos(phi);
|
||||
cands.push({ tier: 3, order: Math.abs(phi) * 1e6 + h + ai * 1e-3, px: a.x + range * dx, py: a.y + range * dy });
|
||||
}
|
||||
});
|
||||
cands.sort((p, q) => p.tier - q.tier || p.order - q.order);
|
||||
|
||||
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) {
|
||||
const need = b.r + size + clearance;
|
||||
const dx = c.px - b.x;
|
||||
const dy = c.py - b.y;
|
||||
if (dx * dx + dy * dy < need * need) return false;
|
||||
}
|
||||
for (const j of jumps) {
|
||||
const need = 2 * size + gap;
|
||||
const dx = c.px - j.x;
|
||||
const dy = c.py - j.y;
|
||||
if (dx * dx + dy * dy < need * need) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const c of cands) {
|
||||
if (ok(c)) {
|
||||
chosen = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!chosen) {
|
||||
// Every candidate failed clearance (nearly impossible — an anchor's
|
||||
// tether circle is 5120 px across, the discs under a thousand): take
|
||||
// the first anchor's aimed point anyway — the tether and facing rules
|
||||
// outrank cosmetics.
|
||||
chosen = cands.find((c) => c.tier === 2) ?? cands[0];
|
||||
console.warn(
|
||||
`[orbit] ${record.id}: gate ${i + 1} fell back to its first aimed candidate (clearance)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!chosen) {
|
||||
// Every candidate failed clearance (nearly impossible — an anchor's
|
||||
// tether circle is 5120 px across, the discs under a thousand): take
|
||||
// the first anchor's aimed point anyway — the tether and facing rules
|
||||
// outrank cosmetics.
|
||||
chosen = cands.find((c) => c.tier === 2) ?? cands[0];
|
||||
console.warn(
|
||||
`[orbit] ${record.id}: gate ${i + 1} fell back to its first aimed candidate (clearance)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Unique gate name (two targets can share a star name — star names are
|
||||
|
|
@ -530,6 +616,11 @@ function layoutGates(seed, record, planets, freeSpace, isHome, targets) {
|
|||
x: chosen.px,
|
||||
y: chosen.py,
|
||||
size,
|
||||
// Inert until the player activates it (data/gates.json → ACTIVITY):
|
||||
// an activated gate anchors a level-1 tether at its own position —
|
||||
// the room to move in a barren system. The activation mechanic is
|
||||
// future work; the renderer dims inactive gates.
|
||||
active: false,
|
||||
// The gate's visual bearing — from the gate's own position to the
|
||||
// destination star, so it always points exactly at where it jumps.
|
||||
rotation: Math.atan2(t.y - chosen.py, t.x - chosen.px),
|
||||
|
|
@ -618,6 +709,9 @@ 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 [];
|
||||
|
|
@ -858,12 +952,87 @@ function mixTint(hex, strength) {
|
|||
return (mix((n >> 16) & 255) << 16) | (mix((n >> 8) & 255) << 8) | mix(n & 255);
|
||||
}
|
||||
|
||||
/**
|
||||
* The system's OBJECT COMPOSITION (data/systems.json → objectCount +
|
||||
* attributes.settlements + the core→rim density gradient —
|
||||
* data/galaxy.json → settlements.gradient), in one deterministic roll:
|
||||
*
|
||||
* home system → fixed: 2 planets (gas giant + rocky) beside the home
|
||||
* world, + at most one free-space station (deepSpace only —
|
||||
* a waypoint would be a 5th point the home band can't hold);
|
||||
* every other → 1) the FINAL object count N from the configured table
|
||||
* (0 = barren, then 2/3/4/5 at the configured weights — the
|
||||
* distribution is on the FINAL count, by design);
|
||||
* 2) the free-space stations (0–2, per-type odds × the
|
||||
* density gradient; at most 2, which never exceeds the
|
||||
* minimum non-barren budget of 2);
|
||||
* 3) the planets — N − stations weighted class draws
|
||||
* (a 2-object system can be 0 planets + 2 stations).
|
||||
*
|
||||
* Both the content generator and the galaxy-wide frame pass
|
||||
* (js/galaxy/PlanetFrames.js) call this — the forks
|
||||
* (seed, 'system', id, 'planets' / 'settlements') are pure functions of
|
||||
* their inputs, so they always agree.
|
||||
* → { objects, deepSpace, waypoint, classes }
|
||||
*/
|
||||
export function rollSystemComposition(seed, record, isHome, spec, density, attr) {
|
||||
if (isHome) {
|
||||
const rng = Rng.derive(seed, 'system', record.id, 'settlements');
|
||||
const deepSpace = rng.chance((spec?.deepSpaceStation?.chance ?? 0.12) * density);
|
||||
return { objects: 2, deepSpace, waypoint: false, classes: ['gas', 'rocky'] };
|
||||
}
|
||||
// 1) The final object count — the configured composition table.
|
||||
const oc = config.get('systems.objectCount', {
|
||||
barren: 0.1,
|
||||
objects: { 2: 0.15, 3: 0.3, 4: 0.3, 5: 0.15 },
|
||||
});
|
||||
const table = { 0: Math.max(0, Number(oc.barren) || 0) };
|
||||
for (const [k, w] of Object.entries(oc.objects ?? {})) {
|
||||
const n = Number(k);
|
||||
if (Number.isInteger(n) && n > 0) table[n] = Math.max(0, Number(w) || 0);
|
||||
}
|
||||
const rngP = Rng.derive(seed, 'system', record.id, 'planets');
|
||||
const objects = Math.max(0, Number(rngP.weighted(table, 2)));
|
||||
|
||||
// BARREN (N = 0): a jump-gate-only system — no stations, no planets.
|
||||
if (objects === 0) {
|
||||
return { objects, deepSpace: false, waypoint: false, classes: [] };
|
||||
}
|
||||
|
||||
// 2) The free-space stations (≤ 2 — never more than the min budget of 2).
|
||||
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).
|
||||
const classWeights = attr?.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 };
|
||||
const classes = Array.from({ length: objects - stations }, () => rngP.weighted(classWeights, 'rocky'));
|
||||
return { objects, deepSpace, waypoint, classes };
|
||||
}
|
||||
|
||||
/**
|
||||
* The system's PLANET CLASSES — a thin wrapper over rollSystemComposition
|
||||
* for callers that only need the worlds (the frame pass uses the full roll).
|
||||
*/
|
||||
export function rollPlanetClasses(seed, record, isHome, attr, spec, density) {
|
||||
return rollSystemComposition(seed, record, isHome, spec, density, attr).classes;
|
||||
}
|
||||
|
||||
/** Backwards-compatible roll (tests/tools) — the stations of a system. */
|
||||
export function rollStationCount(seed, record, isHome, spec, density) {
|
||||
const c = rollSystemComposition(seed, record, isHome, spec, density, {});
|
||||
return { deepSpace: c.deepSpace, waypoint: c.waypoint, count: Number(c.deepSpace) + Number(c.waypoint) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Core→rim density: the settled heart of the galaxy has more activity per
|
||||
* system; the rim is thinner, lonelier. `factor` scales every settlement
|
||||
* chance (clamped to a floor so the rim isn't dead). 0 = no gradient.
|
||||
* Exported: the frame pass (js/galaxy/PlanetFrames.js) re-derives the same
|
||||
* values from the same inputs.
|
||||
*/
|
||||
function settlementDensity(galaxy, record) {
|
||||
export function settlementDensity(galaxy, record) {
|
||||
const g = galaxy?.params?.settlements?.gradient ?? {};
|
||||
const falloff = Math.max(0, g.falloff ?? 0.7);
|
||||
const floor = clamp(g.floor ?? 0.22, 0, 1);
|
||||
|
|
@ -886,7 +1055,7 @@ function settlementDensity(galaxy, record) {
|
|||
* the old per-type odds (spec.chance + needs) take over again. The
|
||||
* free-space kinds still roll per type (core→rim scaled).
|
||||
*/
|
||||
function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDeck, density, isHome = false }) {
|
||||
function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDeck, density, stations, isHome = false }) {
|
||||
const out = [];
|
||||
let nameIndex = 0; // next station name from the system's deck (no repeats)
|
||||
|
||||
|
|
@ -927,26 +1096,13 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe
|
|||
}
|
||||
}
|
||||
|
||||
// Free-floating, out in the dark (per-type odds, core→rim scaled).
|
||||
// The HOME system is capped at ONE free-space station: with the home
|
||||
// world and its two fixed planets it must stay a ≤ 4-object
|
||||
// configuration — the home band (6400..10240 px) cannot hold 5 points
|
||||
// (see layoutSystem).
|
||||
if (roll(rng, spec.deepSpaceStation?.chance ?? 0.12, density)) {
|
||||
make('deepSpaceStation', { type: 'space' });
|
||||
}
|
||||
if (!isHome && roll(rng, spec.waypoint?.chance ?? 0.2, density)) {
|
||||
make('waypoint', { type: 'space' });
|
||||
}
|
||||
|
||||
// JUMP-GATE ANCHOR GUARANTEE — every system must hold at least one
|
||||
// planet or space station: the jump gates sit within level-1 tether of
|
||||
// an anchor, so a system with neither would be unreachable (a closed
|
||||
// system). A barren system that rolled no free-space station gets a
|
||||
// gate station.
|
||||
if (planets.length === 0 && out.length === 0) {
|
||||
make('deepSpaceStation', { type: 'space' });
|
||||
}
|
||||
// Free-floating, out in the dark — the PRE-ROLLED flags (rollStationCount:
|
||||
// per-type odds × the core→rim density gradient; the home system never
|
||||
// rolls a waypoint — its band can't hold 5 points). A BARREN system
|
||||
// (objectCount → 0) rolled no stations — it is a jump-gate-only stop,
|
||||
// deliberately.
|
||||
if (stations?.deepSpace) make('deepSpaceStation', { type: 'space' });
|
||||
if (stations?.waypoint) make('waypoint', { type: 'space' });
|
||||
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,11 +218,17 @@ export class GameScene extends Phaser.Scene {
|
|||
|
||||
// The home planet — the player's Terran world, always present in the
|
||||
// system they start in. It sits at the world origin. Which Terran face
|
||||
// it shows is a seed-deterministic pick from the terran pool, so the
|
||||
// same galaxy always yields the same home world.
|
||||
// it shows comes from the galaxy-wide frame pass (content.homeFrame —
|
||||
// the same (class, face) spreading as the planets), falling back to a
|
||||
// seed-deterministic pool pick, so the same galaxy always yields the
|
||||
// same home world.
|
||||
const homeName = config.get('planets.homePlanet', 'terran');
|
||||
const homeRng = Rng.derive(this.galaxy.seed, 'planet', 'home');
|
||||
this.planet = new Planet(this, 0, 0, Planet.frameFor(homeName, homeRng), homeName);
|
||||
const homeFrame =
|
||||
typeof this.systemContent.homeFrame === 'number'
|
||||
? this.systemContent.homeFrame
|
||||
: Planet.frameFor(homeName, homeRng);
|
||||
this.planet = new Planet(this, 0, 0, homeFrame, homeName);
|
||||
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
|
||||
|
|
@ -233,7 +239,13 @@ export class GameScene extends Phaser.Scene {
|
|||
for (const rec of this.systemContent.planets ?? []) {
|
||||
if (typeof rec.x !== 'number' || typeof rec.y !== 'number') continue;
|
||||
const kind = rec.class || 'rocky';
|
||||
const frame = Planet.frameFor(kind, Rng.derive(this.galaxy.seed, 'planet', rec.name));
|
||||
// The galaxy-wide frame pass (js/galaxy/PlanetFrames.js) stamped
|
||||
// `rec.frame` to spread each (class, face) across the galaxy; fall
|
||||
// back to a random pool pick for content that predates the pass.
|
||||
const frame =
|
||||
typeof rec.frame === 'number'
|
||||
? rec.frame
|
||||
: Planet.frameFor(kind, Rng.derive(this.galaxy.seed, 'planet', rec.name));
|
||||
const tint = config.get(`planets.classTint.${kind}`);
|
||||
const p = new Planet(this, rec.x, rec.y, frame, kind, {
|
||||
scale: rec.scale ?? 1,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* 40,000 systems and no reason to run out of star names.
|
||||
* 200 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue