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:
Brian Fertig 2026-09-06 13:06:29 -06:00
parent a1aa25b5ba
commit 0b92cb7b98
19 changed files with 905 additions and 254 deletions

View File

@ -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 24 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 13 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: 13 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

View File

@ -1,5 +1,5 @@
{
"systemCount": 40000,
"systemCount": 200,
"radius": 20000,
"layout": {
"coreFraction": 0.25,

View File

@ -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" }
}

View File

@ -1,5 +1,5 @@
{
"_comment": "Names. Stars and the galaxy are still SYNTHEISED from syllable pools (star / galaxy below) — short, consistent, unlimited. PLANETS and STATIONS draw from finite, curated NAME BANKS instead: a deep pool of hand-picked names, dealt out without repeats until the pool is exhausted (see js/utils/NameGenerator.js → planetDeck / stationDeck). Edit the banks freely — names are drawn per-system from the galaxy seed, so changing the banks changes every name, consistently. Within a system a planet never repeats another planet's name and a station never repeats another station's name; across the 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,

View File

@ -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",

View File

@ -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 targetObjectsplanets (±jitter), clamped ${D.minClusters}${D.maxClusters}${countOk ? '' : ' — ' + countWhy}`, countOk);
check(`count: ${sample.length} systems obey targetObjectsplanets (±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);

View File

@ -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) =>

184
dev/frames.test.mjs Normal file
View File

@ -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);

View File

@ -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

View File

@ -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 records 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);

View File

@ -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 systemstar
* 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 systemstar bearing from the anchoring object);
* - BARREN systems (objectCount 0 a jump-gate-only stop): the gate
* sits ON the ray toward its destination, gates.barrenDistance from
* the star, within the radius band, facing it;
* - gates stay gates.minRadius..gates.maxRadius from the star;
* - gates keep size+clearance from anchor discs and 2·size+gateGap
* from each other;
* - 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;

View File

@ -81,7 +81,7 @@ The galaxy is **seeded and two-level**. The seed is chosen on the main
menu (displayed, editable, rerollable; same seed ⇒ same galaxy).
1. **Roster**`Galaxy.create(seed)` builds every system's *identity*
(id, name, type, x, y) up front. 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] (24).
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 (02, per-type odds × the core→rim
density), then planets fill the remaining budget (N stations). The
**starting system** is the exception: it always holds exactly two
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

View File

@ -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);

View File

@ -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. */

View File

@ -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.
*/

113
js/galaxy/PlanetFrames.js Normal file
View File

@ -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 02, gas 35, rocky 68
* 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 };
}

View File

@ -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 24
* worlds. Settlement kinds and their population ranges live in
* data/settlements.json; the corerim 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 corerim gradient), planets
* fill the rest of the budget. Settlement kinds and their population
* ranges live in data/settlements.json; the corerim 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):
* 13 gates per system each placed on the side of the system facing its
* destination star on the 2-D map (an upper-right gate jumps to a star in
* the upper right), 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 (13; [] 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 corerim 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 (02, 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) };
}
/**
* Corerim 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 (corerim 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;
}

View File

@ -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 02), 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,

View File

@ -7,7 +7,7 @@ import { config } from '../config/Config.js';
*
* STARS & THE GALAXY synthesised from syllable pools (data/naming.json
* star / galaxy). Short, consistent, and effectively unlimited: there are
* 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