diff --git a/README.md b/README.md index 00715e7..1d16ad1 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,15 @@ python3 -m http.server 8080 population, and an `owner` seam reserved for the factions/pirates to come. The current system's dossier (name, identity, what's there) shows top-left in the game scene. +- **Names with a point of view** (`data/naming.json`): planets draw from a + 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 `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). @@ -88,7 +97,7 @@ orbit/ │ ├── research.json # RESEARCH: time-based, one at a time, gates builds/research │ ├── builds.json # BUILDING: credits + minerals, ship/planet/station upgrades │ ├── 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/fonts/ # UI typefaces: Ethnocentric (headers), Centauri (body) ├── lib/ # vendored third-party libs (Phaser 4.2.1) diff --git a/data/naming.json b/data/naming.json index 4676353..ccfa384 100644 --- a/data/naming.json +++ b/data/naming.json @@ -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": { "syllables": ["ka", "vel", "thu", "ori", "an", "esh", "mar", "dy", "neth", "avi", "cor", "lu", "tan", "ys", "brei", "hal", "ion", "sol", "qua", "ren"], "minParts": 2, "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": { "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"] + "banks": { + "_comment": "planet.colonial = familiar, Earth-flavored names (colonial 'New ' + 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.", + "planet": { + "colonial": [ + "New Denver", "New Austin", "New Phoenix", "New Portland", "New Dallas", "New Houston", + "New Chicago", "New Detroit", "New Boston", "New Atlanta", "New Nashville", "New Memphis", + "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" + ] } } } diff --git a/dev/galaxy.test.mjs b/dev/galaxy.test.mjs index 9c8948b..7f20d66 100644 --- a/dev/galaxy.test.mjs +++ b/dev/galaxy.test.mjs @@ -292,9 +292,15 @@ let big; 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)); + // Station + planet naming now draws from the curated BANKS (no repeats + // within a system until the pool is exhausted). The deck is a seeded + // 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( pathToFileURL(join(__dirname, '../js/galaxy/SystemReport.js')).href @@ -302,7 +308,7 @@ let big; 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 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('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); 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). 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)); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 1a15bb7..ebd22ae 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -45,7 +45,7 @@ trading/economy, stations, quests — to be scoped as we go. *parameters*. - Split config by concern as the game grows: world generation already has `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 giant file. @@ -146,6 +146,23 @@ unclaimed". Model & seams: of interest: the data already says what's there and where (anchor = 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 player starts in a solar system, and the other worlds **exist in the diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js index b74f5c9..415b782 100644 --- a/js/galaxy/SystemGenerator.js +++ b/js/galaxy/SystemGenerator.js @@ -1,6 +1,6 @@ import { config } from '../config/Config.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)); @@ -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), ); 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 = []; for (let i = 1; i <= count; i++) { 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. moons = pclass === 'gas' || pclass === 'ice' ? rng.int(1, 6) : rng.int(0, 2); } - const root = NameGenerator.planetRoot(rng); planets.push({ - name: `${root} ${ordinalLabel(i)}`, + name: planetDeck[(i - 1) % planetDeck.length], ordinal: i, - root, class: pclass, moons, 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', {}), spec: attr.settlements ?? {}, planets, - starName: star.name, + stationDeck: NameGenerator.stationDeck( + Rng.derive(galaxy.seed, 'system', record.id, 'names', 'stations') + ), density: settlementDensity(galaxy, record), }); @@ -185,14 +191,15 @@ function settlementDensity(galaxy, record) { * 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 }) { +function generateSettlements({ rng, kindDefs, spec, planets, stationDeck, density }) { 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] ?? {}; out.push({ kind, - name: NameGenerator.station(rng, kind, rootWord), + name: stationDeck[nameIndex++ % stationDeck.length], anchor, population: logPopulation(rng, def.population), 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. 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); + make('colony', { type: 'planet', ordinal: p.ordinal }); } if (needs(p, spec.miningStation?.needs, ['rocky', 'lava', 'ice']) && roll(rng, spec.miningStation?.chance ?? 0.2, density)) { - make('miningStation', { type: 'planet', ordinal: p.ordinal }, p.root); + make('miningStation', { type: 'planet', ordinal: p.ordinal }); } if (needs(p, spec.cloudBase?.needs, ['gas']) && roll(rng, spec.cloudBase?.chance ?? 0.15, density)) { - make('cloudBase', { type: 'planet', ordinal: p.ordinal }, p.root); + make('cloudBase', { type: 'planet', ordinal: p.ordinal }); } } // Free-floating, out in the dark. 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)) { - make('waypoint', { type: 'space' }, starName); + make('waypoint', { type: 'space' }); } return out; diff --git a/js/utils/NameGenerator.js b/js/utils/NameGenerator.js index c15468d..127fda9 100644 --- a/js/utils/NameGenerator.js +++ b/js/utils/NameGenerator.js @@ -1,13 +1,31 @@ import { config } from '../config/Config.js'; /** - * Deterministic name synthesis. Everything is drawn from the caller's - * 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. + * Names. * - * const rng = Rng.derive(seed, 'name', 'S000123'); - * const name = NameGenerator.star(rng); // "Kethavoryn" + * Two different strategies, both seed-deterministic: + * + * 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 = { @@ -16,23 +34,15 @@ const FALLBACK = { minParts: 2, 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: { syllables: ['an', 'dro', 'me', 'mil', 'ky', 'cen', 'tau', 'rhi', 'sa', 'vel', 'pyr', 'os'], 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'], - }, - }, + // Tiny built-in pools so the game still names things if data/naming.json + // is missing. The real banks live in data/naming.json → banks. + planet: ['New Denver', 'Klaxoria', 'New Austin', 'Zyneatha', 'New Phoenix', 'Vexithunhal', 'New Dallas', 'Kordrasul'], + station: ['Deep Space SC-145', "Hell's Hideout", 'Beacon 4491', 'The Rusty Anchor', 'Station 9012', 'The Shadow Quay'], }; 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); } +/** + * 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. */ export function ordinalLabel(n) { return ROMAN[n - 1] ?? String(n); @@ -66,13 +96,6 @@ export const NameGenerator = { 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". */ galaxy(rng) { const g = section('galaxy', FALLBACK.galaxy); @@ -81,15 +104,29 @@ export const NameGenerator = { }, /** - * 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). + * A shuffled deck of PLANET names for one system. The first N draws are N + * distinct names (until the bank is exhausted). Pure function of (rng) ⇒ + * deterministic and order-independent when the rng is a per-system derive. */ - 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}`; + planetDeck(rng) { + const b = config.get('naming.banks.planet', null); + const pools = (b && (Array.isArray(b.colonial) || Array.isArray(b.alien))) + ? [b.colonial, b.alien] + : [FALLBACK.planet]; + 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); }, };