Remove waypoint kind; allow up to two deep-space stations per system

- Waypoints were comms-only beacons with no landing or trade; a second
  deep-space station is a more useful destination in their place
- The old waypoint chance in systems.json becomes
  deepSpaceStation.second, so a system can roll 0–2 stations
- StationFrames now assigns a distinct sprite variant per station
  (Galaxy.stationFrames is id → [frame, …]) and a two-station system
  always wears two different variants
- Rename the route-gate pulse from "destination's waypoint" to
  "route's next stop" so the term no longer refers to a removed kind
This commit is contained in:
Brian Fertig 2026-09-14 10:25:19 -06:00
parent afa7a00c29
commit 54d2886c91
27 changed files with 289 additions and 296 deletions

View File

@ -72,7 +72,7 @@
"rippleAlpha": 0.85 "rippleAlpha": 0.85
}, },
"objectPulse": { "objectPulse": {
"_comment": "DESTINATION OBJECT PULSE (js/entities/RoutePulse.js via Planet.setRouteTarget / Station.setRouteTarget): in the DESTINATION system the route's last stop is the destination OBJECT (a planet or a space station — GameScene._routeDestinationEntity). It wears the SAME pulse as the route gate, SUBDUED: the same shape + timing language, but lower alpha, a smaller reach and a slower cycle — 'the place to go', without shouting like the gate's full pulse (pulse). The geometry still scales with the object's keepout radius (a small waypoint and a gas giant read the same).", "_comment": "DESTINATION OBJECT PULSE (js/entities/RoutePulse.js via Planet.setRouteTarget / Station.setRouteTarget): in the DESTINATION system the route's last stop is the destination OBJECT (a planet or a space station — GameScene._routeDestinationEntity). It wears the SAME pulse as the route gate, SUBDUED: the same shape + timing language, but lower alpha, a smaller reach and a slower cycle — 'the place to go', without shouting like the gate's full pulse (pulse). The geometry still scales with the object's keepout radius (a station and a gas giant read the same).",
"baseRadius": 1.12, "baseRadius": 1.12,
"expand": 0.4, "expand": 0.4,
"period": 1.8, "period": 1.8,

View File

@ -6,7 +6,7 @@
"VARIANT frames (0..2 — the deep-space station types, data/stations.json →", "VARIANT frames (0..2 — the deep-space station types, data/stations.json →",
"variants; the same frame the station is drawn with and its comms label", "variants; the same frame the station is drawn with and its comms label",
"reads, data/stations.json → typeLabels). See data/stations.json for the", "reads, data/stations.json → typeLabels). See data/stations.json for the",
"station variants; waypoints are beacons, not a port (no clips).", "station variants.",
"", "",
"`land` — the full-screen one-shot clip played when the ship lands.", "`land` — the full-screen one-shot clip played when the ship lands.",
"`surface` — the looping clip shown on the surface deck behind the action bar.", "`surface` — the looping clip shown on the surface deck behind the action bar.",

View File

@ -74,7 +74,7 @@
"procedural": [ "procedural": [
"Deep Space SC-145", "Orbital Station KX-2210", "Nav Relay V-77", "Forward Base SC-09", "Survey Camp 8812", "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", "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", "Beacon 4491", "Relay 7", "Station 9012", "Outpost Delta-7", "Habitat 44",
"Ring Station 12", "Platform 44", "Module 7", "Sector 8", "Node 44", "Ring Station 12", "Platform 44", "Module 7", "Sector 8", "Node 44",
"Port 88", "Dock 11", "Berth 22", "Pad 33", "Bay 55", "Port 88", "Dock 11", "Berth 22", "Pad 33", "Bay 55",
"Quay 66", "Spire 77", "Tower 88", "Rig 44", "Yard 55", "Quay 66", "Spire 77", "Tower 88", "Rig 44", "Yard 55",

View File

@ -1,5 +1,5 @@
{ {
"_comment": "The galaxy is already LIVED IN: it was settled long before the player. For now EVERY planet hosts a settlement (allPlanetsSettled): each world gets the one kind that fits its class (settledKindByClass — a habitable rocky OR arid world earns a colony, every other world gets its mining outfit, gas giants ride cloud bases, and the four CAPITAL worlds host the capital kind). Arid worlds are habitable by rule (data/systems.json doesn't roll it — js/galaxy/SystemGenerator.js sets habitable=true for every arid), so they settle as colonies like rocky habitables. The CAPITALS (data/factions.json — neutral/apex/mindweave/zenith) are designated systems whose outermost world is a class-`capital*` planet (data/planets.json → capitals); that world hosts the capital kind and its settlement carries the faction's `owner` (the reputation faction seam) — the neutral capital's owner stays null. Flip allPlanetsSettled to false to return to the old probabilistic layer (per-type chance + needs in data/systems.json). The free-space kinds (deepSpaceStation, waypoint) still roll per type. kinds = the shared vocabulary (label, theme, population range, anchor type). Core→rim density gradient lives in data/galaxy.json (settlements.gradient).", "_comment": "The galaxy is already LIVED IN: it was settled long before the player. For now EVERY planet hosts a settlement (allPlanetsSettled): each world gets the one kind that fits its class (settledKindByClass — a habitable rocky OR arid world earns a colony, every other world gets its mining outfit, gas giants ride cloud bases, and the four CAPITAL worlds host the capital kind). Arid worlds are habitable by rule (data/systems.json doesn't roll it — js/galaxy/SystemGenerator.js sets habitable=true for every arid), so they settle as colonies like rocky habitables. The CAPITALS (data/factions.json — neutral/apex/mindweave/zenith) are designated systems whose outermost world is a class-`capital*` planet (data/planets.json → capitals); that world hosts the capital kind and its settlement carries the faction's `owner` (the reputation faction seam) — the neutral capital's owner stays null. Flip allPlanetsSettled to false to return to the old probabilistic layer (per-type chance + needs in data/systems.json). The free-space kind (deepSpaceStation) still rolls per type — up to two per system (data/systems.json → settlements.deepSpaceStation `chance` + `second`). kinds = the shared vocabulary (label, theme, population range, anchor type). Core→rim density gradient lives in data/galaxy.json (settlements.gradient).",
"allPlanetsSettled": true, "allPlanetsSettled": true,
"settledKindByClass": { "settledKindByClass": {
"rocky": { "habitable": "colony", "default": "miningStation" }, "rocky": { "habitable": "colony", "default": "miningStation" },
@ -47,13 +47,6 @@
"theme": { "color": "#c9a7ff" }, "theme": { "color": "#c9a7ff" },
"population": { "min": 200, "max": 60000 }, "population": { "min": 200, "max": 60000 },
"anchor": "space" "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"
} }
} }
} }

View File

@ -1,5 +1,5 @@
{ {
"_comment": "SPACE STATIONS — free-space settlements (deepSpaceStation, waypoint) rendered as world objects (js/entities/Station.js). enabled = draw them in-world (solid + discoverable + comms targets); shipClearance = the edge gap the ship holds at their surface (px — never crosses, like planets); compassColor = the compass-arrow accent for stations (bright purple — planets get green from data/planets.json, clusters their own gray from data/asteroids.json); kinds.<kind>.size = the station's keepout radius (px — the wings/ring extent); kinds.<kind>.ringSpeed = the deep-space station's ring rotation (rad/s, the procedural fallback only). texture/frameWidth/frameHeight = the station spritesheet (frame 0 = top-left); variants = the sheet frames a DEEP-SPACE STATION may wear — the galaxy-wide pass (js/galaxy/StationFrames.js) spreads them across the galaxy (a system avoids what its nearest stars already wear, like the planet frames — js/galaxy/PlanetFrames.js). typeLabels = the comms/HUD label a variant reads as (keyed by sheet frame — the fallback for an unlabeled frame is the kind's label, data/settlements.json → kinds.<kind>.label). The landing/surface/take-off/shop clips a variant plays come from data/landing.json → stationVideos (same sheet-frame key) — surface/shop are null until they are authored.", "_comment": "SPACE STATIONS — the free-space settlements (deepSpaceStation — the only free-space kind) rendered as world objects (js/entities/Station.js). enabled = draw them in-world (solid + discoverable + comms targets); shipClearance = the edge gap the ship holds at their surface (px — never crosses, like planets); compassColor = the compass-arrow accent for stations (bright purple — planets get green from data/planets.json, clusters their own gray from data/asteroids.json); kinds.<kind>.size = the station's keepout radius (px — the wings/ring extent); kinds.<kind>.ringSpeed = the deep-space station's ring rotation (rad/s, the procedural fallback only). texture/frameWidth/frameHeight = the station spritesheet (frame 0 = top-left); variants = the sheet frames a DEEP-SPACE STATION may wear — the galaxy-wide pass (js/galaxy/StationFrames.js) spreads them across the galaxy (a system avoids what its nearest stars already wear, like the planet frames — js/galaxy/PlanetFrames.js). typeLabels = the comms/HUD label a variant reads as (keyed by sheet frame — the fallback for an unlabeled frame is the kind's label, data/settlements.json → kinds.<kind>.label). The landing/surface/take-off/shop clips a variant plays come from data/landing.json → stationVideos (same sheet-frame key) — surface/shop are null until they are authored.",
"enabled": true, "enabled": true,
"texture": "assets/images/spacestations.png", "texture": "assets/images/spacestations.png",
"frameWidth": 256, "frameWidth": 256,
@ -16,9 +16,6 @@
"deepSpaceStation": { "deepSpaceStation": {
"size": 108, "size": 108,
"ringSpeed": 0.08 "ringSpeed": 0.08
},
"waypoint": {
"size": 46
} }
} }
} }

View File

