Name worlds & stations from curated banks (colonial/alien, official/smuggler)

Planets and space stations were previously made by stitching a root word to
a kind noun ("Kest Skyspire", "New Natick IV"), which read like labels, not
names. They now draw from deep, hand-picked name BANKS in data/naming.json:

- banks.planet  — colonial "New Denver"/"Port Tucson" worlds mixed with
                  alien "Klaxoria"/"Vexithunhal" worlds (269 names).
- banks.station — official designations ("Deep Space SC-145", "Nav Relay
                  V-77") mixed with smuggler hangouts ("Hell's Hideout",
                  "The Rusty Anchor") (165 names).

Assignment is a seeded shuffle of the whole bank (NameGenerator.planetDeck /
stationDeck), dealt out per-system in order, so a system never repeats a
name until the pool is exhausted (impossible at these sizes). The decks come
from dedicated Rng.derive(...) streams, keeping generation order-independent
(lazy === eager). Stars and the galaxy still synthesize from syllable pools
(unbounded), and the home world keeps its configured name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Brian Fertig 2026-09-03 22:29:31 -06:00
parent 30af365c68
commit d355fd50e4
6 changed files with 231 additions and 64 deletions

View File

@ -40,6 +40,15 @@ python3 -m http.server 8080
population, and an `owner` seam reserved for the factions/pirates to come. population, and an `owner` seam reserved for the factions/pirates to come.
The current system's dossier (name, identity, what's there) shows The current system's dossier (name, identity, what's there) shows
top-left in the game scene. top-left in the game scene.
- **Names with a point of view** (`data/naming.json`): planets draw from a
deep curated bank — colonial "New Denver" worlds beside alien "Klaxoria"
worlds — and stations from a bank of official designations ("Deep Space
SC-145") and smuggler hangouts ("Hell's Hideout"). Names are dealt out
randomly per system **without repeats until the bank is exhausted** (a
seeded shuffle, so the first N draws are N distinct names). Stars and the
galaxy itself are still synthesised from syllable pools, so you never run
out of those. Within a system a planet never shares a name with another
planet and a station never with another station.
- Game screen with a top-down ship (real spritesheet art — frame 0 of - Game screen with a top-down ship (real spritesheet art — frame 0 of
`assets/images/ships-player.png`, see `data/ship.json`): **click anywhere to fly there** `assets/images/ships-player.png`, see `data/ship.json`): **click anywhere to fly there**
in the current system's open space (system boundaries/jumps come next). in the current system's open space (system boundaries/jumps come next).
@ -88,7 +97,7 @@ orbit/
│ ├── research.json # RESEARCH: time-based, one at a time, gates builds/research │ ├── research.json # RESEARCH: time-based, one at a time, gates builds/research
│ ├── builds.json # BUILDING: credits + minerals, ship/planet/station upgrades │ ├── builds.json # BUILDING: credits + minerals, ship/planet/station upgrades
│ ├── actionbar.json # the command deck: 6 slots (Research, Build, Ship, ·, ·, Menu) │ ├── actionbar.json # the command deck: 6 slots (Research, Build, Ship, ·, ·, Menu)
│ └── naming.json # syllable pools for names │ └── naming.json # names: star/galaxy syllable pools + the curated PLANET & STATION name banks
├── assets/images/ # art: planets.png (1024×1024 frames), ships-player.png (256×256 frames) ├── assets/images/ # art: planets.png (1024×1024 frames), ships-player.png (256×256 frames)
├── assets/fonts/ # UI typefaces: Ethnocentric (headers), Centauri (body) ├── assets/fonts/ # UI typefaces: Ethnocentric (headers), Centauri (body)
├── lib/ # vendored third-party libs (Phaser 4.2.1) ├── lib/ # vendored third-party libs (Phaser 4.2.1)

View File

@ -1,25 +1,106 @@
{ {
"_comment": "Syllable pools for deterministic name synthesis (NameGenerator.js). Edit freely — names are drawn per-entity from the galaxy seed, so changing pools changes every name, consistently.", "_comment": "Names. Stars and the galaxy are still SYNTHEISED from syllable pools (star / galaxy below) — short, consistent, unlimited. PLANETS and STATIONS draw from finite, curated NAME BANKS instead: a deep pool of hand-picked names, dealt out without repeats until the pool is exhausted (see js/utils/NameGenerator.js → planetDeck / stationDeck). Edit the banks freely — names are drawn per-system from the galaxy seed, so changing the banks changes every name, consistently. Within a system a planet never repeats another planet's name and a station never repeats another station's name; across the 40,000-system galaxy a finite pool must eventually recur (that's the price of lazy, order-independent generation — see docs/PROJECT_NOTES.md).",
"star": { "star": {
"syllables": ["ka", "vel", "thu", "ori", "an", "esh", "mar", "dy", "neth", "avi", "cor", "lu", "tan", "ys", "brei", "hal", "ion", "sol", "qua", "ren"], "syllables": ["ka", "vel", "thu", "ori", "an", "esh", "mar", "dy", "neth", "avi", "cor", "lu", "tan", "ys", "brei", "hal", "ion", "sol", "qua", "ren"],
"minParts": 2, "minParts": 2,
"maxParts": 4 "maxParts": 4
}, },
"planet": {
"roots": ["aurel", "bryn", "cald", "dross", "emrys", "fen", "gale", "hyra", "ilv", "jor", "kest", "lun", "mora", "neth", "orb", "pyre", "quill", "ross", "sable", "tarn", "ulric", "vex", "wren", "xan", "yrra", "zeph"]
},
"galaxy": { "galaxy": {
"syllables": ["an", "dro", "me", "mil", "ky", "cen", "tau", "rhi", "sa", "vel", "pyr", "os", "eth", "ni"], "syllables": ["an", "dro", "me", "mil", "ky", "cen", "tau", "rhi", "sa", "vel", "pyr", "os", "eth", "ni"],
"minParts": 3, "minParts": 3,
"maxParts": 5 "maxParts": 5
}, },
"station": { "banks": {
"nouns": { "_comment": "planet.colonial = familiar, Earth-flavored names (colonial 'New <city>' + American place-names). planet.alien = unfamiliar, exotic names. station.procedural = official designations. station.smuggler = underworld hangouts. A system's worlds/stations are drawn from the COMBINED pool for each category (colonial+alien, procedural+smuggler) without replacement.",
"colony": ["Reach", "Haven", "Landing", "Hold", "City", "Gate"], "planet": {
"miningStation": ["Rig", "Dredge", "Claim", "Works", "Yard"], "colonial": [
"cloudBase": ["Skyspire", "Drift", "Aerie", "Balcony", "Cloudspire"], "New Denver", "New Austin", "New Phoenix", "New Portland", "New Dallas", "New Houston",
"deepSpaceStation": ["Relay", "Gate", "Beacon", "Haven", "Anchor"], "New Chicago", "New Detroit", "New Boston", "New Atlanta", "New Nashville", "New Memphis",
"waypoint": ["Waypoint", "Beacon", "Marker", "Light"] "New Kansas", "New Omaha", "New Tulsa", "New Fargo", "New Duluth", "New Billings",
"New Cheyenne", "New Amarillo", "New Laredo", "New Albuquerque", "New Flagstaff", "New Prescott",
"New Yakima", "New Spokane", "New Tacoma", "New Salem", "New Provo", "New Reno",
"New Elko", "New Medford", "New Eugene", "New Corvallis", "New Astoria", "New Olympia",
"New Wenatchee", "New Missoula", "New Butte", "New Casper", "New Sheridan", "New Bismarck",
"New Superior", "New Milwaukee", "New Madison", "New Rockford", "New Peoria", "New Columbus",
"New Cincinnati", "New Cleveland", "New Buffalo", "New Rochester", "New Syracuse", "New Albany",
"New Binghamton", "New Harrisburg", "New Scranton", "New Elmira", "New Ithaca", "New Kingston",
"New Yonkers", "New Greenwich", "New Stamford", "New Hartford", "New Bridgeport", "New Portsmouth",
"New Manchester", "New Concord", "New Bangor", "New Lewiston", "New Auburn", "New Waterville",
"New Saco", "New Exeter", "New Keene", "New Plymouth", "New Brockton", "New Lowell",
"New Haverhill", "New Gloucester", "New Ipswich", "New Andover", "New Quincy", "New Dedham",
"New Natick", "New Worcester", "New Pittsfield", "New Greenfield", "New Chelsea", "New Holyoke",
"New Westfield", "Fort Boise", "Port Tucson", "Denver City", "Austin Falls", "Phoenix Ridge",
"Tucson Mesa", "Dallas Harbor", "Houston Gulf", "Chicago Point", "Boston Landing", "Nashville Bend",
"Kansas Plains", "Cheyenne Gap", "Brownsville Bay", "Salem Harbor", "Provo Canyon", "Elko Basin",
"Butte Ridge", "Havre Plains", "Rawlins Basin", "Wausau River", "Springfield Falls", "Champaign Valley",
"Erie Bay", "Albany Point", "Poughkeepsie Point", "Norwalk Cove", "Danbury Ridge", "Waterbury Falls",
"Kennebunk Cove", "Newburyport Cove", "Braintree Cove", "Worcester River", "Northampton River"
],
"alien": [
"Klaxoria", "Zyneatha", "Draonvexus", "Zorvraquaax", "Vexithunhal", "Ythorialzyr",
"Elithanzra", "Zaxaviion", "Quavex", "Avivryxyl", "Ananora", "Kakethilith",
"Zynera", "Koren", "Zorath", "Velvrazr", "Alkhaqo", "Velqavalax",
"Xylzolenori", "Eshkaelura", "Kadraisia", "Kordrasul", "Suldra", "Ketosera",
"Elisyth", "Xyresh", "Rendymar", "Ythera", "Anera", "Khaora",
"Khaqor", "Oridravexia", "Zalax", "Zalqor", "Vrazalthu", "Zalvexkha",
"Xanzranvry", "Qavnydraziv", "Aviusys", "Corsyl", "Zynziv", "Drathu",
"Xylhalil", "Sylxanys", "Breivexyrr", "Quaqor", "Ysvexys", "Qulxyl",
"Zorzalthuea", "Xevquazax", "Thaeaithxyl", "Exvexvryzax", "Zyrxun", "Nyilsul",
"Ketqavkor", "Ketzra", "Uravex", "Breiqe", "Iaoriythxun", "Vryvex",
"Yrryrrxan", "Umothora", "Alnyth", "Thaunketsol", "Oraka", "Oriqavyldys",
"Kethothixys", "Kaelyld", "Zrakoris", "Nytholonzyn", "Thazrqor", "Axos",
"Zorqul", "Korvex", "Envra", "Exqav", "Nythkor", "Thairayth",
"Qalsul", "Olnythhal", "Ustan", "Nyith", "Korvrady", "Nethzolyld",
"Solil", "Vryin", "Zaxzyr", "Xylan", "Draqosol", "Ixzal",
"Quaketxyl", "Xuniazaxan", "Athkha", "Haldy", "Ysorayldum", "Ketzyl",
"Zrathu", "Umzynzr", "Ketqavxan", "Nethys", "Korvane", "Sulthar",
"Vrynnex", "Zaxolis", "Xanthea", "Ylthar", "Zorvax", "Vraxol",
"Nymthea", "Qorvyn", "Zorvane", "Kethar", "Xanthys", "Zorvyn",
"Klaxys", "Dravys", "Korvys", "Vexxys", "Sulthys", "Qorvax",
"Xylthys", "Ylthys", "Zynthys", "Vrynnys", "Zaxolys", "Draquis",
"Sulvar", "Korthex", "Vrynnal", "Xanthis", "Ylthral", "Zorvael",
"Kethral", "Vraxalis", "Nymthal", "Qorvael", "Xylthral", "Zynthral",
"Klaxalis", "Vexxalis", "Dravael", "Sulvaris", "Korveth", "Vrynthal"
]
},
"station": {
"procedural": [
"Deep Space SC-145", "Orbital Station KX-2210", "Nav Relay V-77", "Forward Base SC-09", "Survey Camp 8812",
"Transit Gate SG-4", "Habitat Ring 330", "Dock Platform 12", "Relay Node 210", "Anchor Point 55",
"Beacon 4491", "Waypoint 7", "Station 9012", "Outpost Delta-7", "Habitat 44",
"Ring Station 12", "Platform 44", "Module 7", "Sector 8", "Node 44",
"Port 88", "Dock 11", "Berth 22", "Pad 33", "Bay 55",
"Quay 66", "Spire 77", "Tower 88", "Rig 44", "Yard 55",
"Works 66", "Terminal 11", "Depot 22", "Trading Post 88", "Bourse 99",
"Observatory 77", "Research Station 99", "Institute 11", "Archive 77", "Vault 88",
"Warehouse 11", "Reserve 33", "Cache 44", "Supply Depot 66", "Outfitting 99",
"Repair Bay 33", "Service Station 55", "Salvage Yard 22", "Lighthouse 12", "Signal 34",
"Pulse 56", "Echo 78", "Resonance 90", "Frequency 12", "Band 34",
"Channel 56", "Carrier 78", "Wave 90", "Phase 12", "Orbit 78",
"Apogee 12", "Perigee 34", "Sidereal 34", "Synodic 56", "Tropical 78",
"Draconic 12", "Lunar 34", "Solar 56", "Planetary 78", "Stellar 90",
"Galactic 12", "Cosmic 34", "Universal 56", "Absolute 78", "Relative 90"
],
"smuggler": [
"Hell's Hideout", "The Rusty Anchor", "Black Tide", "The Gilded Cage", "Widow's Rest",
"The Broken Compass", "The Salted Barrel", "The Drowned Bell", "The Cursed Cargo", "The Phantom Rig",
"The Ghost Dock", "The Midnight Berth", "The Shadow Quay", "The Black Market", "The Hidden Hold",
"The Secret Stow", "The Smuggler's Den", "The Contraband Bay", "The Night Runner", "The Shadow Fleet",
"The Dark Harbor", "The Hidden Cove", "The Smuggler's Cove", "The Cursed Anchor", "The Wandering Rig",
"The Drifting Hulk", "The Broken Mast", "The Tattered Sail", "The Rusty Helm", "The Salted Keel",
"The Drowned Rig", "The Phantom Ship", "The Last Light", "The Fading Star", "The Dying Sun",
"The Waning Moon", "The Hollow Crown", "The Broken Chain", "The Shattered Wheel", "The Lost Helm",
"The Cursed Compass", "The Wandering Star", "The Drifting Light", "The Fading Flame", "The Dying Ember",
"The Last Ember", "The Cold Hearth", "The Dead Hearth", "The Frozen Flame", "The Drowned Flame",
"The Buried Light", "The Hidden Fire", "The Secret Flame", "The Smuggler's Flame", "The Contraband Light",
"The Black Flame", "The Cursed Fire", "The Wandering Fire", "The Drifting Flame", "The Broken Light",
"The Tattered Ember", "The Rusty Ember", "The Salted Flame", "The Drowned Light", "The Phantom Flame",
"The Ghost Fire", "The Midnight Ember", "The Shadow Flame", "The Hidden Ember", "The Secret Fire",
"The Night Flame", "The Dark Ember", "The Cursed Flame", "The Wandering Ember", "The Wrecked Beacon",
"The Tattered Flame", "The Rusty Flame", "The Salted Light", "The Drowned Ember", "The Phantom Fire",
"The Ghost Light", "The Midnight Flame", "The Shadow Ember", "The Smuggler's Light", "The Secret Light",
"The Night Ember", "The Dark Flame", "The Cursed Light", "The Lost Lantern", "The Broken Flame"
]
} }
} }
} }

View File

@ -292,9 +292,15 @@ let big;
const avg = (arr) => arr.reduce((s, x) => s + x.n, 0) / arr.length; const avg = (arr) => arr.reduce((s, x) => s + x.n, 0) / arr.length;
check(`core→rim settlement gradient (inner ${avg(inner).toFixed(2)}/system > outer ${avg(outer).toFixed(2)}/system)`, avg(inner) > avg(outer)); check(`core→rim settlement gradient (inner ${avg(inner).toFixed(2)}/system > outer ${avg(outer).toFixed(2)}/system)`, avg(inner) > avg(outer));
// Station naming + the pure report formatter. // Station + planet naming now draws from the curated BANKS (no repeats
const stName = NameGenerator.station(Rng.derive('x', 'station', 'S1'), 'cloudBase', 'Kest'); // within a system until the pool is exhausted). The deck is a seeded
check('settlement names are anchor + noun ("Kest Skyspire"-style)', /Kest [A-Z]\w+/.test(stName)); // shuffle of the whole bank ⇒ the first N draws are N distinct names.
const stDeck = NameGenerator.stationDeck(Rng.derive('x', 'names', 'S1', 'stations'));
check('station deck is a non-empty list of names', Array.isArray(stDeck) && stDeck.length >= 10 && stDeck.every((n) => typeof n === 'string' && n.length > 0));
check('station deck has no duplicates', new Set(stDeck).size === stDeck.length);
const plDeck = NameGenerator.planetDeck(Rng.derive('x', 'names', 'S1', 'planets'));
check('planet deck is a non-empty list of names', Array.isArray(plDeck) && plDeck.length >= 10 && plDeck.every((n) => typeof n === 'string' && n.length > 0));
check('planet deck has no duplicates', new Set(plDeck).size === plDeck.length);
const { formatSystemReport, formatPop } = await import( const { formatSystemReport, formatPop } = await import(
pathToFileURL(join(__dirname, '../js/galaxy/SystemReport.js')).href pathToFileURL(join(__dirname, '../js/galaxy/SystemReport.js')).href
@ -302,7 +308,7 @@ let big;
const anySys = big2.currentSystem(); const anySys = big2.currentSystem();
const report = formatSystemReport(big2.ensureContent(anySys.id)); 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 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 lines name their anchor world or open space', report.settlements.every((s) => /on .+|in open space/.test(s.text)));
check('report population sums match', report.population === report.settlements.reduce((s, x) => s + x.population, 0)); 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'); check('formatPop() scales (1.2k / 9.0M)', formatPop(1234) === '1.2k' && formatPop(9000000) === '9.0M' && formatPop(12) === '12');
@ -310,6 +316,16 @@ let big;
const unclaimedRec = sample.map((r) => big2.ensureContent(r.id)).find((c) => c.settlements.length === 0); 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'); check('unclaimed systems report "charted · unclaimed"', unclaimedRec && formatSystemReport(unclaimedRec).summary === 'charted · unclaimed');
// No-duplicate guarantee: within a system, planet names and station names
// never repeat (drawn without replacement from the bank).
const noDupOk = sample.every((r) => {
const c = big2.ensureContent(r.id);
const pnames = c.planets.map((p) => p.name);
const snames = c.settlements.map((s) => s.name);
return new Set(pnames).size === pnames.length && new Set(snames).size === snames.length;
});
check('within a system: planet names & station names are all distinct', noDupOk);
// Lazy === eager still holds with the lived-in layer (spot check). // Lazy === eager still holds with the lived-in layer (spot check).
const fresh2 = Galaxy.create(SEED); 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)); check('settlements: lazy content === content from a fresh galaxy', deepEq(fresh2.ensureContent(big2.records[0].id).settlements, big2.ensureContent(big2.records[0].id).settlements));

View File

@ -45,7 +45,7 @@ trading/economy, stations, quests — to be scoped as we go.
*parameters*. *parameters*.
- Split config by concern as the game grows: world generation already has - Split config by concern as the game grows: world generation already has
`data/galaxy.json` (shape/scale), `data/systems.json` (archetypes), and `data/galaxy.json` (shape/scale), `data/systems.json` (archetypes), and
`data/naming.json` (name pools); next up might be `data/economy.json`, `data/naming.json` (syllable pools + name banks); next up might be `data/economy.json`,
`data/ships.json`, `data/crew.json`, etc. One file per system beats one `data/ships.json`, `data/crew.json`, etc. One file per system beats one
giant file. giant file.
@ -146,6 +146,23 @@ unclaimed". Model & seams:
of interest: the data already says what's there and where (anchor = of interest: the data already says what's there and where (anchor =
planet ordinal or open space). planet ordinal or open space).
**Naming** — planets and stations get their names from **curated banks** in
`data/naming.json → banks` (distinct from the star/galaxy *syllable pools*):
- **`banks.planet`** — a deep list mixing **colonial** names ("New Denver",
"Port Tucson") and **alien** names ("Klaxoria", "Vexithunhal").
- **`banks.station`** — a deep list mixing **official/procedural**
designations ("Deep Space SC-145", "Nav Relay V-77") and **smuggler** /
outlaw hangouts ("Hell's Hideout", "The Rusty Anchor").
- **Assignment**`NameGenerator.planetDeck(rng)` / `stationDeck(rng)`
build one seeded shuffle of the whole bank; `SystemGenerator` deals names
out of it in order. So **within a system there are no repeats** (the first
N draws are N distinct names), and a system only reuses a name if it has
more bodies than the bank holds (impossible at these sizes). The decks are
dealt from dedicated derived streams (`Rng.derive(seed,'system',id,'names',
'planets'|'stations')`) so they stay order-independent (lazy === eager).
The home world keeps its configured name (`planets.homeName`), and stars /
the galaxy are still synthesised from syllable pools (unbounded).
## The current system is a place, not just a dossier (discovery + compass) ## The current system is a place, not just a dossier (discovery + compass)
The player starts in a solar system, and the other worlds **exist in the The player starts in a solar system, and the other worlds **exist in the

