Make every planet settled and simplify system planet counts to a global
- Planet count is now a single global rule (20% barren, else 2–4 planets) instead of per-type spreads; the starting system always holds a gas giant plus a rocky world beside the home world. - Every planet hosts one settlement by class (habitable rocky → colony, other rocky/ice/lava → mining station, gas → cloud base); free-space stations and beacons still roll per archetype with the core→rim gradient. - Old probabilistic planet-settlement path kept behind allPlanetsSettled flag. - Layout max drops to 6 objects (4 planets + 2 stations); the 11-object two-orbit case remains as a defensive fallback. - Update tests, docs, and add rocky-land video asset.
This commit is contained in:
parent
4fd1f05359
commit
09dfd00dd3
15
README.md
15
README.md
|
|
@ -45,12 +45,15 @@ node dev/server.mjs 8080
|
|||
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.
|
||||
Systems host colonies on habitable worlds, mining stations over resource
|
||||
worlds, cloud bases riding gas giants, stations adrift in open space, and
|
||||
beacons — or report *charted · unclaimed* when nobody's there. Rates are
|
||||
per-archetype (`data/systems.json`) and thin out from the settled core to
|
||||
the wilder rim (`data/galaxy.json`). Each settlement has a name,
|
||||
population, and an `owner` seam reserved for the factions/pirates to come.
|
||||
Every planet is settled (for now): colonies on habitable worlds, mining
|
||||
stations over the rest, cloud bases riding gas giants — plus stations
|
||||
adrift in open space and beacons, whose odds are per-archetype
|
||||
(`data/systems.json`) and thin out from the settled core to the wilder
|
||||
rim (`data/galaxy.json`). Barren systems with nothing adrift report
|
||||
*charted · unclaimed*. Systems hold 2–4 planets, or none (~20% are
|
||||
barren); the starting system always holds the home world, a gas giant,
|
||||
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
|
||||
top-left in the game scene.
|
||||
- **Names with a point of view** (`data/naming.json`): planets draw from a
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,5 +1,12 @@
|
|||
{
|
||||
"_comment": "The galaxy is already LIVED IN: systems host settlements — colonies on habitable worlds, mining stations over resource worlds, cloud bases riding gas giants, stations adrift in open space. kinds = the shared vocabulary (label, theme, population range, anchor type). HOW OFTEN each kind appears, and what world it needs, is a per-type attribute in data/systems.json (types.<id>.attributes.settlements). Core→rim density gradient lives in data/galaxy.json (settlements.gradient).",
|
||||
"_comment": "The galaxy is already LIVED IN: it was settled long before the player. For now EVERY planet hosts a settlement (allPlanetsSettled): each world gets the one kind that fits its class (settledKindByClass — a habitable rocky world earns a colony, every other world gets its mining outfit, gas giants ride cloud bases). Flip allPlanetsSettled to false to return to the old probabilistic layer (per-type chance + needs in data/systems.json). The free-space kinds (deepSpaceStation, waypoint) still roll per type. kinds = the shared vocabulary (label, theme, population range, anchor type). Core→rim density gradient lives in data/galaxy.json (settlements.gradient).",
|
||||
"allPlanetsSettled": true,
|
||||
"settledKindByClass": {
|
||||
"rocky": { "habitable": "colony", "default": "miningStation" },
|
||||
"gas": "cloudBase",
|
||||
"ice": "miningStation",
|
||||
"lava": "miningStation"
|
||||
},
|
||||
"kinds": {
|
||||
"colony": {
|
||||
"label": "Colony",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet count spread, planet class weights, moon/belt chances, habitability, hazard. distribution.weight sets how common the type is; distribution.radiusBand ([inner, outer] as a fraction of galaxy radius) is the first proximity rule — richer distribution rules slot into galaxy.json `distribution.rules` later.",
|
||||
"_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet class weights, moon/belt chances, habitability, hazard, and free-space settlement odds (settlements: deepSpaceStation / waypoint). 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 },
|
||||
"types": {
|
||||
"main": {
|
||||
"label": "Main Sequence",
|
||||
|
|
@ -12,16 +13,12 @@
|
|||
"mass": { "G": [0.8, 1.4], "K": [0.5, 0.8], "M": [0.1, 0.5] }
|
||||
},
|
||||
"binaryChance": 0.05,
|
||||
"planetCount": { "min": 4, "max": 9, "mean": 6.5 },
|
||||
"planetClasses": { "rocky": 45, "gas": 25, "ice": 18, "lava": 12 },
|
||||
"moonChance": 0.3,
|
||||
"beltChance": 0.35,
|
||||
"habitability": 0.1,
|
||||
"hazard": 0.1,
|
||||
"settlements": {
|
||||
"colony": { "chance": 0.45 },
|
||||
"miningStation": { "chance": 0.3, "needs": ["rocky", "lava", "ice"] },
|
||||
"cloudBase": { "chance": 0.35, "needs": ["gas"] },
|
||||
"deepSpaceStation": { "chance": 0.25 },
|
||||
"waypoint": { "chance": 0.35 }
|
||||
}
|
||||
|
|
@ -38,16 +35,12 @@
|
|||
"mass": { "M": [0.08, 0.35], "K": [0.5, 0.8] }
|
||||
},
|
||||
"binaryChance": 0.12,
|
||||
"planetCount": { "min": 3, "max": 7, "mean": 5 },
|
||||
"planetClasses": { "rocky": 50, "gas": 15, "ice": 28, "lava": 7 },
|
||||
"moonChance": 0.45,
|
||||
"beltChance": 0.3,
|
||||
"habitability": 0.22,
|
||||
"hazard": 0.12,
|
||||
"settlements": {
|
||||
"colony": { "chance": 0.6 },
|
||||
"miningStation": { "chance": 0.35, "needs": ["rocky", "ice"] },
|
||||
"cloudBase": { "chance": 0.1, "needs": ["gas"] },
|
||||
"deepSpaceStation": { "chance": 0.2 },
|
||||
"waypoint": { "chance": 0.3 }
|
||||
}
|
||||
|
|
@ -64,16 +57,12 @@
|
|||
"mass": { "F": [1.0, 1.6], "G": [0.8, 1.4], "K": [0.5, 0.8] }
|
||||
},
|
||||
"binaryChance": 1.0,
|
||||
"planetCount": { "min": 2, "max": 6, "mean": 4 },
|
||||
"planetClasses": { "rocky": 30, "gas": 40, "ice": 20, "lava": 10 },
|
||||
"moonChance": 0.5,
|
||||
"beltChance": 0.6,
|
||||
"habitability": 0.06,
|
||||
"hazard": 0.18,
|
||||
"settlements": {
|
||||
"colony": { "chance": 0.1 },
|
||||
"miningStation": { "chance": 0.3, "needs": ["rocky", "gas"] },
|
||||
"cloudBase": { "chance": 0.5, "needs": ["gas"] },
|
||||
"deepSpaceStation": { "chance": 0.3 },
|
||||
"waypoint": { "chance": 0.3 }
|
||||
}
|
||||
|
|
@ -90,16 +79,12 @@
|
|||
"mass": { "G": [0.85, 1.3], "K": [0.6, 0.85] }
|
||||
},
|
||||
"binaryChance": 0.03,
|
||||
"planetCount": { "min": 3, "max": 8, "mean": 5.5 },
|
||||
"planetClasses": { "rocky": 62, "gas": 18, "ice": 14, "lava": 6 },
|
||||
"moonChance": 0.35,
|
||||
"beltChance": 0.25,
|
||||
"habitability": 0.5,
|
||||
"hazard": 0.05,
|
||||
"settlements": {
|
||||
"colony": { "chance": 0.85 },
|
||||
"miningStation": { "chance": 0.2, "needs": ["rocky", "ice"] },
|
||||
"cloudBase": { "chance": 0.2, "needs": ["gas"] },
|
||||
"deepSpaceStation": { "chance": 0.25 },
|
||||
"waypoint": { "chance": 0.4 }
|
||||
}
|
||||
|
|
@ -116,16 +101,12 @@
|
|||
"mass": { "A": [1.4, 2.1], "F": [1.0, 1.6], "G": [0.8, 1.4] }
|
||||
},
|
||||
"binaryChance": 0.1,
|
||||
"planetCount": { "min": 1, "max": 5, "mean": 3 },
|
||||
"planetClasses": { "rocky": 20, "gas": 25, "ice": 15, "lava": 40 },
|
||||
"moonChance": 0.2,
|
||||
"beltChance": 0.8,
|
||||
"habitability": 0.02,
|
||||
"hazard": 0.35,
|
||||
"settlements": {
|
||||
"colony": { "chance": 0.05 },
|
||||
"miningStation": { "chance": 0.5, "needs": ["lava", "rocky"] },
|
||||
"cloudBase": { "chance": 0.3, "needs": ["gas"] },
|
||||
"deepSpaceStation": { "chance": 0.2 },
|
||||
"waypoint": { "chance": 0.45 }
|
||||
}
|
||||
|
|
@ -142,16 +123,12 @@
|
|||
"mass": { "M": [0.08, 0.3], "K": [0.4, 0.6] }
|
||||
},
|
||||
"binaryChance": 0.08,
|
||||
"planetCount": { "min": 0, "max": 4, "mean": 1.5 },
|
||||
"planetClasses": { "rocky": 20, "gas": 10, "ice": 55, "lava": 15 },
|
||||
"moonChance": 0.1,
|
||||
"beltChance": 0.2,
|
||||
"habitability": 0.02,
|
||||
"hazard": 0.5,
|
||||
"settlements": {
|
||||
"colony": { "chance": 0.02 },
|
||||
"miningStation": { "chance": 0.3, "needs": ["ice"] },
|
||||
"cloudBase": { "chance": 0.05, "needs": ["gas"] },
|
||||
"deepSpaceStation": { "chance": 0.15 },
|
||||
"waypoint": { "chance": 0.25 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -244,10 +244,10 @@ check(
|
|||
return { planets: c.planets.length, clusters: c.asteroids.length };
|
||||
});
|
||||
const avg = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length;
|
||||
const rich = rows.filter((r) => r.planets >= 6);
|
||||
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)} > ≥6 planets: ${avg(rich.map((r) => r.clusters)).toFixed(2)}`,
|
||||
`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)}`,
|
||||
rich.length > 0 && poor.length > 0 && avg(poor.map((r) => r.clusters)) > avg(rich.map((r) => r.clusters)),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,8 @@ const MAX_NBR = band.maxNeighbor;
|
|||
const g = Galaxy.create('discovery-layout-test');
|
||||
let layoutOk = true;
|
||||
let layoutWhy = '';
|
||||
let sawTwoOrbits = false; // the N = 11 case (9 planets + 2 stations)
|
||||
let sawMaxObjects = false; // the N = 6 maximum (4 planets + 2 stations)
|
||||
let maxObjectsSeen = 0;
|
||||
const probe = (systems) => {
|
||||
for (const rec of systems) {
|
||||
const c = g.ensureContent(rec.id);
|
||||
|
|
@ -144,7 +145,9 @@ const probe = (systems) => {
|
|||
}
|
||||
objs.push({ x: s.x, y: s.y });
|
||||
}
|
||||
if (objs.length - 1 === 11) sawTwoOrbits = true;
|
||||
const nObjects = objs.length - 1;
|
||||
if (nObjects > maxObjectsSeen) maxObjectsSeen = nObjects;
|
||||
if (nObjects === 6) sawMaxObjects = true;
|
||||
// 1) No two objects closer than minSpacing (center to center).
|
||||
for (let i = 0; i < objs.length && layoutOk; i++) {
|
||||
for (let j = i + 1; j < objs.length; j++) {
|
||||
|
|
@ -176,7 +179,8 @@ check(
|
|||
`layout: 5000 systems obey minSpacing (no pair <${MIN_SEP}px center-to-center) & maxNeighbor (every object ≤${MAX_NBR}px from a neighbor)${layoutOk ? '' : ' — ' + layoutWhy}`,
|
||||
layoutOk,
|
||||
);
|
||||
check('layout: the 11-object two-orbit case (9 planets + 2 stations) occurs in the sample', sawTwoOrbits);
|
||||
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);
|
||||
|
||||
// Determinism on a system that has BOTH planet and station objects.
|
||||
const rec0 = g.records.find((r) =>
|
||||
|
|
|
|||
|
|
@ -146,21 +146,28 @@ let big;
|
|||
}
|
||||
check('radius bands (proximity rule) respected by every system', bandOk);
|
||||
|
||||
// Lazy contents: attribute bounds across the WHOLE galaxy.
|
||||
// Lazy contents: the global planet-count rule across the WHOLE galaxy.
|
||||
const t1 = performance.now();
|
||||
const pc = config.get('systems.planetCount', { noneChance: 0, min: 0, max: 99 });
|
||||
let boundsOk = true;
|
||||
let emptyCount = 0;
|
||||
for (const r of big.records) {
|
||||
const content = big.ensureContent(r.id);
|
||||
const attr = types[r.type].attributes ?? {};
|
||||
const pc = attr.planetCount ?? { min: 0, max: 99 };
|
||||
if (content.planets.length < pc.min || content.planets.length > pc.max) {
|
||||
const n = content.planets.length;
|
||||
if (n === 0) emptyCount++;
|
||||
if (!(n === 0 || (n >= pc.min && n <= pc.max))) {
|
||||
boundsOk = false;
|
||||
break;
|
||||
}
|
||||
if (!content.star || typeof content.star.class !== 'string') boundsOk = false;
|
||||
}
|
||||
const tGen = performance.now() - t1;
|
||||
check('every system obeys its type attribute bounds (planetCount etc.)', boundsOk);
|
||||
check(`every system has 0 or [${pc.min}, ${pc.max}] planets (the global planetCount rule)`, boundsOk);
|
||||
const emptyShare = emptyCount / nSys;
|
||||
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,
|
||||
);
|
||||
check('lazy content generation over all 40k systems', big.generatedCount === nSys);
|
||||
|
||||
// Lazy === eager: fresh galaxy (unopened) vs fully generated one.
|
||||
|
|
@ -278,6 +285,29 @@ let big;
|
|||
check('the lived-in mix appears (colonies, miners, cloud bases, stations, beacons)', ['colony', 'miningStation', 'cloudBase', 'deepSpaceStation', 'waypoint'].every((k) => seen.has(k)));
|
||||
check('colonies sit on habitable worlds; cloud bases ride gas giants', anchorRulesOk);
|
||||
|
||||
// The all-planets-settled rule (for now): EVERY world hosts a settlement,
|
||||
// the kind that fits its class (data/settlements.json → settledKindByClass
|
||||
// — habitable rocky ⇒ colony, other rocky/ice/lava ⇒ mining station,
|
||||
// gas ⇒ cloud base).
|
||||
let allSettledOk = true;
|
||||
for (const r of sample) {
|
||||
const c = big2.ensureContent(r.id);
|
||||
for (const p of c.planets) {
|
||||
const anchored = c.settlements.some(
|
||||
(s) => s.anchor?.type === 'planet' && s.anchor?.ordinal === p.ordinal,
|
||||
);
|
||||
const kindOk = c.settlements.filter(
|
||||
(s) => s.anchor?.type === 'planet' && s.anchor?.ordinal === p.ordinal,
|
||||
).every((s) =>
|
||||
(p.class === 'gas' && s.kind === 'cloudBase') ||
|
||||
(p.class !== 'gas' && (s.kind === 'colony' || s.kind === 'miningStation'))
|
||||
);
|
||||
if (!anchored || !kindOk) { allSettledOk = false; break; }
|
||||
}
|
||||
if (!allSettledOk) break;
|
||||
}
|
||||
check('every planet is settled with a class-fitting kind (colonies only on habitable rocky worlds)', allSettledOk);
|
||||
|
||||
// Not everything is inhabited — some systems stay unclaimed.
|
||||
let unclaimed = 0;
|
||||
for (const r of sample) if ((big2.ensureContent(r.id).settlements).length === 0) unclaimed++;
|
||||
|
|
@ -338,6 +368,20 @@ let big;
|
|||
const nonHomeRec = big2.records.find((r) => r.id !== homeRec.id);
|
||||
check('other systems have no home world', !('homeName' in big2.ensureContent(nonHomeRec.id)));
|
||||
|
||||
// The starting system always holds exactly three planets: the home world
|
||||
// (the origin, not a generated planet) + a gas giant + a rocky world —
|
||||
// both generated worlds settled (gas ⇒ cloud base, rocky ⇒ colony or
|
||||
// mining station).
|
||||
check(
|
||||
'starting system = home world + gas giant + rocky world (exactly 2 generated: gas, then rocky)',
|
||||
homeC.planets.length === 2 && homeC.planets[0].class === 'gas' && homeC.planets[1].class === 'rocky',
|
||||
);
|
||||
check(
|
||||
'starting system: gas giant rides a cloud base; rocky world hosts a colony or mining station',
|
||||
homeC.settlements.some((s) => s.kind === 'cloudBase' && s.anchor?.ordinal === 1) &&
|
||||
homeC.settlements.some((s) => (s.kind === 'colony' || s.kind === 'miningStation') && s.anchor?.ordinal === 2),
|
||||
);
|
||||
|
||||
// Lazy === eager still holds with the lived-in layer (spot check).
|
||||
const fresh2 = Galaxy.create(SEED);
|
||||
check('settlements: lazy content === content from a fresh galaxy', deepEq(fresh2.ensureContent(big2.records[0].id).settlements, big2.ensureContent(big2.records[0].id).settlements));
|
||||
|
|
|
|||
|
|
@ -104,9 +104,16 @@ menu (displayed, editable, rerollable; same seed ⇒ same galaxy).
|
|||
**System archetypes** live in `data/systems.json`. Each type is:
|
||||
- **themable** — `theme.color` etc. for UI;
|
||||
- **attribute-driven** — `attributes` steer the SystemGenerator (star
|
||||
classes, binary chance, planet count spread, planet class weights,
|
||||
moon/belt chances, habitability, hazard). New attribute key = JSON +
|
||||
a few lines in `SystemGenerator.js`;
|
||||
classes, binary chance, planet class weights, moon/belt chances,
|
||||
habitability, hazard, free-space settlement odds). New attribute key =
|
||||
JSON + a few lines in `SystemGenerator.js`;
|
||||
- **planet count is global, not per-type** — `data/systems.json →
|
||||
planetCount`: noneChance (20%) of all systems are barren (no planets
|
||||
at all), the rest hold a uniform whole number in [min, max] (2–4).
|
||||
Exception: the **starting system** always holds exactly two generated
|
||||
planets — a gas giant and a rocky world — which with the home world
|
||||
(the origin, the player's homestead, not a generated planet) makes its
|
||||
three planets, always.
|
||||
- **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,
|
||||
|
|
@ -120,18 +127,26 @@ The player's **current system** starts at `galaxy.currentSystem()`
|
|||
and the spatial hash already built for it.
|
||||
|
||||
**The galaxy is already lived in.** It was settled long before the
|
||||
player arrives. Every system's content can include **settlements**:
|
||||
`colony` (on a habitable world), `miningStation` (over a resource
|
||||
world), `cloudBase` (riding a gas giant), `deepSpaceStation` (adrift in
|
||||
open space), `waypoint` (a small beacon — the faint trace of a crossed
|
||||
galaxy). Not everything is inhabited: many systems report "charted ·
|
||||
unclaimed". Model & seams:
|
||||
player arrives. **Every planet is settled (for now)**: each world hosts
|
||||
the one settlement kind that fits its class (`data/settlements.json →
|
||||
allPlanetsSettled` + `settledKindByClass`) — `colony` on a habitable
|
||||
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 charted-but-unclaimed systems are the barren ones
|
||||
that also roll no free-space station. 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.
|
||||
- **Per-type rates** — `types.<id>.attributes.settlements` in
|
||||
`data/systems.json`: `chance` + optional `needs` (planet classes) per
|
||||
kind. Same attribute-driven pattern as everything else.
|
||||
- **The settled rule** — `data/settlements.json → allPlanetsSettled` +
|
||||
`settledKindByClass` (class → kind, with a `habitable` override for
|
||||
rocky worlds). Flip `allPlanetsSettled` off to return to the old
|
||||
probabilistic layer (per-type `chance` + `needs` — the code path is
|
||||
kept behind the flag).
|
||||
- **Per-type free-space rates** — `types.<id>.attributes.settlements` in
|
||||
`data/systems.json`: `chance` per free-space kind (deep-space station,
|
||||
waypoint). Same attribute-driven pattern as everything else.
|
||||
- **Core→rim gradient** — `galaxy.settlements.gradient` in
|
||||
`data/galaxy.json`: the settled heart is denser (factor 1.0), the rim
|
||||
is thinner (clamped to `floor`). Each record carries `rNorm` (0 =
|
||||
|
|
@ -179,10 +194,12 @@ world** — solid, rendered, flyable-to. Rules and seams:
|
|||
center-to-center, between ANY two objects — planets, stations, the home
|
||||
world) and `maxNeighbor` (10240 px — whenever a system holds more than
|
||||
one object, every object is within it of at least one other; the inner
|
||||
orbit's near neighbor is the home world at the origin). ≤ 10 objects
|
||||
share one orbit; the 11-object maximum (9 planets + 2 stations) splits
|
||||
into a 3-ring + 8-ring. Planet-bound settlements (colonies, mining
|
||||
stations, cloud bases) are features of their planet, not layout objects.
|
||||
orbit's near neighbor is the home world at the origin). The current data
|
||||
max is 6 objects (4 planets + 2 stations), which always shares one
|
||||
orbit; the 11-object two-orbit case (3-ring + 8-ring) is kept as a
|
||||
defensive fallback for any future data that could produce it.
|
||||
Planet-bound settlements (colonies, mining stations, cloud bases) are
|
||||
features of their planet, not layout objects.
|
||||
Draws come from the dedicated fork `Rng.derive(seed, 'system', id,
|
||||
'layout')` — layout results are seed-deterministic *and* don't perturb
|
||||
the content stream (lazy === eager is preserved). Class sizes/tints in
|
||||
|
|
@ -248,11 +265,11 @@ The ship starts with **one level-1 tether** anchored on the home world
|
|||
visual/feel parameter.
|
||||
|
||||
**Upgrade economics (planned):** radius(level) = 5120 × 2^(level−1) —
|
||||
level 2 = 10240 px, which reaches every object of a ≤ 10-object system
|
||||
(all within 10240 px of the home world); the far 8-ring of the rare
|
||||
11-object system (up to ~13380 px out) fits inside level 3 (20480 px). Extra
|
||||
tethers anchored on planets/stations will be the "expand your reach" verb;
|
||||
the seams are in place.
|
||||
level 2 = 10240 px, which reaches every object of the current 6-object
|
||||
maximum (all within 10240 px of the home world); the defensive two-orbit
|
||||
fallback (11 objects, up to ~13380 px out) would fit inside level 3
|
||||
(20480 px). Extra tethers anchored on planets/stations will be the
|
||||
"expand your reach" verb; the seams are in place.
|
||||
|
||||
**Phaser v4 note:** the barrier is two `Graphics` layers redrawn per frame
|
||||
(`clear()` → one `strokePath` per pass) — no textures, no physics bodies,
|
||||
|
|
|
|||
|
|
@ -20,18 +20,27 @@ const DEG = Math.PI / 180;
|
|||
*
|
||||
* What the system contains is steered by the TYPE'S ATTRIBUTES in
|
||||
* data/systems.json (`types.<id>.attributes`): star classes, binary
|
||||
* chance, planet count spread, planet class weights, moon/belt chances,
|
||||
* habitability, hazard, and — the lived-in layer — `settlements` (chance
|
||||
* + required planet class per settlement kind). Settlement kinds and their
|
||||
* population ranges live in data/settlements.json; the core→rim density
|
||||
* gradient in data/galaxy.json (`settlements.gradient`).
|
||||
* chance, planet class weights, moon/belt chances, habitability, hazard,
|
||||
* and — the lived-in layer — `settlements` (per-type odds for the
|
||||
* free-space kinds). Planet COUNT is a global rule (data/systems.json →
|
||||
* `planetCount`): noneChance of systems are barren, the rest hold 2–4
|
||||
* worlds. Settlement kinds and their population ranges live in
|
||||
* data/settlements.json; the core→rim density gradient in data/galaxy.json
|
||||
* (`settlements.gradient`).
|
||||
*
|
||||
* The galaxy is ALREADY LIVED IN: it was settled long before the player.
|
||||
* Colonies on habitable worlds, mining stations over resource worlds,
|
||||
* cloud bases riding gas giants, stations adrift in open space — and a
|
||||
* fair number of charted-but-unclaimed systems. Nothing here is hostile
|
||||
* yet: `owner` on every settlement is a reserved seam for the factions
|
||||
* and pirates we'll introduce later.
|
||||
* EVERY planet hosts a settlement (for now — data/settlements.json →
|
||||
* allPlanetsSettled + settledKindByClass): colonies on habitable worlds,
|
||||
* mining stations over the rest, cloud bases riding gas giants. The
|
||||
* charted-but-unclaimed systems are the barren ones that also roll no
|
||||
* free-space station. 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
|
||||
* holds exactly two more planets — a gas giant and a rocky world. With the
|
||||
* home world, three planets, always.
|
||||
*
|
||||
* Place identity (the reputation/trading/faction keys): every planet and
|
||||
* settlement carries a stable `id`, seed-deterministic because it is
|
||||
|
|
@ -71,10 +80,17 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
|||
}
|
||||
|
||||
// --- Planets ----------------------------------------------------------
|
||||
const pc = attr.planetCount ?? { min: 3, max: 8, mean: 5 };
|
||||
const count = Math.round(
|
||||
clamp(pc.mean + (rng.next() - 0.5) * (pc.max - pc.min), pc.min, pc.max),
|
||||
);
|
||||
// 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.
|
||||
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 };
|
||||
// Planet names come from a curated bank (data/naming.json → banks.planet),
|
||||
// dealt out per-system without repeats (see NameGenerator.planetDeck).
|
||||
|
|
@ -86,11 +102,12 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
|||
// not one of the generated planets. It takes the first name off that
|
||||
// system's deck so it can never clash with a planet; the planets then draw
|
||||
// from the rest of the deck (all distinct within the system).
|
||||
const isHome = record.id === galaxy?.currentSystemId;
|
||||
const homeName = isHome ? planetDeck[0] : null;
|
||||
const planets = [];
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const pclass = rng.weighted(classWeights, 'rocky');
|
||||
// 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');
|
||||
let moons = 0;
|
||||
if (rng.chance(attr.moonChance ?? 0.3)) {
|
||||
// Jovian/ice worlds drag moon systems; terrestrials mostly don't.
|
||||
|
|
@ -178,9 +195,11 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
|
|||
* - every object's nearest neighbor is then the home world, at
|
||||
* R ≤ maxNeighbor.
|
||||
* That radius range is non-empty for k ≤ 10 (k = 11 would need
|
||||
* R ≥ 10905, which already strands the home world beyond maxNeighbor). So
|
||||
* the 11-object maximum (9 planets + 2 stations — the most the current
|
||||
* data can produce) gets two orbits: an inner 3-ring and an outer 8-ring.
|
||||
* R ≥ 10905, which already strands the home world beyond maxNeighbor).
|
||||
* The current data's maximum is 6 objects (4 planets + 2 free-space
|
||||
* stations), which always fits a single orbit; the 11-object case is kept
|
||||
* as a defensive fallback (an inner 3-ring and an outer 8-ring) for any
|
||||
* future data that could produce it.
|
||||
* The outer ring's near neighbor is its ring-mate (edge stays in
|
||||
* [minSpacing, maxNeighbor]); the inner ring keeps the home world within
|
||||
* maxNeighbor; and any inner/outer pair is at least R_outer − R_inner ≥
|
||||
|
|
@ -593,11 +612,19 @@ function settlementDensity(galaxy, record) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Draw settlements for one system. Stable draw order: planet-bound kinds in
|
||||
* orbital order (colony, mining, cloud), then free-floating (deep-space
|
||||
* station, waypoint). Every roll goes through the system's own stream.
|
||||
* Each settlement gets a stable `id` (`<systemId>-s<n>`, n = its position
|
||||
* in this order) — the reputation/factions key.
|
||||
* Draw settlements for one system. Stable draw order: planet-bound
|
||||
* settlements in orbital order, then free-floating (deep-space station,
|
||||
* waypoint). Every roll goes through the system's own stream. Each
|
||||
* settlement gets a stable `id` (`<systemId>-s<n>`, n = its position in
|
||||
* this order) — the reputation/factions key.
|
||||
*
|
||||
* The planet-bound layer is a RULE, not a roll (for now): the galaxy is
|
||||
* fully settled, so EVERY planet hosts the one kind that fits its class
|
||||
* (data/settlements.json → allPlanetsSettled + settledKindByClass — a
|
||||
* habitable rocky world earns a colony, every other world gets its mining
|
||||
* outfit, gas giants ride cloud bases). Flip allPlanetsSettled off and
|
||||
* the old per-type odds (spec.chance + needs) take over again. The
|
||||
* free-space kinds still roll per type (core→rim scaled).
|
||||
*/
|
||||
function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDeck, density }) {
|
||||
const out = [];
|
||||
|
|
@ -616,21 +643,31 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe
|
|||
};
|
||||
|
||||
// Planet-bound, in orbital order.
|
||||
for (const p of planets) {
|
||||
if (p.habitable && roll(rng, spec.colony?.chance ?? 0.3, density)) {
|
||||
make('colony', { type: 'planet', ordinal: p.ordinal });
|
||||
if (config.get('settlements.allPlanetsSettled', true) === true) {
|
||||
// Fully settled: every world hosts the kind that fits its class.
|
||||
const byClass = config.get('settlements.settledKindByClass', {});
|
||||
for (const p of planets) {
|
||||
const kind = settledKindFor(p, byClass, kindDefs);
|
||||
if (kind) make(kind, { type: 'planet', ordinal: p.ordinal });
|
||||
}
|
||||
if (needs(p, spec.miningStation?.needs, ['rocky', 'lava', 'ice']) &&
|
||||
roll(rng, spec.miningStation?.chance ?? 0.2, density)) {
|
||||
make('miningStation', { type: 'planet', ordinal: p.ordinal });
|
||||
}
|
||||
if (needs(p, spec.cloudBase?.needs, ['gas']) &&
|
||||
roll(rng, spec.cloudBase?.chance ?? 0.15, density)) {
|
||||
make('cloudBase', { type: 'planet', ordinal: p.ordinal });
|
||||
} else {
|
||||
// The old probabilistic layer (per-type chances + class needs).
|
||||
for (const p of planets) {
|
||||
if (p.habitable && roll(rng, spec.colony?.chance ?? 0.3, density)) {
|
||||
make('colony', { type: 'planet', ordinal: p.ordinal });
|
||||
}
|
||||
if (needs(p, spec.miningStation?.needs, ['rocky', 'lava', 'ice']) &&
|
||||
roll(rng, spec.miningStation?.chance ?? 0.2, density)) {
|
||||
make('miningStation', { type: 'planet', ordinal: p.ordinal });
|
||||
}
|
||||
if (needs(p, spec.cloudBase?.needs, ['gas']) &&
|
||||
roll(rng, spec.cloudBase?.chance ?? 0.15, density)) {
|
||||
make('cloudBase', { type: 'planet', ordinal: p.ordinal });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Free-floating, out in the dark.
|
||||
// Free-floating, out in the dark (per-type odds, core→rim scaled).
|
||||
if (roll(rng, spec.deepSpaceStation?.chance ?? 0.12, density)) {
|
||||
make('deepSpaceStation', { type: 'space' });
|
||||
}
|
||||
|
|
@ -641,6 +678,16 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe
|
|||
return out;
|
||||
}
|
||||
|
||||
/** The settled kind a planet hosts (settledKindByClass entry → kind id). */
|
||||
function settledKindFor(planet, byClass, kindDefs) {
|
||||
const entry = byClass[planet.class];
|
||||
if (!entry) return null;
|
||||
const kind = typeof entry === 'string'
|
||||
? entry
|
||||
: (planet.habitable ? (entry.habitable ?? entry.default) : entry.default);
|
||||
return kind && kindDefs[kind] ? kind : null;
|
||||
}
|
||||
|
||||
/** One deterministic roll, scaled by the core→rim density factor. */
|
||||
function roll(rng, chance, density) {
|
||||
return rng.chance(chance * density);
|
||||
|
|
|
|||
Loading…
Reference in New Issue