@ -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 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).", "_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: `chance` of a station, `second` of a second one — up to two per system). 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), 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).",
"_effect": "Per-type VISUAL EFFECT (js/visuals/SystemEffects.js): the star's character. Each sub-effect is an OPTIONAL block — a type wears exactly what it lists (an empty {} = no effect, renders untouched). CAMERA FILTERS (WebGL; the HUD is excluded by the UI-camera split) — 'ripple' { center: screen|star, strength (wave cycles), amplitude (displacement, fraction of screen), speed (rad/s), padding (framebuffer slack, keep >= amplitude*width*(1+flare.rippleBoost)*centers), centers: 1|2, orbit {radius, period} (two centers circling each other) }; 'grade' { tint (hex hue), amount (0..1), saturation (1=none), brightness, lift {amount, color — or a+b for two directional hues (the binary's two lights glow opposite sides of the view, across the seeded axis)}, grain {amount, parallax}, split {a, b hex, mix 0..1, axis deg} (a two-color directional wash) }. 'flare' { interval, duration (seconds), rippleBoost x, flash 0..1, flashColor } is a SHARED seeded rhythm that surges the ripple's amplitude AND flashes the grade warm — the star flaring (a ripple with no flare just shimmers at its base amplitude). 'breath' { period (s), depth 0..0.5 } — the binary's two lights slowly trade brightness (take their turn in the sky). CONTENT (world-space sprites, Canvas-safe, depth 3, behind the art) — 'particles' (ember dust), 'wanderer' (a distant companion star: coreSize, haloSize, parallax, speed — camera-anchored so it stays in the sky), 'haze' (a full-screen atmospheric color+grit layer behind the stars, redDwarf's red void), 'void' (a full-screen NEUTRAL dark+grit base behind the stars — the binary's two lights lift/breathe it into a two-color wash; without it the clear color bypasses the filter and the void can't be tinted), 'lightning' (OVERT bolts streaking the background sky — the nebula's far-sky discharge: a jagged edge-to-edge trunk + random branches in the gas's base/accent colors (alternating per strike), strobed, optional soft distant thunder; world Graphics at depth 2.9 — the sky itself, behind the back gas; interval [s], strike (s), levels, branches, glowWidth, coreWidth, glowAlpha, coreAlpha, parallax, thunder). LIVE TODAY: nebula = a steady ripple + occasional far-sky lightning bolts (in the gas colors); redDwarf = a SUPER-subtle ripple that SURGES on each flare + a warm grade with shadow lift, gritty grain and a red haze + ember dust; binary = a two-color split grade + a two-light void glow (lift painted on a neutral void base) + a slow breath + a wandering companion star (NO ripple — two stars, one steady light). Per-system variation (flare phase, split axis, breath phase, wanderer path) is seeded from the system id in SystemGenerator (content.fx), so each system is stable but distinct. Tuning is a JSON edit.", "_effect": "Per-type VISUAL EFFECT (js/visuals/SystemEffects.js): the star's character. Each sub-effect is an OPTIONAL block — a type wears exactly what it lists (an empty {} = no effect, renders untouched). CAMERA FILTERS (WebGL; the HUD is excluded by the UI-camera split) — 'ripple' { center: screen|star, strength (wave cycles), amplitude (displacement, fraction of screen), speed (rad/s), padding (framebuffer slack, keep >= amplitude*width*(1+flare.rippleBoost)*centers), centers: 1|2, orbit {radius, period} (two centers circling each other) }; 'grade' { tint (hex hue), amount (0..1), saturation (1=none), brightness, lift {amount, color — or a+b for two directional hues (the binary's two lights glow opposite sides of the view, across the seeded axis)}, grain {amount, parallax}, split {a, b hex, mix 0..1, axis deg} (a two-color directional wash) }. 'flare' { interval, duration (seconds), rippleBoost x, flash 0..1, flashColor } is a SHARED seeded rhythm that surges the ripple's amplitude AND flashes the grade warm — the star flaring (a ripple with no flare just shimmers at its base amplitude). 'breath' { period (s), depth 0..0.5 } — the binary's two lights slowly trade brightness (take their turn in the sky). CONTENT (world-space sprites, Canvas-safe, depth 3, behind the art) — 'particles' (ember dust), 'wanderer' (a distant companion star: coreSize, haloSize, parallax, speed — camera-anchored so it stays in the sky), 'haze' (a full-screen atmospheric color+grit layer behind the stars, redDwarf's red void), 'void' (a full-screen NEUTRAL dark+grit base behind the stars — the binary's two lights lift/breathe it into a two-color wash; without it the clear color bypasses the filter and the void can't be tinted), 'lightning' (OVERT bolts streaking the background sky — the nebula's far-sky discharge: a jagged edge-to-edge trunk + random branches in the gas's base/accent colors (alternating per strike), strobed, optional soft distant thunder; world Graphics at depth 2.9 — the sky itself, behind the back gas; interval [s], strike (s), levels, branches, glowWidth, coreWidth, glowAlpha, coreAlpha, parallax, thunder). LIVE TODAY: nebula = a steady ripple + occasional far-sky lightning bolts (in the gas colors); redDwarf = a SUPER-subtle ripple that SURGES on each flare + a warm grade with shadow lift, gritty grain and a red haze + ember dust; binary = a two-color split grade + a two-light void glow (lift painted on a neutral void base) + a slow breath + a wandering companion star (NO ripple — two stars, one steady light). Per-system variation (flare phase, split axis, breath phase, wanderer path) is seeded from the system id in SystemGenerator (content.fx), so each system is stable but distinct. Tuning is a JSON edit.",
"objectCount": { "objectCount": {
"barren": 0.10, "barren": 0.10,
@ -29,8 +29,7 @@
"habitability": 0.1, "habitability": 0.1,
"hazard": 0.1, "hazard": 0.1,
"settlements": { "settlements": {
"deepSpaceStation": { "chance": 0.25 }, "deepSpaceStation": { "chance": 0.25, "second": 0.35 }
"waypoint": { "chance": 0.35 }
} }
} }
}, },
@ -58,8 +57,7 @@
"habitability": 0.22, "habitability": 0.22,
"hazard": 0.12, "hazard": 0.12,
"settlements": { "settlements": {
"deepSpaceStation": { "chance": 0.2 }, "deepSpaceStation": { "chance": 0.2, "second": 0.3 }
"waypoint": { "chance": 0.3 }
} }
} }
}, },
@ -86,8 +84,7 @@
"habitability": 0.06, "habitability": 0.06,
"hazard": 0.18, "hazard": 0.18,
"settlements": { "settlements": {
"deepSpaceStation": { "chance": 0.3 }, "deepSpaceStation": { "chance": 0.3, "second": 0.3 }
"waypoint": { "chance": 0.3 }
} }
} }
}, },
@ -124,8 +121,7 @@
"habitability": 0.5, "habitability": 0.5,
"hazard": 0.05, "hazard": 0.05,
"settlements": { "settlements": {
"deepSpaceStation": { "chance": 0.25 }, "deepSpaceStation": { "chance": 0.25, "second": 0.4 }
"waypoint": { "chance": 0.4 }
} }
} }
}, },
@ -162,8 +158,7 @@
"habitability": 0.02, "habitability": 0.02,
"hazard": 0.35, "hazard": 0.35,
"settlements": { "settlements": {
"deepSpaceStation": { "chance": 0.2 }, "deepSpaceStation": { "chance": 0.2, "second": 0.45 }
"waypoint": { "chance": 0.45 }
} }
} }
}, },
@ -185,8 +180,7 @@
"habitability": 0.02, "habitability": 0.02,
"hazard": 0.5, "hazard": 0.5,
"settlements": { "settlements": {
"deepSpaceStation": { "chance": 0.15 }, "deepSpaceStation": { "chance": 0.15, "second": 0.25 }
"waypoint": { "chance": 0.25 }
} }
} }
} }

View File