View File

@ -1,6 +1,6 @@
import { config } from '../config/Config.js'; import { config } from '../config/Config.js';
import { Rng } from '../utils/Rng.js'; import { Rng } from '../utils/Rng.js';
import { NameGenerator, ordinalLabel } from '../utils/NameGenerator.js'; import { NameGenerator } from '../utils/NameGenerator.js';
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
@ -62,6 +62,12 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
clamp(pc.mean + (rng.next() - 0.5) * (pc.max - pc.min), pc.min, pc.max), clamp(pc.mean + (rng.next() - 0.5) * (pc.max - pc.min), pc.min, pc.max),
); );
const classWeights = attr.planetClasses ?? { rocky: 45, gas: 25, ice: 18, lava: 12 }; 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).
// A dedicated derived stream keeps this order-independent (lazy === eager).
const planetDeck = NameGenerator.planetDeck(
Rng.derive(galaxy.seed, 'system', record.id, 'names', 'planets')
);
const planets = []; const planets = [];
for (let i = 1; i <= count; i++) { for (let i = 1; i <= count; i++) {
const pclass = rng.weighted(classWeights, 'rocky'); const pclass = rng.weighted(classWeights, 'rocky');
@ -70,11 +76,9 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
// Jovian/ice worlds drag moon systems; terrestrials mostly don't. // Jovian/ice worlds drag moon systems; terrestrials mostly don't.
moons = pclass === 'gas' || pclass === 'ice' ? rng.int(1, 6) : rng.int(0, 2); moons = pclass === 'gas' || pclass === 'ice' ? rng.int(1, 6) : rng.int(0, 2);
} }
const root = NameGenerator.planetRoot(rng);
planets.push({ planets.push({
name: `${root} ${ordinalLabel(i)}`, name: planetDeck[(i - 1) % planetDeck.length],
ordinal: i, ordinal: i,
root,
class: pclass, class: pclass,
moons, moons,
habitable: pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1), habitable: pclass === 'rocky' && rng.chance(attr.habitability ?? 0.1),
@ -88,7 +92,9 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
kindDefs: config.get('settlements.kinds', {}), kindDefs: config.get('settlements.kinds', {}),
spec: attr.settlements ?? {}, spec: attr.settlements ?? {},
planets, planets,
starName: star.name, stationDeck: NameGenerator.stationDeck(
Rng.derive(galaxy.seed, 'system', record.id, 'names', 'stations')
),
density: settlementDensity(galaxy, record), density: settlementDensity(galaxy, record),
}); });
@ -185,14 +191,15 @@ function settlementDensity(galaxy, record) {
* orbital order (colony, mining, cloud), then free-floating (deep-space * orbital order (colony, mining, cloud), then free-floating (deep-space
* station, waypoint). Every roll goes through the system's own stream. * station, waypoint). Every roll goes through the system's own stream.
*/ */
function generateSettlements({ rng, kindDefs, spec, planets, starName, density }) { function generateSettlements({ rng, kindDefs, spec, planets, stationDeck, density }) {
const out = []; const out = [];
let nameIndex = 0; // next station name from the system's deck (no repeats)
const make = (kind, anchor, rootWord) => { const make = (kind, anchor) => {
const def = kindDefs[kind] ?? {}; const def = kindDefs[kind] ?? {};
out.push({ out.push({
kind, kind,
name: NameGenerator.station(rng, kind, rootWord), name: stationDeck[nameIndex++ % stationDeck.length],
anchor, anchor,
population: logPopulation(rng, def.population), population: logPopulation(rng, def.population),
owner: null, // reserved: factions / pirates claim settlements later owner: null, // reserved: factions / pirates claim settlements later
@ -202,24 +209,24 @@ function generateSettlements({ rng, kindDefs, spec, planets, starName, density }
// Planet-bound, in orbital order. // Planet-bound, in orbital order.
for (const p of planets) { for (const p of planets) {
if (p.habitable && roll(rng, spec.colony?.chance ?? 0.3, density)) { if (p.habitable && roll(rng, spec.colony?.chance ?? 0.3, density)) {
make('colony', { type: 'planet', ordinal: p.ordinal }, p.root); make('colony', { type: 'planet', ordinal: p.ordinal });
} }
if (needs(p, spec.miningStation?.needs, ['rocky', 'lava', 'ice']) && if (needs(p, spec.miningStation?.needs, ['rocky', 'lava', 'ice']) &&
roll(rng, spec.miningStation?.chance ?? 0.2, density)) { roll(rng, spec.miningStation?.chance ?? 0.2, density)) {
make('miningStation', { type: 'planet', ordinal: p.ordinal }, p.root); make('miningStation', { type: 'planet', ordinal: p.ordinal });
} }
if (needs(p, spec.cloudBase?.needs, ['gas']) && if (needs(p, spec.cloudBase?.needs, ['gas']) &&
roll(rng, spec.cloudBase?.chance ?? 0.15, density)) { roll(rng, spec.cloudBase?.chance ?? 0.15, density)) {
make('cloudBase', { type: 'planet', ordinal: p.ordinal }, p.root); make('cloudBase', { type: 'planet', ordinal: p.ordinal });
} }
} }
// Free-floating, out in the dark. // Free-floating, out in the dark.
if (roll(rng, spec.deepSpaceStation?.chance ?? 0.12, density)) { if (roll(rng, spec.deepSpaceStation?.chance ?? 0.12, density)) {
make('deepSpaceStation', { type: 'space' }, starName); make('deepSpaceStation', { type: 'space' });
} }
if (roll(rng, spec.waypoint?.chance ?? 0.2, density)) { if (roll(rng, spec.waypoint?.chance ?? 0.2, density)) {
make('waypoint', { type: 'space' }, starName); make('waypoint', { type: 'space' });
} }
return out; return out;

View File

@ -1,13 +1,31 @@
import { config } from '../config/Config.js'; import { config } from '../config/Config.js';
/** /**
* Deterministic name synthesis. Everything is drawn from the caller's * Names.
* Rng (so names are part of the seed), with syllable pools coming from
* data/naming.json. Code-level fallbacks keep this working even if the
* file is missing.
* *
* const rng = Rng.derive(seed, 'name', 'S000123'); * Two different strategies, both seed-deterministic:
* const name = NameGenerator.star(rng); // "Kethavoryn" *
* STARS & THE GALAXY synthesised from syllable pools (data/naming.json
* star / galaxy). Short, consistent, and effectively unlimited: there are
* 40,000 systems and no reason to run out of star names.
*
* PLANETS & STATIONS drawn from finite, curated NAME BANKS (data/
* naming.json banks). A deep pool of hand-picked names colonial
* "New Denver" worlds and alien "Klaxoria" worlds; official designations
* like "Deep Space SC-145" and smuggler hangouts like "Hell's Hideout".
* Within a single system a planet never repeats another planet's name and
* a station never repeats another station's name (no-duplicate drawing).
* Across the galaxy a finite pool must eventually recur that's the cost
* of keeping generation LAZY and ORDER-INDEPENDENT (see
* docs/PROJECT_NOTES.md "Determinism rules").
*
* How the banks are dealt out:
* const deck = NameGenerator.planetDeck(Rng.derive(seed, 'system', id, 'names', 'planets'));
* const name = deck[i % deck.length]; // i = the system's i-th planet
*
* The deck is a seeded SHUFFLE of the whole bank, so the first N draws are
* N distinct names until the bank is exhausted. `Rng.derive(..., 'system',
* id, ...)` keeps it independent per system ⇒ lazy === eager.
*/ */
const FALLBACK = { const FALLBACK = {
@ -16,23 +34,15 @@ const FALLBACK = {
minParts: 2, minParts: 2,
maxParts: 4, maxParts: 4,
}, },
planet: {
roots: ['aurel', 'bryn', 'cald', 'dross', 'emrys', 'fen', 'gale', 'hyra', 'ilv', 'jor', 'kest', 'lun', 'mora', 'neth', 'orb', 'pyre', 'quill', 'ross', 'sable', 'tarn', 'ulric', 'vex', 'wren', 'xan', 'yrra', 'zeph'],
},
galaxy: { galaxy: {
syllables: ['an', 'dro', 'me', 'mil', 'ky', 'cen', 'tau', 'rhi', 'sa', 'vel', 'pyr', 'os'], syllables: ['an', 'dro', 'me', 'mil', 'ky', 'cen', 'tau', 'rhi', 'sa', 'vel', 'pyr', 'os'],
minParts: 3, minParts: 3,
maxParts: 5, maxParts: 5,
}, },
station: { // Tiny built-in pools so the game still names things if data/naming.json
nouns: { // is missing. The real banks live in data/naming.json → banks.
colony: ['Reach', 'Haven', 'Landing'], planet: ['New Denver', 'Klaxoria', 'New Austin', 'Zyneatha', 'New Phoenix', 'Vexithunhal', 'New Dallas', 'Kordrasul'],
miningStation: ['Rig', 'Dredge', 'Claim'], station: ['Deep Space SC-145', "Hell's Hideout", 'Beacon 4491', 'The Rusty Anchor', 'Station 9012', 'The Shadow Quay'],
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']; const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI', 'XII', 'XIII', 'XIV', 'XV', 'XVI', 'XVII', 'XVIII', 'XIX', 'XX'];
@ -53,6 +63,26 @@ function joinSyllables(rng, syllables, minParts, maxParts) {
return capitalize(out); return capitalize(out);
} }
/**
* The combined name bank for a category: the curated sub-pools concatenated
* (planets: colonial + alien; stations: procedural + smuggler). Deduplicated
* defensively in case the JSON has an accidental repeat.
*/
function combinedBank(pools) {
const out = [];
const seen = new Set();
for (const pool of pools) {
if (!Array.isArray(pool)) continue;
for (const name of pool) {
if (typeof name !== 'string' || !name) continue;
if (seen.has(name)) continue;
seen.add(name);
out.push(name);
}
}
return out;
}
/** "1" → "I" … "20" → "XX"; beyond that, plain digits. */ /** "1" → "I" … "20" → "XX"; beyond that, plain digits. */
export function ordinalLabel(n) { export function ordinalLabel(n) {
return ROMAN[n - 1] ?? String(n); return ROMAN[n - 1] ?? String(n);
@ -66,13 +96,6 @@ export const NameGenerator = {
return joinSyllables(rng, syllables, s.minParts ?? 2, s.maxParts ?? 4); return joinSyllables(rng, syllables, s.minParts ?? 2, s.maxParts ?? 4);
}, },
/** A planet root word: "Aurel" (compose the ordinal yourself). */
planetRoot(rng) {
const p = section('planet', FALLBACK.planet);
const roots = Array.isArray(p.roots) && p.roots.length ? p.roots : FALLBACK.planet.roots;
return capitalize(rng.pick(roots) ?? 'Novus');
},
/** A galaxy name: "Vethani". */ /** A galaxy name: "Vethani". */
galaxy(rng) { galaxy(rng) {
const g = section('galaxy', FALLBACK.galaxy); const g = section('galaxy', FALLBACK.galaxy);
@ -81,15 +104,29 @@ export const NameGenerator = {
}, },
/** /**
* A settlement name from its anchor + kind: "Kest Reach", "Cortan Relay". * A shuffled deck of PLANET names for one system. The first N draws are N
* `anchorWord` is the planet's root word (planet-bound settlements) or the * distinct names (until the bank is exhausted). Pure function of (rng)
* star's name (free-floating ones). * deterministic and order-independent when the rng is a per-system derive.
*/ */
station(rng, kind, anchorWord) { planetDeck(rng) {
const cfg = section('station', FALLBACK.station); const b = config.get('naming.banks.planet', null);
const all = cfg.nouns ?? FALLBACK.station.nouns; const pools = (b && (Array.isArray(b.colonial) || Array.isArray(b.alien)))
const pool = Array.isArray(all?.[kind]) && all[kind].length ? all[kind] : (FALLBACK.station.nouns[kind] ?? ['Post']); ? [b.colonial, b.alien]
const noun = rng.pick(pool) ?? 'Post'; : [FALLBACK.planet];
return `${anchorWord ?? 'Outer'} ${noun}`; const names = combinedBank(pools);
return names.length ? rng.shuffle(names) : Array.from(FALLBACK.planet);
},
/**
* A shuffled deck of STATION names for one system (official designations +
* smuggler hangouts). Same no-duplicate-until-exhausted guarantee.
*/
stationDeck(rng) {
const b = config.get('naming.banks.station', null);
const pools = (b && (Array.isArray(b.procedural) || Array.isArray(b.smuggler)))
? [b.procedural, b.smuggler]
: [FALLBACK.station];
const names = combinedBank(pools);
return names.length ? rng.shuffle(names) : Array.from(FALLBACK.station);
}, },
}; };