diff --git a/README.md b/README.md index 269aab5..8333866 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,18 @@ python3 -m http.server 8080 (`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 are generated lazily on - arrival, deterministically (seed + system id), so lazy and eager give - identical results. The current system's name/identity shows top-left in - the game scene. + Game (~70 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. + 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. + The current system's dossier (name, identity, what's there) shows + top-left in the game scene. - Game screen with a basic top-down ship: **click anywhere to fly there** in infinite, unbounded space (system boundaries/jumps come next) - Camera gently trails the ship; the **parallax starfield** streams past @@ -51,6 +59,7 @@ orbit/ │ ├── ship.json # ship feel: thrust, drag, maxSpeed, … │ ├── galaxy.json # galaxy scale & shape (count, radius, spiral…) │ ├── systems.json # system archetypes: theme, attributes, distribution +│ ├── settlements.json # the lived-in layer: settlement kinds & populations │ └── naming.json # syllable pools for names ├── lib/ # vendored third-party libs (Phaser 4.2.1) ├── js/ @@ -58,7 +67,7 @@ orbit/ │ ├── config/ # Config singleton, ConfigLoader, game config │ ├── scenes/ # MenuScene, GameScene (thin, orchestration) │ ├── entities/ # Ship (own behavior) -│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator +│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport │ ├── ui/ # MenuButton (reusable) │ ├── visuals/ # Starfield (decorative) │ ├── utils/ # small pure helpers (Color, Rng, NameGenerator) diff --git a/data/galaxy.json b/data/galaxy.json index 058d43b..e0ae517 100644 --- a/data/galaxy.json +++ b/data/galaxy.json @@ -12,5 +12,8 @@ "rules": [] }, "startingSystem": { "policy": "center" }, + "settlements": { + "gradient": { "falloff": 0.7, "floor": 0.22 } + }, "neighbors": 8 } diff --git a/data/naming.json b/data/naming.json index d905263..4676353 100644 --- a/data/naming.json +++ b/data/naming.json @@ -12,5 +12,14 @@ "syllables": ["an", "dro", "me", "mil", "ky", "cen", "tau", "rhi", "sa", "vel", "pyr", "os", "eth", "ni"], "minParts": 3, "maxParts": 5 + }, + "station": { + "nouns": { + "colony": ["Reach", "Haven", "Landing", "Hold", "City", "Gate"], + "miningStation": ["Rig", "Dredge", "Claim", "Works", "Yard"], + "cloudBase": ["Skyspire", "Drift", "Aerie", "Balcony", "Cloudspire"], + "deepSpaceStation": ["Relay", "Gate", "Beacon", "Haven", "Anchor"], + "waypoint": ["Waypoint", "Beacon", "Marker", "Light"] + } } } diff --git a/data/settlements.json b/data/settlements.json new file mode 100644 index 0000000..8d8ecc9 --- /dev/null +++ b/data/settlements.json @@ -0,0 +1,40 @@ +{ + "_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..attributes.settlements). Core→rim density gradient lives in data/galaxy.json (settlements.gradient).", + "kinds": { + "colony": { + "label": "Colony", + "description": "A settled habitable world — cities, farms, life.", + "theme": { "color": "#7ce8a4" }, + "population": { "min": 40000, "max": 9000000 }, + "anchor": "planet" + }, + "miningStation": { + "label": "Mining Station", + "description": "Orbital rigs and habitats striping minerals from a resource world.", + "theme": { "color": "#e8b45f" }, + "population": { "min": 80, "max": 4000 }, + "anchor": "planet" + }, + "cloudBase": { + "label": "Cloud Base", + "description": "A mining city riding the upper clouds of a gas giant.", + "theme": { "color": "#6fc3e8" }, + "population": { "min": 500, "max": 120000 }, + "anchor": "planet" + }, + "deepSpaceStation": { + "label": "Deep-Space Station", + "description": "A free-floating station adrift in open system space — refinery, gate, or trading post.", + "theme": { "color": "#c9a7ff" }, + "population": { "min": 200, "max": 60000 }, + "anchor": "space" + }, + "waypoint": { + "label": "Waypoint", + "description": "A small navigation beacon — the faint trace of a crossed galaxy.", + "theme": { "color": "#8fa0c9" }, + "population": { "min": 0, "max": 12 }, + "anchor": "space" + } + } +} diff --git a/data/systems.json b/data/systems.json index 804cf93..19f1d9f 100644 --- a/data/systems.json +++ b/data/systems.json @@ -17,7 +17,14 @@ "moonChance": 0.3, "beltChance": 0.35, "habitability": 0.1, - "hazard": 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 } + } } }, "redDwarf": { @@ -36,7 +43,14 @@ "moonChance": 0.45, "beltChance": 0.3, "habitability": 0.22, - "hazard": 0.12 + "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 } + } } }, "binary": { @@ -55,7 +69,14 @@ "moonChance": 0.5, "beltChance": 0.6, "habitability": 0.06, - "hazard": 0.18 + "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 } + } } }, "habitable": { @@ -74,7 +95,14 @@ "moonChance": 0.35, "beltChance": 0.25, "habitability": 0.5, - "hazard": 0.05 + "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 } + } } }, "nebula": { @@ -93,7 +121,14 @@ "moonChance": 0.2, "beltChance": 0.8, "habitability": 0.02, - "hazard": 0.35 + "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 } + } } }, "void": { @@ -112,7 +147,14 @@ "moonChance": 0.1, "beltChance": 0.2, "habitability": 0.02, - "hazard": 0.5 + "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 } + } } } } diff --git a/dev/galaxy.test.mjs b/dev/galaxy.test.mjs index ba1541a..9c8948b 100644 --- a/dev/galaxy.test.mjs +++ b/dev/galaxy.test.mjs @@ -229,11 +229,91 @@ let big; const g = Galaxy.create('content-shape'); const sys = g.currentSystem(); const c = g.ensureContent(sys.id); - check('content has star/planets/belt/hazard', !!c.star && Array.isArray(c.planets) && !!c.belt && typeof c.hazard === 'boolean'); + check('content has star/planets/settlements/belt/hazard', !!c.star && Array.isArray(c.planets) && Array.isArray(c.settlements) && !!c.belt && typeof c.hazard === 'boolean'); check('planets have name/ordinal/class/moons/habitable', c.planets.every((p) => p.name && p.ordinal >= 1 && p.class && Number.isInteger(p.moons) && typeof p.habitable === 'boolean')); check('type themes are defined (UI hook)', typeIds.every((id) => typeof types[id].theme?.color === 'string')); check('galaxy name is deterministic per seed', Galaxy.create('content-shape').name === g.name); } +// ---------------------------------------------------------------------- +// 5. The lived-in layer: settlements, gradient, report +// ---------------------------------------------------------------------- +{ + const kinds = config.get('settlements.kinds', {}); + check('settlement kinds defined with theme + population range', Object.keys(kinds).length >= 4 && Object.values(kinds).every((k) => typeof k.theme?.color === 'string' && k.population?.min >= 0 && k.population?.max >= k.population?.min)); + + // Structure across a big sample of the full galaxy. + const big2 = Galaxy.create(SEED); + const sample = big2.records.slice(0, 3000); + let structOk = true; + for (const r of sample) { + const c = big2.ensureContent(r.id); + for (const s of c.settlements) { + if (!kinds[s.kind]) { structOk = false; break; } + if (s.anchor.type === 'planet') { + const planet = c.planets.find((p) => p.ordinal === s.anchor.ordinal); + if (!planet) structOk = false; + } else if (s.anchor.type !== 'space') structOk = false; + const pop = kinds[s.kind].population; + if (s.population < pop.min || s.population > pop.max) structOk = false; + if (s.owner !== null) structOk = false; // reserved seam, unused for now + if (!s.name) structOk = false; + } + if (!structOk) break; + } + check('settlement structure: known kinds, valid anchors, population in range, owner=null (faction seam)', structOk); + + // The lived-in mix actually shows up across the sample. + const seen = new Set(); + let anchorRulesOk = true; + for (const r of sample) { + const c = big2.ensureContent(r.id); + for (const s of c.settlements) { + seen.add(s.kind); + const p = c.planets.find((pl) => pl.ordinal === s.anchor.ordinal); + if (s.kind === 'colony' && !p?.habitable) anchorRulesOk = false; + if (s.kind === 'cloudBase' && p?.class !== 'gas') anchorRulesOk = false; + } + } + 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); + + // 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++; + check(`some systems are charted-but-unclaimed (${unclaimed}/${sample.length} in sample)`, unclaimed > 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 })); + 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)); + + // Station naming + the pure report formatter. + const stName = NameGenerator.station(Rng.derive('x', 'station', 'S1'), 'cloudBase', 'Kest'); + check('settlement names are anchor + noun ("Kest Skyspire"-style)', /Kest [A-Z]\w+/.test(stName)); + + const { formatSystemReport, formatPop } = await import( + pathToFileURL(join(__dirname, '../js/galaxy/SystemReport.js')).href + ); + const anySys = big2.currentSystem(); + const report = formatSystemReport(big2.ensureContent(anySys.id)); + check('report has title/subtitle/settlements/summary', !!report.title && !!report.subtitle && Array.isArray(report.settlements) && typeof report.summary === 'string'); + check('report lines name their anchor world or open space', report.settlements.every((s) => /on \w+ [IVX]+|in open space/.test(s.text))); + 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'); + + // Unclaimed systems read as "charted · unclaimed" in the report. + const unclaimedRec = sample.map((r) => big2.ensureContent(r.id)).find((c) => c.settlements.length === 0); + check('unclaimed systems report "charted · unclaimed"', unclaimedRec && formatSystemReport(unclaimedRec).summary === 'charted · unclaimed'); + + // 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)); +} + console.log(failures === 0 ? '\nAll galaxy tests passed ✔' : `\n${failures} test(s) FAILED ✘`); process.exit(failures === 0 ? 0 : 1); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 053bf56..ee52c43 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -3,6 +3,14 @@ Working agreements and conventions for building this game. Read this before adding new systems — these are the rules that keep the project scalable. +## Commits (important) + +- **Brian makes the commits, manually.** After finishing a change, leave the + work staged/unstaged in the working tree with a suggested commit message + (in the chat reply, or a note here), and do NOT run `git commit` yourself. + This gives Brian the chance to review the code before it enters history. +- Amends/reverts are fine if asked explicitly. + ## What we're building A procedurally generated space RPG in the spirit of **Privateer**, but in @@ -76,12 +84,12 @@ menu (displayed, editable, rerollable; same seed ⇒ same galaxy). (id, name, type, x, y) up front. 40,000 systems is ~70 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/hazards are generated **lazily** on - first arrival (`galaxy.ensureContent(id)`), then cached. Each system's - draw stream is `Rng.derive(seed, 'system', id)` — independent of - generation order — so lazy and eager (`galaxy.generateAll()`) results - are identical. Pick whichever is cheaper at runtime; correctness never - depends on it. +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)` + — independent of generation order — so lazy and eager + (`galaxy.generateAll()`) results are identical. Pick whichever is + cheaper at runtime; correctness never depends on it. **Determinism rules (keep them!):** - All generation draws go through `Rng` (`js/utils/Rng.js`). Never @@ -111,6 +119,33 @@ The player's **current system** starts at `galaxy.currentSystem()` (the eventual star map / jump drives) will use `galaxy.neighborsOf(id)` 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: +- **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..attributes.settlements` in + `data/systems.json`: `chance` + optional `needs` (planet classes) per + kind. 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. +- **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. +- **Pure report formatter** — `js/galaxy/SystemReport.js` + (`formatSystemReport(content)` → title/subtitle/settlements/summary). + The GameScene HUD renders it; future star map / terminal UI reuse it. +- Landing/exploration (a future feature) will treat settlements as points + of interest: the data already says what's there and where (anchor = + planet ordinal or open space). + ## Phaser version - Pinned: **Phaser 4.2.1** ("Giedi"), vendored in `lib/phaser.min.js`. @@ -126,6 +161,15 @@ and the spatial hash already built for it. - [x] Two-level worldgen: seeded galaxy roster (40k 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 + bases, deep-space stations, beacons) with per-type rates, a + core→rim density gradient, populations, and a `owner` seam reserved + for the factions/pirates to come; readable as a HUD dossier + (`SystemReport`) +- [ ] Factions & pirates: claim settlements (`owner`), flags, borders, + and the player's place in a populated galaxy +- [ ] Landing & exploration: settlements become points of interest you + can approach (the data — kind, anchor, population — is already there) - [ ] Richer galaxy distribution rules (`galaxy.distribution.rules[]`: clustering by type, borders, adjacency affinity) — hook marked in `Galaxy._generate()` @@ -133,8 +177,8 @@ and the spatial hash already built for it. wire in current-system boundaries, jumps between systems (use `galaxy.neighborsOf`), and a star map scene - [ ] Ship input beyond click-to-fly (throttle/brake keys, manual rotation) -- [ ] HUD (speed, sector name, later: fuel/crew) — system name/identity - already shown top-left +- [ ] HUD (speed, fuel/crew) — the current system's dossier (name, + identity, settlements) is already shown top-left - [ ] Save/load (the `config` + entity split should make this tractable; a save = seed + player state, since the galaxy regenerates) - [ ] Economy/trading loop (the Privateer heart) diff --git a/js/galaxy/Galaxy.js b/js/galaxy/Galaxy.js index 0e7f093..a65a26e 100644 --- a/js/galaxy/Galaxy.js +++ b/js/galaxy/Galaxy.js @@ -133,9 +133,11 @@ export class Galaxy { } // Name comes from a per-record fork so roster generation order can - // never leak into it. + // 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. const name = NameGenerator.star(Rng.derive(this.seed, 'name', id)); - const rec = { id, name, type, x, y }; + const rec = { id, name, type, x, y, rNorm }; records.push(rec); this.byId.set(id, rec); } diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js index af71be2..88371ec 100644 --- a/js/galaxy/SystemGenerator.js +++ b/js/galaxy/SystemGenerator.js @@ -5,8 +5,9 @@ import { NameGenerator, ordinalLabel } from '../utils/NameGenerator.js'; const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); /** - * Turns a lightweight galaxy record (id, name, type, x, y) into a fully - * generated system: star, planets, moons, debris belt, hazard flag. + * Turns a lightweight galaxy record (id, name, type, x, y, rNorm) into a + * fully generated system: star, planets, moons, SETTLEMENTS, debris belt, + * hazard flag. * * Deterministic contract: * same galaxy seed + same record id ⇒ identical content, every time. @@ -15,12 +16,20 @@ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); * is byte-for-byte identical to one generated during an up-front * generateAll(). That's what makes lazy generation safe. * - * What the system actually contains is steered by the TYPE'S ATTRIBUTES - * in data/systems.json (`types..attributes`): star classes, binary + * What the system contains is steered by the TYPE'S ATTRIBUTES in + * data/systems.json (`types..attributes`): star classes, binary * chance, planet count spread, planet class weights, moon/belt chances, - * habitability, hazard. Theme (`types..theme`) is for UI. Add new - * attribute keys here + in the JSON and types become richer without - * touching the galaxy generator. + * 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`). + * + * 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. */ export function generateSystemContent(galaxy, record, typeDefs = null) { const defs = typeDefs ?? config.get('systems.types', {}); @@ -61,15 +70,27 @@ export function generateSystemContent(galaxy, record, typeDefs = null) { // Jovian/ice worlds drag moon systems; terrestrials mostly don't. moons = pclass === 'gas' || pclass === 'ice' ? rng.int(1, 6) : rng.int(0, 2); } + const root = NameGenerator.planetRoot(rng); planets.push({ - name: `${NameGenerator.planetRoot(rng)} ${ordinalLabel(i)}`, + name: `${root} ${ordinalLabel(i)}`, ordinal: i, + root, class: pclass, moons, habitable: pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1), }); } + // --- Settlements (the lived-in layer) --------------------------------- + const settlements = generateSettlements({ + rng, + kindDefs: config.get('settlements.kinds', {}), + spec: attr.settlements ?? {}, + planets, + starName: star.name, + density: settlementDensity(galaxy, record), + }); + // --- Debris belt & system-level hazard -------------------------------- const belt = { present: rng.chance(attr.beltChance ?? 0.35), @@ -82,7 +103,85 @@ export function generateSystemContent(galaxy, record, typeDefs = null) { type: record.type, star, planets, + settlements, belt, hazard, }; } + +/** + * 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. + */ +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); +} + +/** + * 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. + */ +function generateSettlements({ rng, kindDefs, spec, planets, starName, density }) { + const out = []; + + const make = (kind, anchor, rootWord) => { + const def = kindDefs[kind] ?? {}; + out.push({ + kind, + name: NameGenerator.station(rng, kind, rootWord), + anchor, + population: logPopulation(rng, def.population), + owner: null, // reserved: factions / pirates claim settlements later + }); + }; + + // 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 }, p.root); + } + if (needs(p, spec.miningStation?.needs, ['rocky', 'lava', 'ice']) && + roll(rng, spec.miningStation?.chance ?? 0.2, density)) { + make('miningStation', { type: 'planet', ordinal: p.ordinal }, p.root); + } + if (needs(p, spec.cloudBase?.needs, ['gas']) && + roll(rng, spec.cloudBase?.chance ?? 0.15, density)) { + make('cloudBase', { type: 'planet', ordinal: p.ordinal }, p.root); + } + } + + // Free-floating, out in the dark. + if (roll(rng, spec.deepSpaceStation?.chance ?? 0.12, density)) { + make('deepSpaceStation', { type: 'space' }, starName); + } + if (roll(rng, spec.waypoint?.chance ?? 0.2, density)) { + make('waypoint', { type: 'space' }, starName); + } + + return out; +} + +/** One deterministic roll, scaled by the core→rim density factor. */ +function roll(rng, chance, density) { + return rng.chance(chance * density); +} + +/** Does the planet satisfy the kind's requirements? */ +function needs(planet, needsList, defaults) { + const list = Array.isArray(needsList) && needsList.length ? needsList : defaults; + return list.includes(planet.class); +} + +/** Log-uniform population in [min, max] (a few towns to a few megacities). */ +function logPopulation(rng, pop) { + const min = pop?.min ?? 1; + const max = Math.max(min, pop?.max ?? min); + if (min <= 0 && rng.chance(0.5)) return 0; // e.g. unmanned waypoints + return Math.round(Math.exp(rng.range(Math.log(Math.max(1, min)), Math.log(max)))); +} diff --git a/js/galaxy/SystemReport.js b/js/galaxy/SystemReport.js new file mode 100644 index 0000000..338c8b1 --- /dev/null +++ b/js/galaxy/SystemReport.js @@ -0,0 +1,59 @@ +import { config } from '../config/Config.js'; + +/** + * Formats a generated system into a readable "dossier" for UI. + * + * Pure — no Phaser — so it's testable in Node and reusable anywhere a + * system needs describing (the HUD today; the star map, tooltips, saves, + * or a terminal UI later). Scenes stay thin: they render what this + * returns. + * + * const report = formatSystemReport(content); + * // { title, subtitle, settlements: [{ text, color, population }], + * // summary, population } + */ +export function formatSystemReport(content, typeDefs = null, kindDefs = null) { + const types = typeDefs ?? config.get('systems.types', {}); + const kinds = kindDefs ?? config.get('settlements.kinds', {}); + const typeDef = types[content.type] ?? {}; + + const planetName = (ordinal) => { + const p = content.planets.find((pl) => pl.ordinal === ordinal); + return p ? p.name : `planet ${ordinal}`; + }; + + const settlements = []; + for (const s of content.settlements ?? []) { + const def = kinds[s.kind] ?? {}; + const where = + s.anchor?.type === 'planet' ? `on ${planetName(s.anchor.ordinal)}` : 'in open space'; + settlements.push({ + text: `${s.name} · ${def.label ?? s.kind} ${where}`, + color: def.theme?.color ?? '#8fa0c9', + population: s.population ?? 0, + }); + } + + const population = settlements.reduce((sum, s) => sum + s.population, 0); + const n = settlements.length; + const summary = + n === 0 + ? 'charted · unclaimed' + : `${n} settlement${n === 1 ? '' : 's'} · pop ~${formatPop(population)}`; + + const planetN = content.planets.length; + return { + title: content.name, + subtitle: `${typeDef.label ?? content.type} system · star ${content.star.class} · ${planetN} planet${planetN === 1 ? '' : 's'}`, + settlements, + summary, + population, + }; +} + +/** 1234 → "1.2k", 9000000 → "9.0M" */ +export function formatPop(n) { + if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`; + if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`; + return String(n); +} diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 304f198..96cadcd 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -3,6 +3,7 @@ import { config } from '../config/Config.js'; import { toColor } from '../utils/Color.js'; import { Rng } from '../utils/Rng.js'; import { Galaxy } from '../galaxy/Galaxy.js'; +import { formatSystemReport } from '../galaxy/SystemReport.js'; import { Ship } from '../entities/Ship.js'; import { Starfield } from '../visuals/Starfield.js'; @@ -56,7 +57,10 @@ export class GameScene extends Phaser.Scene { } /** - * Top-left HUD: the current system's name and identity. + * Top-left HUD: the current system's dossier — name, identity, and what + *'s ALREADY THERE: colonies, mining stations, cloud bases, stations adrift + * in open space (or "charted · unclaimed" when nobody's settled here). + * Formatted by the pure SystemReport helper; this method only renders. * * The galaxy comes from the shared registry (built by the menu from the * chosen seed). Dev boots that skip the menu (dev/test-game.html) get a @@ -78,30 +82,32 @@ export class GameScene extends Phaser.Scene { const current = this.galaxy.currentSystem(); const content = this.galaxy.ensureContent(current.id); - const typeDef = this.galaxy.typeDefs?.[current.type] ?? {}; + const report = formatSystemReport(content); const fam = FONT_FALLBACK; - this.add - .text(16, 14, content.name, { - fontFamily: fam, - fontSize: '17px', - fontStyle: 'bold', - color: toColor(typeDef.theme?.color ?? '#9fb4e8'), - }) - .setOrigin(0, 0) - .setScrollFactor(0) // UI: pinned to the screen, not the world - .setDepth(30); + let y = 14; + const line = (text, style) => { + this.add + .text(16, y, text, style) + .setOrigin(0, 0) + .setScrollFactor(0) // UI: pinned to the screen, not the world + .setDepth(30); + y += 20 + (style.fontSize === '17px' ? 6 : 0); + }; - const label = typeDef.label ?? current.type; - this.add - .text(16, 38, `${label} \u00b7 star ${content.star.class} \u00b7 ${content.planets.length} planets \u00b7 seed ${this.galaxy.seed}`, { - fontFamily: fam, - fontSize: '12px', - color: '#8fa0c9', - }) - .setOrigin(0, 0) - .setScrollFactor(0) - .setDepth(30); + line(report.title, { + fontFamily: fam, + fontSize: '17px', + fontStyle: 'bold', + color: toColor(this.galaxy.typeDefs?.[current.type]?.theme?.color ?? '#9fb4e8'), + }); + line(report.subtitle, { fontFamily: fam, fontSize: '12px', color: '#8fa0c9' }); + y += 2; + for (const s of report.settlements) { + line(s.text, { fontFamily: fam, fontSize: '13px', color: toColor(s.color, 0x8fa0c9) }); + } + line(report.summary, { fontFamily: fam, fontSize: '12px', color: '#54608a' }); + line(`seed ${this.galaxy.seed}`, { fontFamily: fam, fontSize: '11px', color: '#3d476b' }); } update(_time, delta) { diff --git a/js/utils/NameGenerator.js b/js/utils/NameGenerator.js index e92e826..c15468d 100644 --- a/js/utils/NameGenerator.js +++ b/js/utils/NameGenerator.js @@ -24,6 +24,15 @@ const FALLBACK = { minParts: 3, maxParts: 5, }, + station: { + nouns: { + colony: ['Reach', 'Haven', 'Landing'], + miningStation: ['Rig', 'Dredge', 'Claim'], + cloudBase: ['Skyspire', 'Drift', 'Aerie'], + deepSpaceStation: ['Relay', 'Beacon', 'Anchor'], + waypoint: ['Waypoint', 'Beacon', 'Light'], + }, + }, }; const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII', 'XIII', 'XIV', 'XV', 'XVI', 'XVII', 'XVIII', 'XIX', 'XX']; @@ -70,4 +79,17 @@ export const NameGenerator = { const syllables = Array.isArray(g.syllables) && g.syllables.length ? g.syllables : FALLBACK.galaxy.syllables; return joinSyllables(rng, syllables, g.minParts ?? 3, g.maxParts ?? 5); }, + + /** + * A settlement name from its anchor + kind: "Kest Reach", "Cortan Relay". + * `anchorWord` is the planet's root word (planet-bound settlements) or the + * star's name (free-floating ones). + */ + station(rng, kind, anchorWord) { + const cfg = section('station', FALLBACK.station); + const all = cfg.nouns ?? FALLBACK.station.nouns; + const pool = Array.isArray(all?.[kind]) && all[kind].length ? all[kind] : (FALLBACK.station.nouns[kind] ?? ['Post']); + const noun = rng.pick(pool) ?? 'Post'; + return `${anchorWord ?? 'Outer'} ${noun}`; + }, };