From 5e4f94f385b1c0af0f7811ee03d6dd6e2a784552 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Tue, 8 Sep 2026 14:28:24 -0600 Subject: [PATCH] Rework galaxy layout into square Poisson-disk field with zone-based type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the sparse disk (radius, flatten, spiral arms, per-type radiusBand) with a seeded square domain placed by Bridson Poisson-disk sampling for even, organic star spacing (no clumps/voids). - Add a home→far difficulty axis: each record now carries `d` (0 at the home corner, 1 at the opposite corner) and a named zone; type distribution is mixed per zone via `distribution.zoneMix` instead of radial bands. - Move the starting system to the star nearest the configured corner (`startingSystem.policy: "corner"`, default SE / lower right), with `center` and `random` policies still supported. - Increase default system count from 60 to 90 and retune settlement density gradient, landing video slots, and test expectations to the new layout. --- README.md | 14 +- data/galaxy.json | 28 ++-- data/landing.json | 2 +- data/systems.json | 14 +- dev/galaxy.test.mjs | 137 ++++++++++++----- dev/jumps.test.mjs | 16 ++ dev/station-frames.test.mjs | 55 ++++--- docs/PROJECT_NOTES.md | 51 ++++-- js/galaxy/Galaxy.js | 290 ++++++++++++++++++++++++----------- js/galaxy/SystemGenerator.js | 27 ++-- 10 files changed, 429 insertions(+), 205 deletions(-) diff --git a/README.md b/README.md index d504710..9cab7fe 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,13 @@ 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**: 60 star systems in a sparse seeded disk — no - spiral arms, no core bulge, uniform-in-area (`data/galaxy.json`), typed - into six themed archetypes (`data/systems.json`) with per-type - distribution weights and radial bands — the first "how does the galaxy - lay itself out" rules. +- **Procedural galaxy**: 90 star systems in a seeded square field — + even, organic spacing (Poisson disk, `data/galaxy.json`), the player's + home in the lower-right corner, and a home→far difficulty axis split + into near/middle/far zones — typed into six themed archetypes + (`data/systems.json`) with per-type weights and per-zone mixing + (`galaxy.distribution.zoneMix`) — the galaxy lays itself out on a + progression the trade/combat economy can lean on. - **Two-level generation**: the whole galaxy roster is generated at New Game (~15 ms); each system's planets/moons/belts/settlements are generated lazily on arrival, deterministically (seed + system id), so @@ -186,7 +188,7 @@ orbit/ │ ├── planets.json # home world + system layout + solid-disc rules │ ├── map.json # the MAP console: tabs, the system chart, the GALAXY tab (stars/pulse/lanes/region), stats, zoom │ ├── tether.json # the tether (your range): level radii, barrier line, glitch, contact -│ ├── galaxy.json # galaxy scale & shape (count, radius, sparse disk…) +│ ├── galaxy.json # galaxy scale & shape (count, square field, zones, corner home…) │ ├── systems.json # system archetypes: theme, attributes, distribution │ ├── settlements.json # the lived-in layer: settlement kinds & populations │ ├── gates.json # JUMP GATES: network (1–3 gates, local jumps, pure spanning tree — maze, no shortcuts) + placement (tether anchor, facing, radii, gaps) diff --git a/data/galaxy.json b/data/galaxy.json index ff485dd..e87f6a7 100644 --- a/data/galaxy.json +++ b/data/galaxy.json @@ -1,18 +1,26 @@ { - "_comment": "Galaxy shape + scale (js/galaxy/Galaxy.js). systemCount = total star systems (the starting system is one of them). layout = a sparse seeded DISK: no spiral arms (spiral.enabled = false, arms = 0 — the arm snap is skipped), no center bulge (coreFraction = 0), diskSkew = 0.5 so the radial spread is uniform-in-area (rNorm = √x — stars spread out sparsely across the disk instead of crowding the core), flatten = y squash (ellipse). settlements.gradient = free-space settlement density by galactic radius (rNorm 0 = center, 1 = rim): chance = 1 − rNorm×falloff, floored at floor (js/galaxy/SystemGenerator.js → settlementDensity).", - "systemCount": 60, - "radius": 20000, + "_comment": "Galaxy shape + scale (js/galaxy/Galaxy.js). systemCount = total star systems (the starting system is one of them). layout.square = the galaxy is a seeded SQUARE field of stars (center at the world origin, ±side/2 in x and y): Bridson Poisson-disk sampling keeps every pair of systems at least minSpacing·√(side²/systemCount) apart — an even, organic field (no clumps, no voids, not a grid). startingSystem: policy 'corner' puts the player's home in the star NEAREST the configured corner (corner: NE/NW/SE/SW, screen orientation — SE = lower right); 'center' (nearest the origin) and 'random' still work. distribution.zones slices the HOME→FAR diagonal into named zones (d: 0 at the home corner, 1 at the opposite corner); distribution.zoneMix multiplies each type's distribution.weight PER ZONE (the old per-type radiusBand is gone — type flavor is regional now). settlements.gradient = free-space settlement density by diagonal position (d 0 = home corner, 1 = far corner): chance = 1 − d×falloff, floored at floor (js/galaxy/SystemGenerator.js → settlementDensity).", + "systemCount": 90, "layout": { - "coreFraction": 0, - "bulgeSigma": 0.09, - "diskSkew": 0.5, - "flatten": 0.62, - "spiral": { "enabled": false, "arms": 0, "twist": 2.6, "strength": 0.5 } + "square": { + "side": 32000, + "minSpacing": 0.8 + } }, "distribution": { - "rules": [] + "_comment": "zones slice the HOME→FAR diagonal (d) into the three regions. The diagonal bands have UNEVEN area (corner triangles vs the middle band), so the edges are set for EQUAL-AREA thirds: area(d < a) = 2a² of the square for a ≤ ½ — a = 0.40 and 0.60 give ≈ 32/36/32. zoneMix multiplies each type's global distribution.weight per zone (missing type = ×1).", + "zones": [ + { "name": "near", "d": [0, 0.4] }, + { "name": "middle", "d": [0.4, 0.6] }, + { "name": "far", "d": [0.6, 1] } + ], + "zoneMix": { + "near": { "main": 1.0, "redDwarf": 1.25, "binary": 0.8, "habitable": 1.5, "nebula": 0.4, "void": 0.3 }, + "middle": { "main": 1.15, "redDwarf": 1.0, "binary": 1.0, "habitable": 0.85, "nebula": 0.9, "void": 0.75 }, + "far": { "main": 0.85, "redDwarf": 0.55, "binary": 1.1, "habitable": 0.25, "nebula": 1.8, "void": 2.6 } + } }, - "startingSystem": { "policy": "center" }, + "startingSystem": { "policy": "corner", "corner": "SE" }, "settlements": { "gradient": { "falloff": 0.7, "floor": 0.22 } }, diff --git a/data/landing.json b/data/landing.json index 10ad8ac..b3b2be0 100644 --- a/data/landing.json +++ b/data/landing.json @@ -67,7 +67,7 @@ "stationVideos": [ { "land": "ss-land-01.mp4", "surface": "ss-surface-01.mp4", "takeoff": "ss-takeoff-01.mp4", "shop": null }, - { "land": "ss-land-02.mp4", "surface": null, "takeoff": "ss-takeoff-02.mp4", "shop": null }, + { "land": "ss-land-02.mp4", "surface": "ss-surface-02.mp4", "takeoff": "ss-takeoff-02.mp4", "shop": null }, { "land": "ss-land-03.mp4", "surface": "ss-surface-03.mp4", "takeoff": "ss-takeoff-03.mp4", "shop": null } ] } diff --git a/data/systems.json b/data/systems.json index 5c55ac3..b24c2a4 100644 --- a/data/systems.json +++ b/data/systems.json @@ -1,5 +1,5 @@ { - "_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet class weights, moon/belt chances, habitability, hazard, and free-space settlement odds (settlements: deepSpaceStation / waypoint). objectCount is a GLOBAL rule, not per-type: the starting system is exempt (it always holds exactly two generated planets — gas giant + rocky — beside the home world, plus at most one free-space station). Every other system rolls its TOTAL object count — planets + free-space stations together — from this table: `barren` of systems hold ZERO objects (a gate-only dead-end LEAF of the jump network — the center is empty, the star is invisible flavor, and the only things there are the single gate and 1–2 asteroid clusters drifting inside its tether, data/asteroids.json → barren), the rest hold `objects` counts. The split is resolved by rolling the free-space stations first (per-type odds in attributes.settlements × the core→rim gradient, at most two — deepSpaceStation + waypoint), then planets = N − stations; if that roll would leave the system with NO planet (stations = N), one station is demoted to a planet — every non-barren system keeps ≥ 1 planet, the gate's tether anchor and the world the player can build out from. Every planet is settled by rule (data/settlements.json → allPlanetsSettled + settledKindByClass). distribution.weight sets how common the type is; distribution.radiusBand ([inner, outer] as a fraction of galaxy radius) is the first proximity rule — richer distribution rules slot into galaxy.json `distribution.rules` later.", + "_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet class weights, moon/belt chances, habitability, hazard, and free-space settlement odds (settlements: deepSpaceStation / waypoint). objectCount is a GLOBAL rule, not per-type: the starting system is exempt (it always holds exactly two generated planets — gas giant + rocky — beside the home world, plus at most one free-space station). Every other system rolls its TOTAL object count — planets + free-space stations together — from this table: `barren` of systems hold ZERO objects (a gate-only dead-end LEAF of the jump network — the center is empty, the star is invisible flavor, and the only things there are the single gate and 1–2 asteroid clusters drifting inside its tether, data/asteroids.json → barren), the rest hold `objects` counts. The split is resolved by rolling the free-space stations first (per-type odds in attributes.settlements × the home→far density gradient, at most two — deepSpaceStation + waypoint), then planets = N − stations; if that roll would leave the system with NO planet (stations = N), one station is demoted to a planet — every non-barren system keeps ≥ 1 planet, the gate's tether anchor and the world the player can build out from. Every planet is settled by rule (data/settlements.json → allPlanetsSettled + settledKindByClass). distribution.weight sets how common the type is GLOBALLY; which types favor WHICH REGION of the galaxy (the home corner, the middle, the far/deep corner) is the per-zone mix in data/galaxy.json → distribution.zoneMix, keyed off each system's home→far diagonal zone (record.zone).", "objectCount": { "barren": 0.10, "objects": { "2": 0.15, "3": 0.30, "4": 0.30, "5": 0.15 } @@ -9,7 +9,7 @@ "label": "Main Sequence", "description": "An ordinary star and its worlds — the galaxy's working majority.", "theme": { "color": "#9fb4e8" }, - "distribution": { "weight": 34, "radiusBand": null }, + "distribution": { "weight": 34 }, "attributes": { "star": { "classes": { "G": 30, "K": 40, "M": 30 }, @@ -31,7 +31,7 @@ "label": "Red Dwarf", "description": "A small, long-lived M star with close-in, moon-rich worlds.", "theme": { "color": "#e8927c" }, - "distribution": { "weight": 26, "radiusBand": [0.0, 0.6] }, + "distribution": { "weight": 26 }, "attributes": { "star": { "classes": { "M": 85, "K": 15 }, @@ -53,7 +53,7 @@ "label": "Binary", "description": "Two stars, one system. Tangled orbits, wide spacings, rich debris.", "theme": { "color": "#c9a7ff" }, - "distribution": { "weight": 10, "radiusBand": [0.15, 0.95] }, + "distribution": { "weight": 10 }, "attributes": { "star": { "classes": { "F": 25, "G": 40, "K": 35 }, @@ -75,7 +75,7 @@ "label": "Habitable", "description": "Temperate, well-lit, and quietly crowded with life. Rare.", "theme": { "color": "#7ce8a4" }, - "distribution": { "weight": 10, "radiusBand": [0.2, 0.75] }, + "distribution": { "weight": 10 }, "attributes": { "star": { "classes": { "G": 70, "K": 30 }, @@ -97,7 +97,7 @@ "label": "Nebula", "description": "Young, bright, and still messy — debris where planets should be.", "theme": { "color": "#5fd4d0" }, - "distribution": { "weight": 12, "radiusBand": [0.4, 1.0] }, + "distribution": { "weight": 12 }, "attributes": { "star": { "classes": { "A": 20, "F": 30, "G": 50 }, @@ -119,7 +119,7 @@ "label": "Void", "description": "Old, cold, and mostly empty. The rim's quiet dead ends.", "theme": { "color": "#7d88a8" }, - "distribution": { "weight": 8, "radiusBand": [0.7, 1.0] }, + "distribution": { "weight": 8 }, "attributes": { "star": { "classes": { "M": 90, "K": 10 }, diff --git a/dev/galaxy.test.mjs b/dev/galaxy.test.mjs index fe6d4ef..126a79e 100644 --- a/dev/galaxy.test.mjs +++ b/dev/galaxy.test.mjs @@ -8,8 +8,9 @@ * contract the whole game rests on: * - same seed ⇒ identical roster (ids, names, types, positions); * - different seed ⇒ different galaxy; - * - type distribution matches the weights in data/systems.json; - * - type radius bands (the first proximity rule) are respected; + * - type distribution matches the weights × per-zone mix (zoneMix, ±4σ); + * - square domain + even field (Poisson disk), every record carries d + zone; + * - starting system = the star nearest the configured home corner; * - 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 @@ -18,7 +19,7 @@ * - 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 60-system galaxy. + * Also reports generation timing for the 90-system galaxy. */ import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'node:url'; @@ -108,7 +109,8 @@ let big; const tCreate = performance.now() - t0; const n = config.get('galaxy.systemCount', 0); - check(`roster size = galaxy.systemCount (${n})`, big.records.length === n); + const nSys = big.records.length; + check(`roster size = galaxy.systemCount (${n})`, nSys === n); check('ids are stable & unique', new Set(big.records.map((r) => r.id)).size === n); check('current system is part of the roster', big.byId.has(big.currentSystemId)); @@ -120,35 +122,80 @@ let big; const other = Galaxy.create('totally-different'); check('different seed ⇒ different galaxy', !deepEq(big.records, other.records)); - // Type distribution vs configured weights. - const counts = {}; - for (const r of big.records) counts[r.type] = (counts[r.type] ?? 0) + 1; - const nSys = big.records.length; + // Type distribution vs configured weights — PER ZONE (the region layer: + // distribution.zoneMix in data/galaxy.json multiplies each type's global + // distribution.weight in data/systems.json; the old per-type radiusBand + // is gone — type flavor is regional now). + const zoneCfg = big.params.distribution?.zones ?? []; + const zoneMix = big.params.distribution?.zoneMix ?? {}; + const baseW = Object.fromEntries(typeIds.map((id) => [id, Math.max(0, types[id].distribution?.weight ?? 1)])); + const zoneRecs = {}; + for (const r of big.records) (zoneRecs[r.zone] ??= []).push(r); let distOk = true; - for (const id of typeIds) { - const obs = (counts[id] ?? 0) / nSys; - const sd = Math.sqrt((expected[id] * (1 - expected[id])) / nSys); - if (Math.abs(obs - expected[id]) > 4 * sd + 0.004) { - distOk = false; - console.log(` type ${id}: observed ${(obs * 100).toFixed(1)}% vs expected ${(expected[id] * 100).toFixed(1)}%`); + for (const z of zoneCfg) { + const zrecs = zoneRecs[z.name] ?? []; + const nZ = zrecs.length; + if (nZ < 3) continue; // too few to be meaningful + const zw = {}; + let tot = 0; + for (const id of typeIds) { + const mul = zoneMix[z.name]?.[id]; + zw[id] = baseW[id] * (mul === undefined ? 1 : Math.max(0, mul)); + tot += zw[id]; + } + const counts = {}; + for (const r of zrecs) counts[r.type] = (counts[r.type] ?? 0) + 1; + for (const id of typeIds) { + const p = zw[id] / tot; + if (p <= 0) continue; + const obs = (counts[id] ?? 0) / nZ; + const sd = Math.sqrt(p * (1 - p) / nZ); + if (Math.abs(obs - p) > 4 * sd + 0.004) { + distOk = false; + console.log(` zone ${z.name} type ${id}: observed ${(obs * 100).toFixed(1)}% vs expected ${(p * 100).toFixed(1)}%`); + } } } - check('type distribution matches configured weights (±4σ)', distOk); + check('type distribution matches configured weights × zoneMix per zone (±4σ)', distOk); - // Radius bands (the first "proximity" rule). - const R = Math.max(1, big.params.radius ?? 20000); - const flatten = big.params.layout?.flatten ?? 0.62; - let bandOk = true; - for (const r of big.records) { - const band = types[r.type].distribution?.radiusBand; - if (!Array.isArray(band) || band.length !== 2) continue; - const rNorm = Math.sqrt(r.x * r.x + (r.y / flatten) ** 2) / R; - if (rNorm < band[0] - 1e-9 || rNorm > band[1] + 1e-9) { - bandOk = false; - break; - } - } - check('radius bands (proximity rule) respected by every system', bandOk); + // SQUARE DOMAIN + EVEN FIELD + DIFFICULTY COORDINATE (the layout contract). + const side = Math.max(2, Math.floor(Number(big.params.layout?.square?.side) || 32000)); + const half = side / 2; + check( + 'every system sits inside the square domain (±side/2)', + big.records.every((r) => Math.abs(r.x) <= half + 1e-9 && Math.abs(r.y) <= half + 1e-9), + ); + let minPair = Infinity; + for (let i = 0; i < big.records.length; i++) + for (let j = i + 1; j < big.records.length; j++) + minPair = Math.min(minPair, Math.hypot(big.records[i].x - big.records[j].x, big.records[i].y - big.records[j].y)); + const spacingFloor = 0.7 * Math.sqrt((side * side) / big.records.length); + check( + `even field (Poisson disk): min pair distance ${Math.round(minPair)} px ≥ ${Math.round(spacingFloor)} px — no clumps, no voids`, + minPair >= spacingFloor - 1e-6, + ); + check( + 'every record carries d in [0,1] + a zone name', + big.records.every((r) => Number.isFinite(r.d) && r.d >= 0 && r.d <= 1 && typeof r.zone === 'string'), + ); + check( + 'record.zone matches its d against the configured zones', + big.records.every((r) => { + const z = zoneCfg.find((zz) => r.d >= zz.d[0] && r.d < zz.d[1]) ?? zoneCfg[zoneCfg.length - 1]; + return z?.name === r.zone; + }), + ); + // CORNER HOME (startingSystem.policy 'corner', corner SE — lower right, + // screen y-down): the starting system is the star NEAREST the home corner. + const homeCorner = { x: half, y: half }; + const nearestToCorner = big.records + .slice() + .sort((a, b) => (a.x - homeCorner.x) ** 2 + (a.y - homeCorner.y) ** 2 - ((b.x - homeCorner.x) ** 2 + (b.y - homeCorner.y) ** 2))[0].id; + check('corner policy: the starting system is the star nearest the home (SE) corner', big.currentSystemId === nearestToCorner); + check( + 'corner policy: the home system sits in the NEAR zone (d ≈ 0 at the home corner)', + big.currentSystem().zone === zoneCfg[0]?.name, + ); // Lazy contents: the global OBJECT-COMPOSITION rule across the WHOLE // galaxy (data/systems.json → objectCount: 0/2/3/4/5 objects, ≈ 10% @@ -178,7 +225,7 @@ let big; ); check(`lazy content generation over all ${nSys} systems`, big.generatedCount === nSys); // (The barren-share check below uses the same ±4σ band as the other - // composition checks — at 60 systems the count is small, so an absolute + // composition checks — at 90 systems the count is small, so an absolute // 0.08 band was tighter than the sampling noise.) // Lazy === eager: fresh galaxy (unopened) vs fully generated one. @@ -230,14 +277,23 @@ let big; const wantP = bruteNearest(point.x, point.y, 3).sort(); check('nearest(point, k) matches brute force', deepEq(gotP, wantP)); - const centerPolicy = Galaxy.create('center-policy', { systemCount: 250, startingSystem: { policy: 'random' } }); - check('random starting policy picks a roster member', centerPolicy.byId.has(centerPolicy.currentSystemId)); + const randomPolicy = Galaxy.create('random-policy', { systemCount: 250, startingSystem: { policy: 'random' } }); + check('random starting policy picks a roster member', randomPolicy.byId.has(randomPolicy.currentSystemId)); - const center = Galaxy.create('center-policy', { systemCount: 250 }); + const center = Galaxy.create('center-policy', { systemCount: 250, startingSystem: { policy: 'center' } }); const centerRecs = center.records; const trueCenter = centerRecs.slice().sort((a, b) => (a.x ** 2 + a.y ** 2) - (b.x ** 2 + b.y ** 2))[0].id; const gridNearest = center.nearest(0, 0, 1)[0].id; check('center starting policy picks the record nearest the origin', center.currentSystem().id === trueCenter && gridNearest === trueCenter); + + // The CORNER policy honors whichever corner is configured (here: NW — + // upper left, screen y-down), not just the default SE. + const nw = Galaxy.create('nw-policy', { systemCount: 250, startingSystem: { policy: 'corner', corner: 'NW' } }); + const halfNW = Math.max(2, Math.floor(Number(nw.params.layout?.square?.side) || 32000)) / 2; + const trueNW = nw.records + .slice() + .sort((a, b) => (a.x + halfNW) ** 2 + (a.y + halfNW) ** 2 - ((b.x + halfNW) ** 2 + (b.y + halfNW) ** 2))[0].id; + check('corner policy honors the configured corner (NW)', nw.currentSystemId === trueNW); } // ---------------------------------------------------------------------- @@ -344,20 +400,21 @@ let big; }), ); - // 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. + // Home→far gradient: the free-space STATION odds scale with the home→far + // density (data/galaxy.json → settlements.gradient, keyed on d: 0 at the + // home corner, 1 at the far corner) — 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 deep corner. const withCount = sample.map((r) => ({ - rNorm: r.rNorm, + d: r.d, n: (big2.ensureContent(r.id).settlements ?? []).filter((s) => s.anchor?.type === 'space').length, })); - withCount.sort((a, b) => a.rNorm - b.rNorm); + withCount.sort((a, b) => a.d - b.d); 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 station gradient (inner ${avg(inner).toFixed(2)}/system > outer ${avg(outer).toFixed(2)}/system)`, avg(inner) > avg(outer)); + check(`home→far station gradient (near ${avg(inner).toFixed(2)}/system > far ${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 diff --git a/dev/jumps.test.mjs b/dev/jumps.test.mjs index 2dbcce8..4eab8c3 100644 --- a/dev/jumps.test.mjs +++ b/dev/jumps.test.mjs @@ -142,6 +142,22 @@ const norm = (a) => ((a % (2 * Math.PI)) + 3 * Math.PI) % (2 * Math.PI) - Math.P } check('reachable from home: every system (no forward dead ends)', fwd.size === g.records.length, `${fwd.size}/${g.records.length}`); + // HOP SCALE (the trade-economy property): with the even star field, + // hop counts track MAP distance — the journey to the far corner is a + // normal trip (a fraction of the roster), not a winding labyrinth. + const depth = new Map([[HOME, 0]]); + const qd = [HOME]; + while (qd.length) { + const u = qd.pop(); + for (const t of g.jumpGatesFor(u)) if (!depth.has(t.id)) depth.set(t.id, depth.get(u) + 1), qd.push(t.id); + } + const maxHops = Math.max(...depth.values()); + check( + `hop scale: the farthest system is ${maxHops} hops from home (hops ≈ map distance)`, + maxHops < g.records.length / 2, + `max ${maxHops} of ${g.records.length} systems`, + ); + // Strong connectivity, backward: every system can reach home // (no closed systems, no trapped sets). const radj = new Map(g.records.map((r) => [r.id, []])); diff --git a/dev/station-frames.test.mjs b/dev/station-frames.test.mjs index dd1d6d5..8b1672d 100644 --- a/dev/station-frames.test.mjs +++ b/dev/station-frames.test.mjs @@ -84,37 +84,42 @@ console.log(` (galaxy: ${g.records.length} systems, ${stations.size} deep-space // 2. The pass beats a naive random assignment // ---------------------------------------------------------------------- { - // The pass's variant collision rate: for each station, count its + // The pass's variant collision count: for each station, count its // POOL-nearest stars whose station wears the SAME frame (directed). - const collisionRate = (frameOf) => { - let coll = 0; - let checks = 0; - for (const [id, st] of stations) { - for (const nb of g.neighborsOf(id, POOL)) { + // Aggregated over THREE seeds — a single galaxy is sparse in + // station adjacency (most stars carry no station), and the naive + // random baseline can be 0 in any given seed; summed, it is a + // stable floor the pass must beat. + const SEEDS = [SEED, 'sf-seed-b', 'sf-seed-c']; + let passColl = 0; + let naiveColl = 0; + let checks = 0; + for (const sd of SEEDS) { + const gg = Galaxy.create(sd); + const sts = new Map(); // id → stamped frame + for (const rec of gg.records) + for (const s of gg.ensureContent(rec.id).settlements) + if (s.kind === 'deepSpaceStation') sts.set(rec.id, s.stationFrame); + // Naive: independent random pool picks (seeded, per station). + const naiveF = {}; + for (const id of sts.keys()) + naiveF[id] = POOLV.length === 1 + ? POOLV[0] + : POOLV[Math.floor(Rng.derive(sd, 'naive-station', id).next() * POOLV.length)]; + for (const [id, fr] of sts) { + for (const nb of gg.neighborsOf(id, POOL)) { checks++; - const nbFrame = frameOf(nb.id); - if (nbFrame !== null && nbFrame === st.frame) coll++; + const nfr = sts.get(nb.id); + if (nfr !== undefined && nfr === fr) passColl++; + const nnfr = naiveF[nb.id]; + if (nnfr !== undefined && nnfr === fr) naiveColl++; } } - return { coll, checks, rate: checks === 0 ? 0 : coll / checks }; - }; - - const passed = collisionRate((id) => stations.get(id)?.frame ?? null); - - // Naive: independent random pool picks (seeded, per station). - const naive = new Map(); - for (const id of stations.keys()) { - const f = POOLV.length === 1 - ? POOLV[0] - : POOLV[Math.floor(Rng.derive(SEED, 'naive-station', id).next() * POOLV.length)]; - naive.set(id, f); } - const na = collisionRate((id) => naive.get(id) ?? null); - check( - `variant 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} (station density sets the floor — most stars carry none)`, + `variant collisions vs the ${POOL}-nearest pool (3 seeds): pass ${passColl} < naive ${naiveColl}`, + passColl < naiveColl && naiveColl >= 1, + `${passColl}/${checks} vs ${naiveColl}/${checks} (station density sets the floor — most stars carry none)`, ); // The spread should also produce ALL variants across the galaxy diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 84b7567..aae22e2 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -81,9 +81,9 @@ 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. 60 systems is ~10 ms, so the whole - galaxy is always known: the player can never "discover" a layout that - wasn't already implied by the seed. + (id, name, type, x, y, d, zone) up front. 90 systems is ~10 ms, so the + whole galaxy is always known: the player can never "discover" a layout + that wasn't already implied by the seed. 2. **Contents** — planets/moons/belts/**settlements**/hazards are generated **lazily** on first arrival (`galaxy.ensureContent(id)`), then cached. Each system's draw stream is `Rng.derive(seed, 'system', id)` @@ -132,15 +132,24 @@ menu (displayed, editable, rerollable; same seed ⇒ same galaxy). 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, - faction borders, adjacency affinity) will slot into - `data/galaxy.json` → `distribution.rules[]`, read in - `Galaxy._generate()` — the hook is marked in code. +- **distributed** — a seeded SQUARE field of stars + (`galaxy.layout.square`: `side`, `minSpacing`) placed by Bridson + Poisson-disk sampling — even, organic spacing (no clumps, no voids, + not a grid). The player's home sits in the star NEAREST the configured + corner (`startingSystem: policy "corner"`, `corner: SE` = lower right; + `center`/`random` still work), and the HOME→FAR diagonal is the + progression axis: every record carries `d` (0 = home corner, 1 = far + corner) and its `zone` (`galaxy.distribution.zones`, equal-area thirds: + near/middle/far). Types mix PER ZONE — `galaxy.distribution.zoneMix` + multiplies each type's global `distribution.weight` per zone (the old + per-type `radiusBand` is gone: `void`/`nebula` favor the deep corner, + `habitable` the home corner). Richer galaxy-level rules (faction + territories, trade hubs, combat difficulty by zone) will read + `record.zone` / `record.d` — the seams are in place now. The player's **current system** starts at `galaxy.currentSystem()` -(`startingSystem.policy`: `center` or `random`). Jumping between systems +(`startingSystem.policy`: `corner` (default — the home corner, SE), +`center`, or `random`). Jumping between systems now has its NETWORK (see "Jump gates" below — `data/gates.json` + `js/galaxy/JumpNetwork.js`, built on `galaxy.neighborsOf(id)` and the spatial hash); the in-flight jump drive is the next mechanic on top. @@ -169,10 +178,12 @@ branch is now the normal case, not a defensive fallback. Model & seams: - **Per-type free-space rates** — `types..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 = - center, 1 = rim) so density is per-system, not global. +- **Home→far gradient** — `galaxy.settlements.gradient` in + `data/galaxy.json`: the settled heart (the home corner, `d = 0`) is + denser (factor 1.0), the deep corner (`d = 1`) is thinner (clamped to + `floor`). Each record carries `d` (0 = home corner, 1 = far corner) and + its `zone` so density — and later faction strength, hazard, and trade + value — is per-system, not global. - **Reserved seam: `owner`** — every settlement has `owner: null`. That's where **factions and pirates** will plug in later (claim, flag, relations). Deliberately absent for now — no factions yet. @@ -1265,9 +1276,15 @@ The player holds a REPUTATION (standing) on each planet and space station: capacities; upgrades (ship-category builds) will layer deltas on top - [ ] Ship screen (the Ship slot) — inspect & upgrade the ship (ship-category builds) from one place -- [ ] Richer galaxy distribution rules (`galaxy.distribution.rules[]`: - clustering by type, borders, adjacency affinity) — hook marked in - `Galaxy._generate()` +- [x] Galaxy regions: square field + home→far `d` coordinate + + near/middle/far zones + per-zone type mix (data/galaxy.json → + distribution.zones/zoneMix) — the region layer is live +- [ ] Factions: Voronoi territories around seeded capitals → each + system's `owner` (the reserved seam in reputation + the galaxy + plate), faction strength and hazard keyed off `zone`/`d`; the + middle zone (where the faction borders cross) = the contested space +- [ ] Trade: trade hubs on every planet/station, goods priced by zone + + jump distance (hops already ≈ map distance) - [ ] World model in play: the ship still flies unbounded open space; wire in current-system boundaries, jumps between systems (use `galaxy.neighborsOf`), and a star map scene diff --git a/js/galaxy/Galaxy.js b/js/galaxy/Galaxy.js index 0fde093..0d9f8cc 100644 --- a/js/galaxy/Galaxy.js +++ b/js/galaxy/Galaxy.js @@ -8,10 +8,6 @@ import { generateSystemContent, rollSystemComposition, settlementDensity } from const TAU = Math.PI * 2; const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); -const wrapPI = (a) => { - const t = (a + Math.PI) % TAU; - return (t < 0 ? t + TAU : t) - Math.PI; -}; /** * The Galaxy — an immense, procedurally generated collection of star @@ -20,8 +16,8 @@ const wrapPI = (a) => { * Two-level generation (the "grand scale" design): * * 1. GALAXY ROSTER — generated once, up front, when New Game is pressed: - * `systemCount` lightweight records ({ id, name, type, x, y }). - * This is cheap: the 60-system default is a few ms and a few hundred KB. + * `systemCount` lightweight records ({ id, name, type, x, y, d, zone }). + * This is cheap: the 90-system default is a few ms and a few hundred KB. * It fixes the shape of the galaxy, where every system sits, and * what KIND each one is — for the entire galaxy, from the seed alone. * @@ -32,16 +28,34 @@ const wrapPI = (a) => { * one — so this is a pure performance choice, never a correctness one. * `generateAll()` exists for exactly that, if it's ever "just as easy". * + * The SHAPE (data/galaxy.json): + * - a seeded SQUARE field of stars (layout.square.side, center at the + * world origin) placed by Bridson Poisson-disk sampling — an even, + * organic field: every pair of systems stays at least + * minSpacing·√(side²/N) apart, no clumps, no voids, not a grid; + * - the player's HOME system sits in the star nearest the configured + * corner (startingSystem: policy "corner", corner NE/NW/SE/SW, + * screen orientation — SE = lower right); "center" / "random" still + * work; the HOME→FAR diagonal is the galaxy's progression axis: + * each record carries `d` (0 at the home corner, 1 at the opposite + * corner) and its `zone` (near/middle/far, distribution.zones); + * - types mix PER ZONE (distribution.zoneMix × the type's global + * distribution.weight — the old per-type radiusBand is gone: type + * flavor is regional now), and free-space settlement density thins + * home→far (settlements.gradient, keyed on `d`). + * * Determinism contract: * same seed ⇒ same roster (positions, types, names), same contents, * in any order of generation. Dev/test tools rely on this. * * Extension points for later world rules (see docs/PROJECT_NOTES.md): - * - data/galaxy.json `distribution.rules[]` — proximity/clustering rules - * (e.g. "void systems cluster in the outer rim", faction borders). - * Read in _generate(); today only per-type weight + radiusBand - * (from data/systems.json) apply. - * - more layout knobs in data/galaxy.json `layout`. + * - FACTIONS: assign each system a faction id (or null = wild space) + * at roster time — the record is the seam (reputation's `owner` + * resolution and the galaxy plate's `faction: null` are reserved). + * - COMBAT / TRADE: read difficulty and price levels off `record.d` + * and `record.zone` (the middle zone is where the zone borders cross + * — the planned contested space + transit trade hubs). + * - more layout knobs in data/galaxy.json `layout.square`. */ export class Galaxy { constructor(seed, params, typeDefs) { @@ -94,79 +108,94 @@ export class Galaxy { // ------------------------------------------------------------------ _generate(count) { - const g = Rng.derive(this.seed, 'layout'); const L = this.params.layout ?? {}; - const R = Math.max(1, this.params.radius ?? 20000); - const flatten = clamp(L.flatten ?? 0.62, 0.05, 1); + const SQ = L.square ?? {}; + const S = Math.max(2, Math.floor(Number(SQ.side) || 32000)); + this.side = S; // the square domain's edge (world px, center at origin) + const half = S / 2; + const minSpacing = clamp(Number(SQ.minSpacing) || 0.8, 0.4, 1.2); - // Spiral arms (optional; `enabled: false` or strength 0 = off). - const spiral = L.spiral ?? {}; - const arms = spiral.enabled === true ? Math.max(0, Math.floor(spiral.arms ?? 0)) : 0; - const twist = spiral.twist ?? 2.5; - const strength = clamp(spiral.strength ?? 0.5, 0, 1); - const armPhase = g.next() * TAU; // one phase for the whole galaxy + // The HOME CORNER (data/galaxy.json → startingSystem.corner; screen + // orientation — y down, so SE = lower right). Two things key off it: + // the starting-system policy (home = the star NEAREST this corner), + // and the difficulty diagonal (d = 0 here, d = 1 at the opposite + // corner — the galaxy's progression axis, whatever policy picks the + // starting system). + const start = this.params.startingSystem ?? {}; + const corner = this._cornerPoint(start.corner ?? 'SE', half); + const opp = { x: -corner.x, y: -corner.y }; + const diag2 = (corner.x - opp.x) ** 2 + (corner.y - opp.y) ** 2; // 2·S² + const dOf = (x, y) => clamp( + ((corner.x - x) * (corner.x - opp.x) + (corner.y - y) * (corner.y - opp.y)) / diag2, + 0, 1, + ); - // Type selection: weights from data/systems.json (distribution.weight). - // FUTURE: galaxy.distribution.rules[] proximity/clustering rules hook in - // here, before position sampling. + // ZONES — slices of the home→far diagonal (distribution.zones), and + // the PER-ZONE type mix (distribution.zoneMix × each type's global + // distribution.weight). Regional flavor replaces the old per-type + // radiusBand: which KINDS of stars favor which region of the galaxy. + const zones = this._zones(); + const zoneOf = (d) => (zones.find((z) => d >= z.d[0] && d < z.d[1]) ?? zones[zones.length - 1]).name; + const mix = this.params.distribution?.zoneMix ?? {}; const typeIds = Object.keys(this.typeDefs); - const typeWeights = {}; - for (const id of typeIds) { - typeWeights[id] = Math.max(0, this.typeDefs[id].distribution?.weight ?? 1); - } + const weightsFor = (zone) => { + const m = mix[zone] ?? {}; + const w = {}; + for (const id of typeIds) { + const mul = m[id] === undefined ? 1 : Math.max(0, Number(m[id]) || 0); + w[id] = Math.max(0, this.typeDefs[id].distribution?.weight ?? 1) * mul; + } + return w; + }; + + const g = Rng.derive(this.seed, 'layout'); + + // EVEN PLACEMENT — a Bridson Poisson-disk (blue-noise) field: every + // pair of systems stays at least dmin apart (no clumps, no voids) + // while the field stays organic (not a grid). dmin is a fraction of + // the mean inter-star spacing √(side²/N) (layout.square.minSpacing). + // Even spacing is what keeps the jump network's hop counts + // proportional to map distance — the property the trade economy + // leans on ("a hop is a hop"). + const dmin = minSpacing * Math.sqrt((S * S) / Math.max(1, count)); + const points = this._poissonDisk(count, half, dmin); - let centerId = null; - let centerD2 = Infinity; const records = this.records; - for (let i = 1; i <= count; i++) { const id = `S${String(i).padStart(6, '0')}`; - const type = g.weighted(typeWeights, typeIds[0]); - - // Radius: a center-weighted shape sample, re-anchored into the - // type's radial band (the first "proximity" rule: e.g. void systems - // live out in the rim, habitable ones in the mid-galaxy). - const shape = this._sampleShape(g, L); // [0,1], dense toward center - const band = this.typeDefs[type]?.distribution?.radiusBand; - const rNorm = Array.isArray(band) && band.length === 2 - ? clamp(band[0] + (band[1] - band[0]) * shape, 0, 1) - : shape; - - let theta = g.next() * TAU; - if (arms >= 2) { - theta = this._snapToArm(theta, rNorm, armPhase, arms, twist, strength); - } - - const r = R * rNorm; - const x = r * Math.cos(theta); - const y = r * Math.sin(theta) * flatten; - const d2 = x * x + y * y; - if (d2 < centerD2) { - centerD2 = d2; - centerId = id; - } - + const p = points[i - 1] ?? { x: g.range(-half, half), y: g.range(-half, half) }; + const d = dOf(p.x, p.y); + const zone = zoneOf(d); + const type = g.weighted(weightsFor(zone), typeIds[0]); // Name comes from a per-record fork so roster generation order can - // never leak into it. rNorm (0 = galactic center, 1 = rim) is kept on - // the record: the settlement generator uses it (core→rim density), - // and it's handy for any future "where am I in the galaxy" rules. + // never leak into it. d (0 = home corner, 1 = far corner) and zone + // stay on the record: the settlement generator uses d (home→far + // density), and factions/trade/combat will read difficulty off both. const name = NameGenerator.star(Rng.derive(this.seed, 'name', id)); - const rec = { id, name, type, x, y, rNorm }; + const rec = { id, name, type, x: p.x, y: p.y, d, zone }; records.push(rec); this.byId.set(id, rec); } // Starting system (the player's home port). - const policy = this.params.startingSystem?.policy ?? 'center'; + const policy = start.policy ?? 'corner'; + const closestTo = (px, py) => + records + .slice() + .sort((a, b) => (a.x - px) ** 2 + (a.y - py) ** 2 - ((b.x - px) ** 2 + (b.y - py) ** 2))[0]?.id ?? records[0]?.id; this.currentSystemId = - policy === 'random' ? `S${String(g.int(1, count)).padStart(6, '0')}` : (centerId ?? records[0]?.id); + policy === 'random' + ? `S${String(g.int(1, count)).padStart(6, '0')}` + : policy === 'center' + ? closestTo(0, 0) + : closestTo(corner.x, corner.y); // 'corner' (default) // Frozen copy of the starting system — `currentSystemId` will track // the player from here on (jumps, saves); the home rules must not. this.homeSystemId = this.currentSystemId; // Spatial hash for fast neighbor queries (jump ranges, proximity rules, // the eventual star map). - const area = Math.PI * R * R * flatten; + const area = S * S; this.cellSize = Math.max(8, Math.sqrt(area / count) * 1.4); this.grid = new Map(); for (const rec of records) { @@ -256,32 +285,118 @@ export class Galaxy { this.stationFrames = stationFrames; // Map id → frame (station-bearing systems) } - /** Center-weighted radius sample in [0,1]: core bulge + disk. */ - _sampleShape(g, L) { - if (g.chance(L.coreFraction ?? 0.25)) { - const sigma = Math.max(0.01, L.bulgeSigma ?? 0.09); - return Math.min(1, Math.abs(g.normal(0, sigma))); - } - return Math.min(1, Math.pow(g.next(), Math.max(0.1, L.diskSkew ?? 1.7))); + /** + * The home→far zones (data/galaxy.json → distribution.zones), + * validated and sorted by `d`. A single fallback zone when the config + * is missing/malformed — the galaxy still generates. + */ + _zones() { + const raw = this.params.distribution?.zones; + const out = (Array.isArray(raw) ? raw : []) + .filter( + (z) => + z && + typeof z.name === 'string' && + Array.isArray(z.d) && + z.d.length === 2 && + Number.isFinite(z.d[0]) && + Number.isFinite(z.d[1]) && + z.d[1] > z.d[0], + ) + .map((z) => ({ name: z.name, d: [clamp(z.d[0], 0, 1), clamp(z.d[1], 0, 1)] })) + .sort((a, b) => a.d[0] - b.d[0]); + return out.length ? out : [{ name: 'all', d: [0, 1] }]; } - /** Ease `theta` toward the nearest spiral arm (by `strength`). */ - _snapToArm(theta, rNorm, armPhase, arms, twist, strength) { - if (strength <= 0) return theta; - const step = TAU / arms; - const base = armPhase + twist * rNorm; - const k = Math.floor((((theta - base) % TAU) + TAU) % TAU / step); - let bestD = Infinity; - let bestA = base + k * step; - for (const cand of [k - 1, k, k + 1]) { - const a = base + cand * step; - const d = Math.abs(wrapPI(a - theta)); - if (d < bestD) { - bestD = d; - bestA = a; - } + /** A corner of the square domain (screen orientation — y DOWN). */ + _cornerPoint(name, half) { + const CORNERS = { NE: [1, -1], NW: [-1, -1], SE: [1, 1], SW: [-1, 1] }; + const key = String(name ?? 'SE').toUpperCase(); + const [sx, sy] = CORNERS[key] ?? CORNERS.SE; + return { x: sx * half, y: sy * half }; + } + + /** + * Even star placement: exactly `n` points in the square [−half, half]², + * every pair at least `d0` apart (Bridson / Poisson disk). If the disk + * can't hold `n` points at `d0` (dense config), retry a few times with + * a relaxed spacing; as a last resort pad with random points — a working + * galaxy beats a perfect one. Deterministic: each attempt draws from its + * own seeded fork (seed, 'layout', 'poisson', attempt). + */ + _poissonDisk(n, half, d0) { + if (n <= 0) return []; + let d = Math.max(1, d0); + let pts = null; + for (let attempt = 0; attempt < 8; attempt++) { + const rng = Rng.derive(this.seed, 'layout', 'poisson', attempt); + pts = this._bridson(n, half, d, rng); + if (pts.length >= n) break; + d *= 0.88; // not enough room — loosen the spacing and retry } - return theta + wrapPI(bestA - theta) * strength; + const rng = Rng.derive(this.seed, 'layout', 'poisson', 'pad'); + while (pts.length < n) { + pts.push({ x: rng.range(-half, half), y: rng.range(-half, half) }); + } + return pts.slice(0, n); + } + + /** + * Bridson's algorithm: grow a Poisson-disk of points in the square, + * stopping once `n` points are placed (or the frontier is exhausted). + * Pure — deterministic for a given (n, half, d, rng stream). + */ + _bridson(n, half, d, rng) { + if (n <= 0) return []; + const cell = d / Math.SQRT2; + const grid = new Map(); // "cx,cy" → [point, …] + const pts = []; + const active = []; // indices into pts (Bridson's active list) + const inside = (p) => p.x >= -half && p.x <= half && p.y >= -half && p.y <= half; + const keyOf = (p) => + `${Math.floor((p.x + half) / cell)},${Math.floor((p.y + half) / cell)}`; + const free = (p) => { + const cx = Math.floor((p.x + half) / cell); + const cy = Math.floor((p.y + half) / cell); + for (let ax = -2; ax <= 2; ax++) { + for (let ay = -2; ay <= 2; ay++) { + const bucket = grid.get(`${cx + ax},${cy + ay}`); + if (!bucket) continue; + for (const q of bucket) { + const dx = q.x - p.x; + const dy = q.y - p.y; + if (dx * dx + dy * dy < d * d) return false; + } + } + } + return true; + }; + const place = (p) => { + pts.push(p); + active.push(pts.length - 1); + const k = keyOf(p); + const bucket = grid.get(k); + if (bucket) bucket.push(p); + else grid.set(k, [p]); + }; + // Seed the frontier with one random interior point. + place({ x: rng.range(-half, half), y: rng.range(-half, half) }); + while (active.length > 0 && pts.length < n) { + const i = rng.int(0, active.length - 1); + const p = pts[active[i]]; + let placed = false; + for (let t = 0; t < 30 && !placed; t++) { + // Sample a candidate in the annulus [d, 2d) around p. + const r = d * (1 + rng.next()); + const a = rng.next() * TAU; + const q = { x: p.x + r * Math.cos(a), y: p.y + r * Math.sin(a) }; + if (!inside(q) || !free(q)) continue; + place(q); + placed = true; + } + if (!placed) active.splice(i, 1); // p can never yield a neighbor + } + return pts; } // ------------------------------------------------------------------ @@ -303,7 +418,7 @@ export class Galaxy { best.sort((a, b) => a.d2 - b.d2); if (best.length > k) best.length = k; }; - const maxRing = Math.min(1024, Math.ceil((4 * this.params.radius) / c) + 1); + const maxRing = Math.min(1024, Math.ceil((Math.SQRT2 * (this.side ?? 40000)) / c) + 1); for (let ring = 0; ring <= maxRing; ring++) { for (let dx = -ring; dx <= ring; dx++) { for (let dy = -ring; dy <= ring; dy++) { @@ -403,11 +518,14 @@ export class Galaxy { summary() { const byType = {}; for (const r of this.records) byType[r.type] = (byType[r.type] ?? 0) + 1; + const byZone = {}; + for (const r of this.records) byZone[r.zone] = (byZone[r.zone] ?? 0) + 1; return { name: this.name, seed: this.seed, systems: this.records.length, byType, + byZone, generated: this.generatedCount, currentSystemId: this.currentSystemId, }; diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js index 9db0c8a..ed7a1ec 100644 --- a/js/galaxy/SystemGenerator.js +++ b/js/galaxy/SystemGenerator.js @@ -7,7 +7,7 @@ const TAU = Math.PI * 2; const DEG = Math.PI / 180; /** - * Turns a lightweight galaxy record (id, name, type, x, y, rNorm) into a + * Turns a lightweight galaxy record (id, name, type, x, y, d, zone) into a * fully generated system: star, planets, moons, SETTLEMENTS, debris belt, * hazard flag. * @@ -28,9 +28,9 @@ const DEG = Math.PI / 180; * every other system rolls its TOTAL object count — planets + * free-space stations together — from the table (default: 10% barren — * a jump-gate-only system — then 2/3/4/5 objects at 15/30/30/15%). - * Stations roll first (per-type odds × the core→rim gradient), planets + * Stations roll first (per-type odds × the home→far gradient), planets * fill the rest of the budget. Settlement kinds and their population - * ranges live in data/settlements.json; the core→rim density gradient in + * ranges live in data/settlements.json; the home→far density gradient in * data/galaxy.json (`settlements.gradient`). * * The galaxy is ALREADY LIVED IN: it was settled long before the player. @@ -150,7 +150,7 @@ export function generateSystemContent(galaxy, record, typeDefs = null) { // 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 + // 15/30/30/15%). Stations roll next (per-type odds × the home→far // 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. @@ -1057,7 +1057,7 @@ function mixTint(hex, strength) { /** * The system's OBJECT COMPOSITION (data/systems.json → objectCount + - * attributes.settlements + the core→rim density gradient — + * attributes.settlements + the home→far density gradient — * data/galaxy.json → settlements.gradient), in one deterministic roll: * * home system → fixed: 2 planets (gas giant + rocky) beside the home @@ -1140,9 +1140,10 @@ export function rollStationCount(seed, record, isHome, spec, density) { } /** - * Core→rim density: the settled heart of the galaxy has more activity per - * system; the rim is thinner, lonelier. `factor` scales every settlement - * chance (clamped to a floor so the rim isn't dead). 0 = no gradient. + * Home-corner → far-corner density: the settled heart (the home corner, + * d = 0) has more free-space activity per system; the deep corner + * (d = 1) is thinner, lonelier. `factor` scales every settlement chance + * (clamped to a floor so the deep corner isn't dead). 0 = no gradient. * Exported: the frame pass (js/galaxy/PlanetFrames.js) re-derives the same * values from the same inputs. */ @@ -1150,8 +1151,8 @@ 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); - const rNorm = clamp(record?.rNorm ?? 0, 0, 1); - return clamp(1 - rNorm * falloff, floor, 1); + const d = clamp(record?.d ?? 0, 0, 1); + return clamp(1 - d * falloff, floor, 1); } /** @@ -1167,7 +1168,7 @@ export function settlementDensity(galaxy, record) { * 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). + * free-space kinds still roll per type (home→far scaled). */ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDeck, density, stations, isHome = false }) { const out = []; @@ -1211,7 +1212,7 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe } // Free-floating, out in the dark — the PRE-ROLLED flags (rollStationCount: - // per-type odds × the core→rim density gradient; the home system never + // per-type odds × the home→far 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. @@ -1231,7 +1232,7 @@ function settledKindFor(planet, byClass, kindDefs) { return kind && kindDefs[kind] ? kind : null; } -/** One deterministic roll, scaled by the core→rim density factor. */ +/** One deterministic roll, scaled by the home→far density factor. */ function roll(rng, chance, density) { return rng.chance(chance * density); }