@ -346,7 +346,7 @@ check('lerpAngle eases the short way (3→−3, no long-way sweep)', Math.abs(le
check('isFarTarget: one px past ⇒ compact', isFarTarget({ x: 5121, y: 0 }, { x: 0, y: 0 }, 5120) === true); check('isFarTarget: one px past ⇒ compact', isFarTarget({ x: 5121, y: 0 }, { x: 0, y: 0 }, 5120) === true);
check('isFarTarget: no ship ⇒ full (standalone path)', isFarTarget({ x: 99999, y: 0 }, null, 5120) === false); check('isFarTarget: no ship ⇒ full (standalone path)', isFarTarget({ x: 99999, y: 0 }, null, 5120) === false);
check('isFarTarget: farDistance off (0) ⇒ full', isFarTarget({ x: 99999, y: 0 }, { x: 0, y: 0 }, 0) === false); check('isFarTarget: farDistance off (0) ⇒ full', isFarTarget({ x: 99999, y: 0 }, { x: 0, y: 0 }, 0) === false);
check('isFarTarget: alwaysFull overrides distance (route waypoint)', isFarTarget({ x: 99999, y: 0, alwaysFull: true }, { x: 0, y: 0 }, 5120) === false); check('isFarTarget: alwaysFull overrides distance (route next stop)', isFarTarget({ x: 99999, y: 0, alwaysFull: true }, { x: 0, y: 0 }, 5120) === false);
check('isFarTarget: alwaysFull absent ⇒ distance rule holds', isFarTarget({ x: 99999, y: 0 }, { x: 0, y: 0 }, 5120) === true); check('isFarTarget: alwaysFull absent ⇒ distance rule holds', isFarTarget({ x: 99999, y: 0 }, { x: 0, y: 0 }, 5120) === true);
const fakeScene = { const fakeScene = {

View File

@ -371,7 +371,7 @@ let big;
if (s.kind === 'capital' && !String(p?.class ?? '').startsWith('capital')) anchorRulesOk = false; if (s.kind === 'capital' && !String(p?.class ?? '').startsWith('capital')) 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('the lived-in mix appears (colonies, miners, cloud bases, stations)', ['colony', 'miningStation', 'cloudBase', 'deepSpaceStation'].every((k) => seen.has(k)));
check('colonies sit on habitable worlds; cloud bases ride gas giants; capitals sit on capital worlds', anchorRulesOk); check('colonies sit on habitable worlds; cloud bases ride gas giants; capitals sit on capital worlds', anchorRulesOk);
// The all-planets-settled rule (for now): EVERY world hosts a settlement, // The all-planets-settled rule (for now): EVERY world hosts a settlement,

View File

@ -188,7 +188,7 @@ gateB.update(3000);
check('active gate: the swirl spins (rotation increases with time — clockwise)', check('active gate: the swirl spins (rotation increases with time — clockwise)',
r1 > 0 && swirlB.rotation > r1); r1 > 0 && swirlB.rotation > r1);
// --- The route-target pulse (the destination's WAYPOINT mark) ----------- // --- The route-target pulse (the route's next-stop mark) -----------
const bR = config.get('gates.route.pulse.baseRadius', 1.18); const bR = config.get('gates.route.pulse.baseRadius', 1.18);
check('fresh gate: no route pulse by default', gate.routePulse.on === false && gate.routePulse.ring.visible === false); check('fresh gate: no route pulse by default', gate.routePulse.on === false && gate.routePulse.ring.visible === false);
gate.setRouteTarget(true); gate.setRouteTarget(true);

View File

@ -174,7 +174,7 @@ const { RoutePulse } = await import(pathToFileURL(join(__dirname, '../js/entitie
const { Station } = await import(pathToFileURL(join(__dirname, '../js/entities/Station.js')).href); const { Station } = await import(pathToFileURL(join(__dirname, '../js/entities/Station.js')).href);
const scene = makeScene(false); // sheet missing → the procedural build const scene = makeScene(false); // sheet missing → the procedural build
const st = new Station(scene, { const st = new Station(scene, {
id: 'set-wp-test', kind: 'waypoint', name: 'Waypoint One', id: 'set-station-test', kind: 'deepSpaceStation', name: 'Station One',
anchor: { type: 'space' }, x: 40, y: 70, anchor: { type: 'space' }, x: 40, y: 70,
}); });
check('fresh station: the route pulse is off + hidden by default', check('fresh station: the route pulse is off + hidden by default',

View File

@ -50,17 +50,18 @@ const POOL = Math.max(1, Math.floor(g.params.neighbors ?? 8));
const POOLV = (config.get('stations.variants') ?? []).filter((f) => Number.isInteger(f) && f >= 0); const POOLV = (config.get('stations.variants') ?? []).filter((f) => Number.isInteger(f) && f >= 0);
check('data/stations.json → variants is a non-empty integer pool', POOLV.length >= 1, JSON.stringify(config.get('stations.variants'))); check('data/stations.json → variants is a non-empty integer pool', POOLV.length >= 1, JSON.stringify(config.get('stations.variants')));
// The station-bearing systems and their stamped frame (via content). // The station-bearing systems and their stamped frames (via content).
const stations = new Map(); // id → { rec, frame, name } const stations = new Map(); // id → [{ rec, frame, name }] (one per station)
for (const rec of g.records) { for (const rec of g.records) {
const c = g.ensureContent(rec.id); const c = g.ensureContent(rec.id);
for (const s of c.settlements) { for (const s of c.settlements) {
if (s.kind === 'deepSpaceStation') { if (s.kind === 'deepSpaceStation') {
stations.set(rec.id, { rec, frame: s.stationFrame, name: s.name }); if (!stations.has(rec.id)) stations.set(rec.id, []);
stations.get(rec.id).push({ rec, frame: s.stationFrame, name: s.name });
} }
} }
} }
console.log(` (galaxy: ${g.records.length} systems, ${stations.size} deep-space stations, pool [${POOLV}])`); console.log(` (galaxy: ${g.records.length} systems, ${[...stations.values()].reduce((n, a) => n + a.length, 0)} deep-space stations across ${stations.size} systems, pool [${POOLV}])`);
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
// 1. Frames are present and in-pool // 1. Frames are present and in-pool
@ -68,16 +69,33 @@ console.log(` (galaxy: ${g.records.length} systems, ${stations.size} deep-space
{ {
let inPool = true; let inPool = true;
let why = ''; let why = '';
for (const [id, st] of stations) { for (const [id, sts] of stations) {
for (const st of sts) {
if (typeof st.frame !== 'number' || !Number.isInteger(st.frame) || !POOLV.includes(st.frame)) { if (typeof st.frame !== 'number' || !Number.isInteger(st.frame) || !POOLV.includes(st.frame)) {
inPool = false; inPool = false;
if (!why) why = `${id}: frame ${st.frame} ∉ [${POOLV}]`; if (!why) why = `${id}: frame ${st.frame} ∉ [${POOLV}]`;
} }
} }
}
check('every deep-space station wears a pool variant (settlement.stationFrame)', inPool, why); check('every deep-space station wears a pool variant (settlement.stationFrame)', inPool, why);
check('the galaxy pass covers exactly the station-bearing systems', check('the galaxy pass covers exactly the station-bearing systems',
stations.size === g.stationFrames.size, stations.size === g.stationFrames.size,
`${stations.size} settlements vs ${g.stationFrames.size} pass entries`); `${stations.size} settlements vs ${g.stationFrames.size} pass entries`);
// A system holding two stations spreads them — two different variants.
let distinctOk = true;
let whyD = '';
if (POOLV.length > 1) {
for (const [id, sts] of stations) {
if (sts.length > 1) {
const frames = sts.map((s) => s.frame);
if (new Set(frames).size !== frames.length) {
distinctOk = false;
if (!whyD) whyD = `${id}: ${frames.join(', ')}`;
}
}
}
}
check('a two-station system wears two different variants', distinctOk, whyD);
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -96,23 +114,31 @@ console.log(` (galaxy: ${g.records.length} systems, ${stations.size} deep-space
let checks = 0; let checks = 0;
for (const sd of SEEDS) { for (const sd of SEEDS) {
const gg = Galaxy.create(sd); const gg = Galaxy.create(sd);
const sts = new Map(); // id → stamped frame const sts = new Map(); // id → [stamped frame, ...]
for (const rec of gg.records) for (const rec of gg.records) {
for (const s of gg.ensureContent(rec.id).settlements) for (const s of gg.ensureContent(rec.id).settlements) {
if (s.kind === 'deepSpaceStation') sts.set(rec.id, s.stationFrame); if (s.kind === 'deepSpaceStation') {
if (!sts.has(rec.id)) sts.set(rec.id, []);
sts.get(rec.id).push(s.stationFrame);
}
}
}
// Naive: independent random pool picks (seeded, per station). // Naive: independent random pool picks (seeded, per station).
const naiveF = {}; const naiveF = new Map(); // id → [frame, ...]
for (const id of sts.keys()) for (const [id, frames] of sts) {
naiveF[id] = POOLV.length === 1 naiveF.set(id, frames.map((_, i) => POOLV.length === 1
? POOLV[0] ? POOLV[0]
: POOLV[Math.floor(Rng.derive(sd, 'naive-station', id).next() * POOLV.length)]; : POOLV[Math.floor(Rng.derive(sd, 'naive-station', id, i).next() * POOLV.length)]));
for (const [id, fr] of sts) { }
for (const [id, frames] of sts) {
for (const fr of frames) {
for (const nb of gg.neighborsOf(id, POOL)) { for (const nb of gg.neighborsOf(id, POOL)) {
checks++; checks++;
const nfr = sts.get(nb.id); const nfs = sts.get(nb.id) ?? [];
if (nfr !== undefined && nfr === fr) passColl++; const nnfs = naiveF.get(nb.id) ?? [];
const nnfr = naiveF[nb.id]; if (nfs.includes(fr)) passColl++;
if (nnfr !== undefined && nnfr === fr) naiveColl++; if (nnfs.includes(fr)) naiveColl++;
}
} }
} }
} }
@ -125,7 +151,7 @@ console.log(` (galaxy: ${g.records.length} systems, ${stations.size} deep-space
// The spread should also produce ALL variants across the galaxy // The spread should also produce ALL variants across the galaxy
// (with 3 stations' worth of pool breadth and this many stations, // (with 3 stations' worth of pool breadth and this many stations,
// pigeonhole is generous) — the point is variety, not just local spread. // pigeonhole is generous) — the point is variety, not just local spread.
const used = new Set(stations.size === 0 ? [] : [...stations.values()].map((s) => s.frame)); const used = new Set([...stations.values()].flatMap((sts) => sts.map((s) => s.frame)));
check('the galaxy wears more than one station type (the pool shows variety)', check('the galaxy wears more than one station type (the pool shows variety)',
used.size >= Math.min(2, POOLV.length, stations.size), used.size >= Math.min(2, POOLV.length, stations.size),
`used [${[...used].sort()}] of pool [${POOLV}]`); `used [${[...used].sort()}] of pool [${POOLV}]`);
@ -143,10 +169,11 @@ console.log(` (galaxy: ${g.records.length} systems, ${stations.size} deep-space
// Content stamp === galaxy pass (the stamping contract). // Content stamp === galaxy pass (the stamping contract).
let stamped = true; let stamped = true;
let why = ''; let why = '';
for (const [id, st] of stations) { for (const [id, sts] of stations) {
if (g2.stationFrames.get(id) !== st.frame) { const pass = g2.stationFrames.get(id) ?? [];
if (!sts.every((st) => pass.includes(st.frame))) {
stamped = false; stamped = false;
if (!why) why = `${id}: content ${st.frame} vs pass ${g2.stationFrames.get(id)}`; if (!why) why = `${id}: content ${sts.map((s) => s.frame).join(',')} vs pass ${JSON.stringify(pass)}`;
} }
} }
check('content settlement frames === the galaxy pass (lazy === eager stamping)', stamped, why); check('content settlement frames === the galaxy pass (lazy === eager stamping)', stamped, why);

View File

@ -130,10 +130,10 @@ function verifyPick(spec, pick, why) {
const c = contentOf(pick.record.id); const c = contentOf(pick.record.id);
if (spec.kind === 'station') { if (spec.kind === 'station') {
if (!pick.station) return `missing station flag on ${pick.record.id}`; if (!pick.station) return `missing station flag on ${pick.record.id}`;
const s = c.settlements.find((x) => x.kind === 'deepSpaceStation'); const sts = c.settlements.filter((x) => x.kind === 'deepSpaceStation');
if (!s) return `${pick.record.id} has no deepSpaceStation in content`; if (sts.length === 0) return `${pick.record.id} has no deepSpaceStation in content`;
if (!pick.reskin && spec.frame != null && s.stationFrame !== spec.frame) { if (!pick.reskin && spec.frame != null && !sts.some((s) => s.stationFrame === spec.frame)) {
return `${pick.record.id} station wears ${s.stationFrame}, spec wants ${spec.frame}`; return `${pick.record.id} stations wear ${sts.map((s) => s.stationFrame).join(',')} spec wants ${spec.frame}`;
} }
return null; return null;
} }
@ -311,8 +311,9 @@ function verifyPick(spec, pick, why) {
const worn = new Set(); const worn = new Set();
for (const rec of g.records) { for (const rec of g.records) {
const c = contentOf(rec.id); const c = contentOf(rec.id);
const s = c.settlements.find((x) => x.kind === 'deepSpaceStation'); for (const s of c.settlements) {
if (s?.stationFrame != null) worn.add(s.stationFrame); if (s.kind === 'deepSpaceStation' && s.stationFrame != null) worn.add(s.stationFrame);
}
} }
let ok = true; let ok = true;
let why = ''; let why = '';
@ -419,8 +420,8 @@ function verifyPick(spec, pick, why) {
jumpGatesFor: (id) => Array.from({ length: id === sA.id ? 3 : 1 }, (_, i) => ({ id: `${id}-gate-${i}` })), jumpGatesFor: (id) => Array.from({ length: id === sA.id ? 3 : 1 }, (_, i) => ({ id: `${id}-gate-${i}` })),
}; };
const other = stationVariants.find((f) => f !== variant) ?? stationVariants[0]; const other = stationVariants.find((f) => f !== variant) ?? stationVariants[0];
gsf.stationFrames.set(sA.id, other); gsf.stationFrames.set(sA.id, [other]);
gsf.stationFrames.set(sB.id, variant); gsf.stationFrames.set(sB.id, [variant]);
const pick = pickPreviewSystem(gsf, { kind: 'station', frame: variant }); const pick = pickPreviewSystem(gsf, { kind: 'station', frame: variant });
check('ranking (station): exact beats a richer reskin (B wins)', pick?.record?.id === sB.id && pick.station === true && pick.reskin === false, `got ${pick?.record?.id} reskin=${pick?.reskin}`); check('ranking (station): exact beats a richer reskin (B wins)', pick?.record?.id === sB.id && pick.station === true && pick.reskin === false, `got ${pick?.record?.id} reskin=${pick?.reskin}`);
} else { } else {

View File

@ -165,10 +165,9 @@ player arrives. **Every planet is settled (for now)**: each world hosts
the one settlement kind that fits its class (`data/settlements.json → the one settlement kind that fits its class (`data/settlements.json →
allPlanetsSettled` + `settledKindByClass`) — `colony` on a habitable allPlanetsSettled` + `settledKindByClass`) — `colony` on a habitable
rocky world, `miningStation` over every other rocky/ice/lava world, rocky world, `miningStation` over every other rocky/ice/lava world,
`cloudBase` riding every gas giant. Plus the free-space kinds: `cloudBase` riding every gas giant. Plus the free-space kind:
`deepSpaceStation` (adrift in open space) and `waypoint` (a small beacon `deepSpaceStation` (adrift in open space — up to two per system), still
— the faint trace of a crossed galaxy), still rolled per archetype and rolled per archetype and thinned core→rim. **Barren systems** (the `objectCount` → 0 stops) are
thinned core→rim. **Barren systems** (the `objectCount` → 0 stops) are
the one exception to "lived in": they hold no planets and no stations — the one exception to "lived in": they hold no planets and no stations —
just the jump gate — and are reachable only by jumping in (strong just the jump gate — and are reachable only by jumping in (strong
connectivity keeps them on the network). Their "charted · unclaimed" report connectivity keeps them on the network). Their "charted · unclaimed" report
@ -182,8 +181,8 @@ branch is now the normal case, not a defensive fallback. Model & seams:
probabilistic layer (per-type `chance` + `needs` — the code path is probabilistic layer (per-type `chance` + `needs` — the code path is
kept behind the flag). kept behind the flag).
- **Per-type free-space rates** — `types.<id>.attributes.settlements` in - **Per-type free-space rates** — `types.<id>.attributes.settlements` in
`data/systems.json`: `chance` per free-space kind (deep-space station, `data/systems.json`: `deepSpaceStation.chance` of a station (+ `second`
waypoint). Same attribute-driven pattern as everything else. of a second one). Same attribute-driven pattern as everything else.
- **Home→far gradient** — `galaxy.settlements.gradient` in - **Home→far gradient** — `galaxy.settlements.gradient` in
`data/galaxy.json`: the settled heart (the home corner, `d = 0`) is `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 denser (factor 1.0), the deep corner (`d = 1`) is thinner (clamped to

View File

@ -81,7 +81,7 @@ export function goodsTextureReady(scene) {
* The economic class of a settlement — the single key the catalog prices off. * The economic class of a settlement — the single key the catalog prices off.
* planet → its own class (terran/arid/rocky/gas/ice/lava), or 'capital' * planet → its own class (terran/arid/rocky/gas/ice/lava), or 'capital'
* for a capital world (class capital*). * for a capital world (class capital*).
* station → 'station' (a deepSpaceStation trades; a waypoint does not). * station → 'station' (a deepSpaceStation — the only free-space kind).
* @returns {'terran'|'arid'|'rocky'|'gas'|'ice'|'lava'|'station'|'capital'|null} * @returns {'terran'|'arid'|'rocky'|'gas'|'ice'|'lava'|'station'|'capital'|null}
*/ */
export function economicClass(planet = null, settlement = null) { export function economicClass(planet = null, settlement = null) {
@ -493,7 +493,7 @@ export function buildEconomy(galaxy) {
const comp = rollSystemComposition(galaxy.seed, record, isHome, spec, density, attr, stamped); const comp = rollSystemComposition(galaxy.seed, record, isHome, spec, density, attr, stamped);
const set = new Set(comp.classes); const set = new Set(comp.classes);
if (isHome) set.add(homeCls); // the home world (a terran body by rule) if (isHome) set.add(homeCls); // the home world (a terran body by rule)
if (comp.deepSpace) set.add('station'); if ((comp.stations ?? 0) > 0) set.add('station');
if (record.capital) set.add('capital'); if (record.capital) set.add('capital');
classBySystem.set(id, set); classBySystem.set(id, set);
} }

View File

@ -35,7 +35,7 @@ import { Planet } from './Planet.js';
* *
* ROUTE TARGET (data/gates.json → route.pulse): while a destination is * ROUTE TARGET (data/gates.json → route.pulse): while a destination is
* set and this gate is the route's NEXT STOP in the current system * set and this gate is the route's NEXT STOP in the current system
* (GameScene.routeNextGate — the destination's WAYPOINT here), the scene * (GameScene.routeNextGate — the route's NEXT STOP here), the scene
* marks the gate (setRouteTarget) and update() animates the FULL route * marks the gate (setRouteTarget) and update() animates the FULL route
* pulse around it (js/entities/RoutePulse.js — the shared halo, also * pulse around it (js/entities/RoutePulse.js — the shared halo, also
* worn in a subdued form by the destination OBJECT, Planet/Station): * worn in a subdued form by the destination OBJECT, Planet/Station):
@ -74,7 +74,7 @@ export class JumpGate extends Phaser.GameObjects.Container {
this.theme = this._theme(); this.theme = this._theme();
this.ringBody = null; this.ringBody = null;
this.swirl = null; this.swirl = null;
this.routePulse = null; // the destination's waypoint mark (GameScene → setRouteTarget) this.routePulse = null; // the route's next-stop mark (GameScene → setRouteTarget)
this.setDepth(o.depth ?? 5); this.setDepth(o.depth ?? 5);
this.rotation = gate.rotation ?? 0; // face the destination star this.rotation = gate.rotation ?? 0; // face the destination star
@ -241,9 +241,9 @@ export class JumpGate extends Phaser.GameObjects.Container {
/** /**
* ROUTE-TARGET state (GameScene.update, each frame): true while this * ROUTE-TARGET state (GameScene.update, each frame): true while this
* gate is the route's NEXT STOP — the destination's waypoint in the * gate is the route's NEXT STOP in the current system (routeNextGate).
* current system (routeNextGate). True shows the FULL pulse (update() * True shows the FULL pulse (update() animates it); false clears +
* animates it); false clears + hides it. A no-op when unchanged. * hides it. A no-op when unchanged.
*/ */
setRouteTarget(on) { setRouteTarget(on) {
this.routePulse?.set(on); this.routePulse?.set(on);
@ -252,7 +252,7 @@ export class JumpGate extends Phaser.GameObjects.Container {
/** The swirl spins + breathes (active); the fallback art drifts. (GameScene.update drives this.) */ /** The swirl spins + breathes (active); the fallback art drifts. (GameScene.update drives this.) */
update(time) { update(time) {
const t = time / 1000; const t = time / 1000;
// The route-target pulse (the destination's waypoint in this system). // The route-target pulse (the route's next stop in this system).
// It runs even on a DORMANT gate — the player still has to find the // It runs even on a DORMANT gate — the player still has to find the
// gate to use it. (A no-op while unmarked.) // gate to use it. (A no-op while unmarked.)
this.routePulse?.draw(t); this.routePulse?.draw(t);

View File

@ -5,12 +5,12 @@
* Shape: a steady target ring at `baseRadius` × the object's keepout * Shape: a steady target ring at `baseRadius` × the object's keepout
* radius, plus two radar ripples (staggered half a period) that expand * radius, plus two radar ripples (staggered half a period) that expand
* a further ×`expand` past it and fade out. All the geometry scales with * a further ×`expand` past it and fade out. All the geometry scales with
* the object's keepout radius, so it reads the same on a small waypoint * the object's keepout radius, so it reads the same on a small station
* beacon and a gas giant. * and a gas giant.
* *
* Who wears it (GameScene.update marks exactly one object per frame — * Who wears it (GameScene.update marks exactly one object per frame —
* the route's NEXT STOP): * the route's NEXT STOP):
* · the ROUTE GATE (the destination's waypoint in an intermediate * · the ROUTE GATE (the destination's next stop in an intermediate
* system — GameScene.routeNextGate) — the FULL pulse * system — GameScene.routeNextGate) — the FULL pulse
* (data/gates.json → route.pulse), via JumpGate.setRouteTarget * (data/gates.json → route.pulse), via JumpGate.setRouteTarget
* · the DESTINATION OBJECT in the destination system (a planet or a * · the DESTINATION OBJECT in the destination system (a planet or a

View File

@ -7,30 +7,27 @@ import { Planet } from './Planet.js';
/** /**
* A SPACE STATION — a free-space settlement (SystemGenerator: * A SPACE STATION — a free-space settlement (SystemGenerator:
* `anchor.type === 'space'`, kinds `deepSpaceStation` / `waypoint`) * `anchor.type === 'space'`, kind `deepSpaceStation` — the only
* rendered as a world object: * free-space kind) rendered as a world object:
* *
* DEEP-SPACE STATION — a variant from the shared spritesheet * A variant from the shared spritesheet (data/stations.json →
* (data/stations.json → texture: spacestations.png, 256×256 frames; * texture: spacestations.png, 256×256 frames; `variants` lists the
* `variants` lists the frames in use, today 0..2 — the first three * frames in use, today 0..2 — the first three frames). Which variant
* frames). Which variant it wears comes from the galaxy-wide spread * it wears comes from the galaxy-wide spread pass
* pass (js/galaxy/StationFrames.js, stamped as `settlement.stationFrame`): * (js/galaxy/StationFrames.js, stamped as `settlement.stationFrame`):
* the frame avoids what the NEAREST stars already wear, so the same * the frame avoids what the NEAREST stars already wear, so the same
* station type is spread across the galaxy instead of clustering. The * station type is spread across the galaxy instead of clustering. The
* SAME frame picks its landing/take-off clips (data/landing.json → * SAME frame picks its landing/take-off clips (data/landing.json →
* stationVideos) — a station always lands with the clip matching the * stationVideos) — a station always lands with the clip matching the
* art it is drawn with. * art it is drawn with.
* *
* WAYPOINT — a small nav beacon: a base, a mast, a breathing light
* (procedural — beacons have no sheet art).
*
* Solid like a planet (the ship keeps its clearance — the same plain * Solid like a planet (the ship keeps its clearance — the same plain
* circle rule, GameScene.solids), discoverable (a compass arrow + the * circle rule, GameScene.solids), discoverable (a compass arrow + the
* discovery toast, at the scale of its keepout), and a comms target — * discovery toast, at the scale of its keepout), and a comms target —
* click it and the comms panel opens there (the click is NOT a * click it and the comms panel opens there (the click is NOT a
* fly-here — the ship stays put; GameScene.openCommsPanel). Deep-space * fly-here — the ship stays put; GameScene.openCommsPanel). A station
* stations are also a PORT: REQUEST LANDING launches the surface * is a PORT: REQUEST LANDING launches the surface sequence
* sequence (GameScene.startLanding → SurfaceScene, the station's clips). * (GameScene.startLanding → SurfaceScene, the station's clips).
* *
* The deep-space station's art fills its frame, so it is scaled to 2×size * The deep-space station's art fills its frame, so it is scaled to 2×size
* (size = the keepout RADIUS — data/stations.json → kinds.<kind>.size): * (size = the keepout RADIUS — data/stations.json → kinds.<kind>.size):
@ -71,17 +68,14 @@ export class Station extends Phaser.GameObjects.Container {
this.discoveryId = settlement.id; this.discoveryId = settlement.id;
this.discoveryName = settlement.name; this.discoveryName = settlement.name;
this.size = config.get(`stations.kinds.${this.kind}.size`, this.kind === 'waypoint' ? 46 : 108); this.size = config.get(`stations.kinds.${this.kind}.size`, 108);
this.radius = this.size; // the keepout circle's radius this.radius = this.size; // the keepout circle's radius
this.bound = this.size; // the discovery radius (the compass/toast scale) this.bound = this.size; // the discovery radius (the compass/toast scale)
this.clearance = config.get('stations.shipClearance', 50); this.clearance = config.get('stations.shipClearance', 50);
this.ringSpeed = config.get(`stations.kinds.${this.kind}.ringSpeed`, 0.08); this.ringSpeed = config.get(`stations.kinds.${this.kind}.ringSpeed`, 0.08);
this.ringBody = null; this.ringBody = null;
this.sprite = null; this.sprite = null;
// The spacestations.png frame this station wears (null = no sheet art: // The spacestations.png frame this station wears (null = no pool yet).
// waypoints, or a pool with no frames yet).
this.sheetFrame = null;
if (this.kind === 'deepSpaceStation') {
const v = settlement.stationFrame; const v = settlement.stationFrame;
if (Number.isInteger(v) && v >= 0) { if (Number.isInteger(v) && v >= 0) {
this.sheetFrame = v; // the spread pass's pick (data/stations.json → variants) this.sheetFrame = v; // the spread pass's pick (data/stations.json → variants)
@ -93,15 +87,13 @@ export class Station extends Phaser.GameObjects.Container {
const list = Array.isArray(pool) && pool.length > 0 ? pool : [0]; const list = Array.isArray(pool) && pool.length > 0 ? pool : [0];
this.sheetFrame = Rng.derive('orbit-station-variant', settlement.id).pick(list) ?? 0; this.sheetFrame = Rng.derive('orbit-station-variant', settlement.id).pick(list) ?? 0;
} }
}
this.setDepth(o.depth ?? 5); this.setDepth(o.depth ?? 5);
this.build(); this.build();
} }
/** /**
* The variant art (deep-space stations) — or the procedural hardware * The variant art — or the procedural hardware fallback (sheet missing).
* (waypoints, and the deep-space fallback when the sheet is missing).
*/ */
build() { build() {
// The route-target pulse (data/gates.json → route.objectPulse): when // The route-target pulse (data/gates.json → route.objectPulse): when
@ -146,19 +138,6 @@ export class Station extends Phaser.GameObjects.Container {
const g = scene.add.graphics(); const g = scene.add.graphics();
this.add(g); this.add(g);
if (this.kind === 'waypoint') {
// A small nav beacon: a base, a mast, a breathing light.
g.fillStyle(0x4a3a2c, 1);
g.fillRect(-S * 0.22, S * 0.28, S * 0.44, S * 0.16); // the base
g.fillStyle(0x57493a, 1);
g.fillRect(-S * 0.06, -S * 0.45, S * 0.12, S * 0.78); // the mast
g.fillStyle(0x6e3a1f, 0.5); // a rust band
g.fillRect(-S * 0.06, 0, S * 0.12, S * 0.18);
g.lineStyle(1.5, 0x2c2118, 1);
g.strokeCircle(0, -S * 0.5, S * 0.13);
g.fillStyle(0x3a2f26, 1);
g.fillCircle(0, -S * 0.5, S * 0.1);
} else {
// ---- Deep-space station ----------------------------------------- // ---- Deep-space station -----------------------------------------
const wingX0 = S * 0.24; const wingX0 = S * 0.24;
const wingX1 = S * 0.85; const wingX1 = S * 0.85;
@ -215,14 +194,11 @@ export class Station extends Phaser.GameObjects.Container {
g.fillStyle(0x6e3a1f, 0.5); g.fillStyle(0x6e3a1f, 0.5);
g.fillCircle(hubR * 0.5, -hubR * 0.35, 3); g.fillCircle(hubR * 0.5, -hubR * 0.35, 3);
g.fillCircle(-hubR * 0.4, hubR * 0.5, 2.5); g.fillCircle(-hubR * 0.4, hubR * 0.5, 2.5);
}
// The beacon light (both kinds) — the thing that says "occupied". // The beacon light — the thing that says "occupied".
const hy = this.kind === 'waypoint' ? -S * 0.5 : -S * 0.26; const hy = -S * 0.26;
const hue = this.kind === 'waypoint' ? 0xa8ffc4 : 0xff7a5c; this.beaconGlow = scene.add.circle(0, hy, 7, 0xff5a3c, 0.2);
const glow = this.kind === 'waypoint' ? 0x7dffb0 : 0xff5a3c; this.beacon = scene.add.circle(0, hy, 2.6, 0xff7a5c, 1);
this.beaconGlow = scene.add.circle(0, hy, this.kind === 'waypoint' ? S * 0.24 : 7, glow, 0.2);
this.beacon = scene.add.circle(0, hy, this.kind === 'waypoint' ? S * 0.07 : 2.6, hue, 1);
this.add([this.beaconGlow, this.beacon]); this.add([this.beaconGlow, this.beacon]);
} }

View File

@ -89,7 +89,7 @@ export class Galaxy {
this.planetFrames = new Map(); // id → [frame, ...] — the frame-diversity pass this.planetFrames = new Map(); // id → [frame, ...] — the frame-diversity pass
this.planetClasses = null; // id → [class, ...] — the even-mix deal (or null, balance off) this.planetClasses = null; // id → [class, ...] — the even-mix deal (or null, balance off)
this.homeWorldFrame = null; // the starting system's home world frame this.homeWorldFrame = null; // the starting system's home world frame
this.stationFrames = new Map(); // id → frame — the station-variant spread pass this.stationFrames = new Map(); // id → [frame, ...] — the station-variant spread pass
this.name = NameGenerator.galaxy(Rng.derive(seed, 'galaxy', 'name')); this.name = NameGenerator.galaxy(Rng.derive(seed, 'galaxy', 'name'));
} }
@ -338,12 +338,12 @@ export class Galaxy {
this.homeWorldFrame = homeFrame; this.homeWorldFrame = homeFrame;
// STATION VARIANT SPREAD (js/galaxy/StationFrames.js): each system's // STATION VARIANT SPREAD (js/galaxy/StationFrames.js): each system's
// deep-space station wears one of the spacestations.png variants // deep-space stations (up to two) wear spacestations.png variants
// (data/stations.json → variants) — the frame avoids what the // (data/stations.json → variants) — a frame avoids what the NEAREST
// NEAREST stars already wear, so the same station type is spread // stars already wear, so the same station type is spread across the
// across the galaxy instead of clustering in one region. Fixed // galaxy instead of clustering in one region. Fixed roster order ⇒
// roster order ⇒ visit-order independent; read back by the content // visit-order independent; read back by the content generator
// generator (settlement.stationFrame) — lazy === eager preserved. // (settlement.stationFrame) — lazy === eager preserved.
const { frames: stationFrames } = assignStationFrames({ const { frames: stationFrames } = assignStationFrames({
seed: this.seed, seed: this.seed,
records, records,
@ -351,7 +351,7 @@ export class Galaxy {
params: this.params, params: this.params,
neighborsOf: (id) => this.neighborsOf(id, pool8), neighborsOf: (id) => this.neighborsOf(id, pool8),
}); });
this.stationFrames = stationFrames; // Map id → frame (station-bearing systems) this.stationFrames = stationFrames; // Map id → [frame, ...] (station-bearing systems)
// TRADE ECONOMY (js/economy/Economy.js): the galaxy-level trade map — // TRADE ECONOMY (js/economy/Economy.js): the galaxy-level trade map —
// per-system economic classes + each good's hop-distance to its nearest // per-system economic classes + each good's hop-distance to its nearest

View File

@ -89,7 +89,7 @@ export function assignPlanetClasses({ seed, records, homeId, params, typeDefs =
const comp = rollSystemComposition( const comp = rollSystemComposition(
seed, rec, false, attr.settlements ?? {}, settlementDensity(shim, rec), attr, seed, rec, false, attr.settlements ?? {}, settlementDensity(shim, rec), attr,
); );
const slots = Math.max(0, Number(comp.objects) - Number(comp.deepSpace) - Number(comp.waypoint)); const slots = Math.max(0, Number(comp.objects) - Number(comp.stations));
if (slots === 0) continue; if (slots === 0) continue;
demand.push({ demand.push({
id: rec.id, id: rec.id,

View File

@ -15,10 +15,11 @@ import { rollSystemComposition, settlementDensity } from './SystemGenerator.js';
* galaxy's neighbor pool, data/galaxy.json → neighbors), so the same * galaxy's neighbor pool, data/galaxy.json → neighbors), so the same
* station type reappears only far away. * station type reappears only far away.
* *
* Only DEEP-SPACE STATIONS take a variant — a system holds at most one * Only DEEP-SPACE STATIONS take a variant — and a system holds up to TWO
* (rollSystemComposition rolls a boolean), and waypoints keep their * of them (rollSystemComposition rolls a count): each station picks its
* beacon look (no sheet art for them). The landing handoff reads the * own frame, avoiding the frames its NEAREST stars already wear AND, for
* same frame (settlement.stationFrame → data/landing.json → * the second station, its own system's first frame. The landing handoff
* reads the same frame (settlement.stationFrame → data/landing.json →
* stationVideos), so a station always lands with the clip matching the * stationVideos), so a station always lands with the clip matching the
* art it is drawn with. * art it is drawn with.
* *
@ -34,9 +35,9 @@ import { rollSystemComposition, settlementDensity } from './SystemGenerator.js';
* (SystemGenerator.rollSystemComposition) — guaranteed to agree, since * (SystemGenerator.rollSystemComposition) — guaranteed to agree, since
* the forks are pure functions of (seed, id, type, density). * the forks are pure functions of (seed, id, type, density).
* *
* Cost: one pool scan per station-bearing system — trivial at this * Cost: one pool scan per station per station-bearing system — trivial
* galaxy size (data/galaxy.json → systemCount), computed once at galaxy * at this galaxy size (data/galaxy.json → systemCount), computed once at
* build time (js/galaxy/Galaxy.js). * galaxy build time (js/galaxy/Galaxy.js).
*/ */
export function assignStationFrames({ seed, records, homeId, params, neighborsOf, typeDefs = null }) { export function assignStationFrames({ seed, records, homeId, params, neighborsOf, typeDefs = null }) {
const defs = typeDefs ?? config.get('systems.types', {}); const defs = typeDefs ?? config.get('systems.types', {});
@ -49,10 +50,10 @@ export function assignStationFrames({ seed, records, homeId, params, neighborsOf
const pool = Array.isArray(raw) const pool = Array.isArray(raw)
? raw.filter((f) => Number.isInteger(f) && f >= 0) ? raw.filter((f) => Number.isInteger(f) && f >= 0)
: []; : [];
const frames = new Map(); // id → frame (only station-bearing systems) const frames = new Map(); // id → [frame, ...] (one per station, draw order)
if (pool.length === 0) return { frames }; if (pool.length === 0) return { frames };
const assigned = new Map(); // id → frame (assigned so far) const assigned = new Map(); // id → [frame, ...] (assigned so far)
// FIXED spatial order (x, then y): a pure function of the seeded // FIXED spatial order (x, then y): a pure function of the seeded
// roster, so the assignment never depends on generation timing or // roster, so the assignment never depends on generation timing or
@ -63,33 +64,40 @@ export function assignStationFrames({ seed, records, homeId, params, neighborsOf
const isHome = rec.id === homeId; const isHome = rec.id === homeId;
const attr = defs[rec.type]?.attributes ?? {}; const attr = defs[rec.type]?.attributes ?? {};
const density = settlementDensity(shim, rec); const density = settlementDensity(shim, rec);
const { deepSpace } = rollSystemComposition( const { stations } = rollSystemComposition(
seed, rec, isHome, attr.settlements ?? {}, density, attr, seed, rec, isHome, attr.settlements ?? {}, density, attr,
); );
if (!deepSpace) continue; // no deep-space station here — nothing to frame if (!stations) continue; // no deep-space station here — nothing to frame
const neighbors = (typeof neighborsOf === 'function' ? neighborsOf(rec.id) : []) ?? []; const neighbors = (typeof neighborsOf === 'function' ? neighborsOf(rec.id) : []) ?? [];
// usage(frame) = how many ALREADY-ASSIGNED neighbor stars wear it // One frame PER STATION (a system holds up to two): a system never
// (the spread objective — the same lexicographic pick as the planet // wears the same face twice (hard — the original "one station per
// pass, minus the "own" axis: a system never wears two stations). // system" invariant, now per slot), and among the allowed faces each
const usage = (f) => { // pick MINIMIZES usage among the NEIGHBORS' stations (the spread
// objective — the same lexicographic pick as the planet pass).
// Remaining ties broken by a deterministic derived pick (seeded, per
// slot).
const picked = [];
for (let slot = 0; slot < stations; slot++) {
const allowed = pool.filter((f) => !picked.includes(f));
const cand = allowed.length > 0 ? allowed : pool; // 1-variant pool ⇒ repeat
const nbUsage = (f) => {
let nb = 0; let nb = 0;
for (const n of neighbors) { for (const n of neighbors) {
if (assigned.get(n.id) === f) nb++; const nfs = assigned.get(n.id);
if (nfs) nb += nfs.filter((x) => x === f).length;
} }
return nb; return nb;
}; };
// The least-used face among the neighbors; ties broken by a
// deterministic derived pick (seed, id).
let best = Infinity; let best = Infinity;
for (const f of pool) best = Math.min(best, usage(f)); for (const f of cand) best = Math.min(best, nbUsage(f));
const tied = pool.filter((f) => usage(f) === best); const tied = cand.filter((f) => nbUsage(f) === best);
const frame = tied.length === 1 ? tied[0] : Rng.derive(seed, 'station-frames', rec.id).pick(tied); picked.push(tied.length === 1 ? tied[0] : Rng.derive(seed, 'station-frames', rec.id, slot).pick(tied));
}
frames.set(rec.id, frame); frames.set(rec.id, picked);
assigned.set(rec.id, frame); assigned.set(rec.id, picked);
} }
return { frames }; return { frames };

View File

@ -233,9 +233,9 @@ export function systemChartSnapshot(systemId, content, o = {}) {
kind: 'station', kind: 'station',
x: s.x, x: s.x,
y: s.y, y: s.y,
radius: config.get(`stations.kinds.${kind}.size`, kind === 'waypoint' ? 46 : 108), radius: config.get(`stations.kinds.${kind}.size`, 108),
name: s.name, name: s.name,
typeLabel: kind === 'waypoint' ? 'Waypoint' : 'Station', typeLabel: 'Station',
discovered: isDisc(s.id), discovered: isDisc(s.id),
}); });
} }

View File

@ -119,12 +119,11 @@ const DEG = Math.PI / 180;
* `content.homeFrame` here; the renderer prefers those over a random pick. * `content.homeFrame` here; the renderer prefers those over a random pick.
* *
* STATION VARIANTS (js/galaxy/StationFrames.js): the same spreading for * STATION VARIANTS (js/galaxy/StationFrames.js): the same spreading for
* the deep-space stations — each system's free-space station wears one of * the deep-space stations — each system's free-space stations (up to two)
* the spacestations.png variants (data/stations.json → variants), chosen * wear spacestations.png variants (data/stations.json → variants), chosen
* to avoid what the NEAREST stars already wear. The pass stamps * to avoid what the NEAREST stars already wear (and each other). The pass
* `settlement.stationFrame` here (the landing handoff reads the same * stamps `settlement.stationFrame` here (the landing handoff reads the same
* frame — data/landing.json → stationVideos); waypoints keep their beacon * frame — data/landing.json → stationVideos).
* look and take no frame.
* *
* Place identity (the reputation/trading/faction keys): every planet and * Place identity (the reputation/trading/faction keys): every planet and
* settlement carries a stable `id`, seed-deterministic because it is * settlement carries a stable `id`, seed-deterministic because it is
@ -266,7 +265,7 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
Rng.derive(galaxy.seed, 'system', record.id, 'names', 'stations') Rng.derive(galaxy.seed, 'system', record.id, 'names', 'stations')
), ),
density: settlementDensity(galaxy, record), density: settlementDensity(galaxy, record),
stations: { deepSpace: composition.deepSpace, waypoint: composition.waypoint }, stations: composition.stations,
isHome, isHome,
// FACTION ownership (data/factions.json — the roster's faction pass): // FACTION ownership (data/factions.json — the roster's faction pass):
// the system's territory (null = neutral wild space) claims its // the system's territory (null = neutral wild space) claims its
@ -276,14 +275,16 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
}); });
// STATION VARIANTS (js/galaxy/StationFrames.js — the galaxy-wide // STATION VARIANTS (js/galaxy/StationFrames.js — the galaxy-wide
// spread pass): stamp the assigned spacestations.png frame on this // spread pass): stamp the assigned spacestations.png frames on this
// system's deep-space station, so the same station type is spread // system's deep-space stations (up to two, in draw order), so the same
// across the galaxy (the renderer prefers settlement.stationFrame over // station type is spread across the galaxy (the renderer prefers
// a fallback pick; the landing handoff reads the same frame). // settlement.stationFrame over a fallback pick; the landing handoff
const stationFrame = galaxy?.stationFrames?.get(record.id); // reads the same frame).
if (typeof stationFrame === 'number') { const stationFrames = galaxy?.stationFrames?.get(record.id);
if (Array.isArray(stationFrames)) {
let i = 0;
for (const s of settlements) { for (const s of settlements) {
if (s.kind === 'deepSpaceStation') s.stationFrame = stationFrame; if (s.kind === 'deepSpaceStation' && i < stationFrames.length) s.stationFrame = stationFrames[i++];
} }
} }
@ -437,7 +438,7 @@ export function generateSystemContent(galaxy, record, typeDefs = null) {
* consecutive gap : neighborMin..(level-1 − edgeMargin) (2048..4608 px) * consecutive gap : neighborMin..(level-1 − edgeMargin) (2048..4608 px)
* *
* What counts as an object: planets (all of them) and free-space * What counts as an object: planets (all of them) and free-space
* settlements (anchor.type 'space' — deep-space stations, waypoints). * settlements (anchor.type 'space' — deep-space stations).
* Planet-bound settlements are features OF their planet (a colony sits on * Planet-bound settlements are features OF their planet (a colony sits on
* it) and so are not layout objects of their own. The central body is the * it) and so are not layout objects of their own. The central body is the
* origin (the game renders the home world there in the starting system; * origin (the game renders the home world there in the starting system;
@ -1321,15 +1322,16 @@ function mixTint(hex, strength) {
* data/galaxy.json → settlements.gradient), in one deterministic roll: * data/galaxy.json → settlements.gradient), in one deterministic roll:
* *
* home system → fixed: 2 planets (gas giant + rocky) beside the home * home system → fixed: 2 planets (gas giant + rocky) beside the home
* world, + at most one free-space station (deepSpace only — * world, + at most one free-space station (by design: the
* by design: the home system stays a small start the player * home system stays a small start the player expands out
* expands out of; the arc layout could seat more) * of; the arc layout could seat more)
* every other → 1) the FINAL object count N from the configured table * every other → 1) the FINAL object count N from the configured table
* (0 = barren, then 2/3/4/5 at the configured weights — the * (0 = barren, then 2/3/4/5 at the configured weights — the
* distribution is on the FINAL count, by design); * distribution is on the FINAL count, by design);
* 2) the free-space stations (0–2, per-type odds × the * 2) the free-space deep-space stations (0–2 — the first at
* density gradient; at most 2, which never exceeds the * the type's chance, the second at `second`; per-type
* minimum non-barren budget of 2); * odds × the density gradient; at most 2, which never
* exceeds the minimum non-barren budget of 2);
* 3) the planets — N − stations classes, from the STAMPED * 3) the planets — N − stations classes, from the STAMPED
* GALAXY-DECK DEAL when `stampedClasses` is given * GALAXY-DECK DEAL when `stampedClasses` is given
* (data/systems.json → planetClassBalance — the even * (data/systems.json → planetClassBalance — the even
@ -1342,13 +1344,15 @@ function mixTint(hex, strength) {
* their inputs, so they always agree. The stamped classes are themselves * their inputs, so they always agree. The stamped classes are themselves
* a pure function of (seed, roster, config) (stamped once at galaxy * a pure function of (seed, roster, config) (stamped once at galaxy
* build), so lazy (on-arrival) === eager (generateAll) holds for them. * build), so lazy (on-arrival) === eager (generateAll) holds for them.
* → { objects, deepSpace, waypoint, classes } * → { objects, stations, classes } (stations = the free-space deep-space
* station count, 0–2)
*/ */
export function rollSystemComposition(seed, record, isHome, spec, density, attr, stampedClasses = null) { export function rollSystemComposition(seed, record, isHome, spec, density, attr, stampedClasses = null) {
if (isHome) { if (isHome) {
const rng = Rng.derive(seed, 'system', record.id, 'settlements'); const rng = Rng.derive(seed, 'system', record.id, 'settlements');
const deepSpace = rng.chance((spec?.deepSpaceStation?.chance ?? 0.12) * density); // At most one free-space station — the home band can't hold 5 points.
return { objects: 2, deepSpace, waypoint: false, classes: ['gas', 'rocky'] }; const stations = rng.chance((spec?.deepSpaceStation?.chance ?? 0.12) * density) ? 1 : 0;
return { objects: 2, stations, classes: ['gas', 'rocky'] };
} }
// 1) The final object count — the configured composition table. // 1) The final object count — the configured composition table.
const oc = config.get('systems.objectCount', { const oc = config.get('systems.objectCount', {
@ -1365,27 +1369,23 @@ export function rollSystemComposition(seed, record, isHome, spec, density, attr,
// BARREN (N = 0): a jump-gate-only system — no stations, no planets. // BARREN (N = 0): a jump-gate-only system — no stations, no planets.
if (objects === 0) { if (objects === 0) {
return { objects, deepSpace: false, waypoint: false, classes: [] }; return { objects, stations: 0, classes: [] };
} }
// 2) The free-space stations (≤ 2 — never more than the min budget of 2). // 2) The free-space deep-space stations (≤ 2 — never more than the min
// budget of 2): the first at the type's chance, the second at its own
// (the old waypoint slot — now a station too).
const rngS = Rng.derive(seed, 'system', record.id, 'settlements'); const rngS = Rng.derive(seed, 'system', record.id, 'settlements');
const deepSpace = rngS.chance((spec?.deepSpaceStation?.chance ?? 0.12) * density); let stations = 0;
const waypoint = rngS.chance((spec?.waypoint?.chance ?? 0.2) * density); if (rngS.chance((spec?.deepSpaceStation?.chance ?? 0.12) * density)) stations++;
if (rngS.chance((spec?.deepSpaceStation?.second ?? 0.2) * density)) stations++;
// 3) The planets fill the rest of the budget (N − stations ≥ 0). // 3) The planets fill the rest of the budget (N − stations ≥ 0).
// Every non-barren system keeps AT LEAST ONE PLANET: a gate anchors // Every non-barren system keeps AT LEAST ONE PLANET: a gate anchors
// its tether to a world (layoutGates), and the player must be able to // its tether to a world (layoutGates), and the player must be able to
// reach a planet and build out from it — a station-only budget // reach a planet and build out from it — a station-only budget
// (N = 2 stations) demotes one station to a planet. // (N = 2 stations) demotes one station to a planet.
let stations = Number(deepSpace) + Number(waypoint); stations = Math.min(stations, objects - 1);
let wp = waypoint;
let ds = deepSpace;
while (stations >= objects && stations > 0) {
if (wp) wp = false;
else ds = false;
stations = Number(ds) + Number(wp);
}
// The classes: the GALAXY-DECK DEAL when stamped (the even galaxy-wide // The classes: the GALAXY-DECK DEAL when stamped (the even galaxy-wide
// mix — PlanetClasses.js), else the per-type weighted draws (standalone // mix — PlanetClasses.js), else the per-type weighted draws (standalone
// callers: dev tools, tests without a galaxy object). A stamped list of // callers: dev tools, tests without a galaxy object). A stamped list of
@ -1397,7 +1397,7 @@ export function rollSystemComposition(seed, record, isHome, spec, density, attr,
Array.isArray(stampedClasses) && stampedClasses.length === count Array.isArray(stampedClasses) && stampedClasses.length === count
? stampedClasses.slice() ? stampedClasses.slice()
: Array.from({ length: count }, () => rngP.weighted(classWeights, 'rocky')); : Array.from({ length: count }, () => rngP.weighted(classWeights, 'rocky'));
return { objects, deepSpace: ds, waypoint: wp, classes }; return { objects, stations, classes };
} }
/** /**
@ -1411,7 +1411,7 @@ export function rollPlanetClasses(seed, record, isHome, attr, spec, density) {
/** Backwards-compatible roll (tests/tools) — the stations of a system. */ /** Backwards-compatible roll (tests/tools) — the stations of a system. */
export function rollStationCount(seed, record, isHome, spec, density) { export function rollStationCount(seed, record, isHome, spec, density) {
const c = rollSystemComposition(seed, record, isHome, spec, density, {}); const c = rollSystemComposition(seed, record, isHome, spec, density, {});
return { deepSpace: c.deepSpace, waypoint: c.waypoint, count: Number(c.deepSpace) + Number(c.waypoint) }; return { count: c.stations };
} }
/** /**
@ -1432,8 +1432,8 @@ export function settlementDensity(galaxy, record) {
/** /**
* Draw settlements for one system. Stable draw order: planet-bound * Draw settlements for one system. Stable draw order: planet-bound
* settlements in orbital order, then free-floating (deep-space station, * settlements in orbital order, then free-floating (the deep-space
* waypoint). Every roll goes through the system's own stream. Each * stations). Every roll goes through the system's own stream. Each
* settlement gets a stable `id` (`<systemId>-s<n>`, n = its position in * settlement gets a stable `id` (`<systemId>-s<n>`, n = its position in
* this order) — the reputation/factions key. `owner` is the FACTION * this order) — the reputation/factions key. `owner` is the FACTION
* SEAM (data/factions.json): the system's territory (systemFaction, from * SEAM (data/factions.json): the system's territory (systemFaction, from
@ -1492,13 +1492,12 @@ function generateSettlements({ rng, systemId, kindDefs, spec, planets, stationDe
} }
} }
// Free-floating, out in the dark — the PRE-ROLLED flags (rollStationCount: // Free-floating, out in the dark — the PRE-ROLLED count (per-type odds
// per-type odds × the home→far density gradient; the home system never // × the home→far density gradient; the home system never rolls a second
// rolls a waypoint — its band can't hold 5 points). A BARREN system // station — its band can't hold 5 points). A BARREN system
// (objectCount → 0) rolled no stations — it is a jump-gate-only stop, // (objectCount → 0) rolled no stations — it is a jump-gate-only stop,
// deliberately. // deliberately.
if (stations?.deepSpace) make('deepSpaceStation', { type: 'space' }, systemFaction); for (let i = 0; i < (stations ?? 0); i++) make('deepSpaceStation', { type: 'space' }, systemFaction);
if (stations?.waypoint) make('waypoint', { type: 'space' }, systemFaction);
return out; return out;
} }
@ -1528,6 +1527,6 @@ function needs(planet, needsList, defaults) {
function logPopulation(rng, pop) { function logPopulation(rng, pop) {
const min = pop?.min ?? 1; const min = pop?.min ?? 1;
const max = Math.max(min, pop?.max ?? min); const max = Math.max(min, pop?.max ?? min);
if (min <= 0 && rng.chance(0.5)) return 0; // e.g. unmanned waypoints if (min <= 0 && rng.chance(0.5)) return 0; // some kinds roll to zero
return Math.round(Math.exp(rng.range(Math.log(Math.max(1, min)), Math.log(max)))); return Math.round(Math.exp(rng.range(Math.log(Math.max(1, min)), Math.log(max))));
} }

View File

@ -210,14 +210,14 @@ export function pickPreviewSystem(galaxy, spec, systemType = null) {
continue; continue;
} }
// STATIONS — at most one deep-space station per system. // STATIONS — up to two deep-space stations per system.
if (!comp.deepSpace) continue; if (!comp.stations) continue;
const frame = stationFrames?.get(rec.id) ?? null; const frames = stationFrames?.get(rec.id) ?? null;
if (spec.frame == null) { if (spec.frame == null) {
consider(rec, { station: true }, true); consider(rec, { station: true }, true);
continue; continue;
} }
if (frame === spec.frame) consider(rec, { station: true }, true); if (Array.isArray(frames) && frames.includes(spec.frame)) consider(rec, { station: true }, true);
else consider(rec, { station: true }, false); // reskin the variant else consider(rec, { station: true }, false); // reskin the variant
} }

View File

@ -197,7 +197,7 @@ export function buildSystemTree(o = {}) {
* central body ('home') — the starting system only, where it is the home * central body ('home') — the starting system only, where it is the home
* world — then the planets (by name), the free-space stations (settlement * world — then the planets (by name), the free-space stations (settlement
* id), and the jump gates (gate id). Asteroid clusters are NOT NAV points * id), and the jump gates (gate id). Asteroid clusters are NOT NAV points
* — they're objects you can mine, not chart waypoints. A system without a * — they're objects you can mine, not chart nav points. A system without a
* central body (every non-home system — the star is invisible flavor) * central body (every non-home system — the star is invisible flavor)
* simply charts its planets, stations, and gates. * simply charts its planets, stations, and gates.
* *

View File

@ -164,8 +164,8 @@ export class GameScene extends Phaser.Scene {
// The space-station spritesheet (data/stations.json → texture): the // The space-station spritesheet (data/stations.json → texture): the
// deep-space station variants (variants 0..2 today — the first three // deep-space station variants (variants 0..2 today — the first three
// 256×256 frames); waypoints keep their procedural beacon. Station // 256×256 frames). Station falls back to its procedural hardware if
// falls back to its procedural hardware if the sheet is missing. // the sheet is missing.
const stationTexture = config.get('stations.texture', ''); const stationTexture = config.get('stations.texture', '');
if (stationTexture && config.get('stations.enabled', true) !== false) { if (stationTexture && config.get('stations.enabled', true) !== false) {
this.load.spritesheet( this.load.spritesheet(
@ -618,7 +618,7 @@ export class GameScene extends Phaser.Scene {
} }
// Free-space stations (settlements with `anchor.type === 'space'` // Free-space stations (settlements with `anchor.type === 'space'`
// — deep-space stations and waypoints): settlements that float in // — deep-space stations): settlements that float in
// the void, rendered as world objects (js/entities/Station.js). // the void, rendered as world objects (js/entities/Station.js).
// Solid (the ship keeps its clearance), discoverable (compass + // Solid (the ship keeps its clearance), discoverable (compass +
// toast), and comms targets — a click opens the comms panel there // toast), and comms targets — a click opens the comms panel there
@ -1735,7 +1735,7 @@ export class GameScene extends Phaser.Scene {
const t = this.typeTarget; const t = this.typeTarget;
if (!t) return null; if (!t) return null;
if (t.station) { if (t.station) {
// At most one deep-space station per system — match the entity // Up to two deep-space stations per system — match the entity
// to the settlement record by id (name-safe). // to the settlement record by id (name-safe).
const rec = (this.systemContent?.settlements ?? []).find((s) => s?.kind === 'deepSpaceStation'); const rec = (this.systemContent?.settlements ?? []).find((s) => s?.kind === 'deepSpaceStation');
if (!rec) return null; if (!rec) return null;
@ -2802,7 +2802,7 @@ export class GameScene extends Phaser.Scene {
* (both wear a spritesheet frame — the planet's class * (both wear a spritesheet frame — the planet's class
* frame / the station's variant frame — and land with * frame / the station's variant frame — and land with
* the matching clips: data/landing.json → videos / * the matching clips: data/landing.json → videos /
* stationVideos). Waypoints are comms, not a port. * stationVideos).
*/ */
commsTargetFor(obj) { commsTargetFor(obj) {
let key = null; let key = null;
@ -2823,8 +2823,7 @@ export class GameScene extends Phaser.Scene {
// Deep-space stations wear a spacestations.png variant (the // Deep-space stations wear a spacestations.png variant (the
// galaxy-wide spread pass — js/galaxy/StationFrames.js) and label // galaxy-wide spread pass — js/galaxy/StationFrames.js) and label
// themselves by it (data/stations.json → typeLabels — the fallback // themselves by it (data/stations.json → typeLabels — the fallback
// is the kind's label). Waypoints are a nav beacon: comms, not a // is the kind's label).
// port, and no sheet frame.
isStation = obj.kind === 'deepSpaceStation'; isStation = obj.kind === 'deepSpaceStation';
const variantLabel = typeof obj.sheetFrame === 'number' const variantLabel = typeof obj.sheetFrame === 'number'
? config.get(`stations.typeLabels.${obj.sheetFrame}`) ? config.get(`stations.typeLabels.${obj.sheetFrame}`)
@ -2861,8 +2860,7 @@ export class GameScene extends Phaser.Scene {
reputation: rep, reputation: rep,
// Landing = the surface sequence: a PLANTED world (population > 0) // Landing = the surface sequence: a PLANTED world (population > 0)
// or a deep-space station (always settled) with enough goodwill. // or a deep-space station (always settled) with enough goodwill.
// Waypoints are not a port (isStation stays false), and the home // The home world's button is hidden as a UI fact (the comms panel).
// world's button is hidden as a UI fact (the comms panel).
canLand: settled && rep > -4 && landingOn && (isPlanet || isStation), canLand: settled && rep > -4 && landingOn && (isPlanet || isStation),
kindLabel, kindLabel,
isPlanet, isPlanet,
@ -2937,8 +2935,8 @@ export class GameScene extends Phaser.Scene {
this.startLanding(target); this.startLanding(target);
return; return;
} }
// 'request-landing' on a waypoint — a nav beacon is not a port; the // A landing press on a non-port target (defensive — the panel only
// click is acknowledged on the console for now. // offers it on planets + stations) — acknowledged on the console.
console.info(`[orbit] comms: ${id} — ${target.name} (key: ${target.key ?? 'none'})`); console.info(`[orbit] comms: ${id} — ${target.name} (key: ${target.key ?? 'none'})`);
} }
@ -4427,7 +4425,7 @@ export class GameScene extends Phaser.Scene {
y: st.y, y: st.y,
radius: st.bound ?? st.size, radius: st.bound ?? st.size,
name: st.discoveryName, name: st.discoveryName,
typeLabel: st.kind === 'waypoint' ? 'Waypoint' : 'Station', typeLabel: 'Station',
discovered: isDisc(st.discoveryId), discovered: isDisc(st.discoveryId),
}); });
} }

View File

@ -82,7 +82,8 @@ export class SurfaceScene extends Phaser.Scene {
this.planetType = String(data.type ?? ''); // e.g. "Rocky World" / "Smuggler Outpost" this.planetType = String(data.type ?? ''); // e.g. "Rocky World" / "Smuggler Outpost"
this.tetherLevel = Math.max(0, Math.round(Number(data.tetherLevel ?? 0))); this.tetherLevel = Math.max(0, Math.round(Number(data.tetherLevel ?? 0)));
// A deep-space STATION surface? (GameScene.startLanding hands over // A deep-space STATION surface? (GameScene.startLanding hands over
// `station: true` for a deepSpaceStation — waypoints never land.) // `station: true` for a deepSpaceStation — the only free-space kind
// that lands.)
// Same deck, same flow; the frame indexes a different clip set // Same deck, same flow; the frame indexes a different clip set
// (data/landing.json → stationVideos) and a different music key // (data/landing.json → stationVideos) and a different music key
// (data/music.json → stationFrames). Surface/shop clips are authored // (data/music.json → stationFrames). Surface/shop clips are authored
@ -887,7 +888,7 @@ export class SurfaceScene extends Phaser.Scene {
const p = match(g?.systemPlanets) ?? (g?.planet?.discoveryName === name ? g.planet : null); const p = match(g?.systemPlanets) ?? (g?.planet?.discoveryName === name ? g.planet : null);
if (p && p.class) return Economy.economicClass(p); if (p && p.class) return Economy.economicClass(p);
const st = match(g?.systemStations); const st = match(g?.systemStations);
if (st) return 'station'; // both deepSpaceStation + waypoint shops are the 'station' class if (st) return 'station'; // free-space stations are the 'station' class
const sysId = g?.systemRecord?.id; const sysId = g?.systemRecord?.id;
const raw = g?.galaxy?.economy?.classBySystem?.get?.(sysId); const raw = g?.galaxy?.economy?.classBySystem?.get?.(sysId);
const classes = raw ? [...raw] : []; const classes = raw ? [...raw] : [];

View File

@ -427,7 +427,7 @@ function wrapPI(a) {
*/ */
export function isFarTarget(t, ship, farDistance) { export function isFarTarget(t, ship, farDistance) {
if (!t || !ship || farDistance <= 0) return false; if (!t || !ship || farDistance <= 0) return false;
if (t.alwaysFull === true) return false; // always prominent (route waypoint) if (t.alwaysFull === true) return false; // always prominent (route next stop)
return Math.hypot(t.x - ship.x, t.y - ship.y) > farDistance; return Math.hypot(t.x - ship.x, t.y - ship.y) > farDistance;
} }