diff --git a/data/gates.json b/data/gates.json index f3e3729..fb7ee0b 100644 --- a/data/gates.json +++ b/data/gates.json @@ -72,7 +72,7 @@ "rippleAlpha": 0.85 }, "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, "expand": 0.4, "period": 1.8, diff --git a/data/landing.json b/data/landing.json index 909fc15..20adc15 100644 --- a/data/landing.json +++ b/data/landing.json @@ -6,7 +6,7 @@ "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", "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.", "`surface` — the looping clip shown on the surface deck behind the action bar.", diff --git a/data/naming.json b/data/naming.json index 25034e3..af941ec 100644 --- a/data/naming.json +++ b/data/naming.json @@ -74,7 +74,7 @@ "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", + "Beacon 4491", "Relay 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", diff --git a/data/settlements.json b/data/settlements.json index 2ca1b56..8fde93d 100644 --- a/data/settlements.json +++ b/data/settlements.json @@ -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, "settledKindByClass": { "rocky": { "habitable": "colony", "default": "miningStation" }, @@ -47,13 +47,6 @@ "theme": { "color": "#c9a7ff" }, "population": { "min": 200, "max": 60000 }, "anchor": "space" - }, - "waypoint": { - "label": "Waypoint", - "description": "A small navigation beacon — the faint trace of a crossed galaxy.", - "theme": { "color": "#8fa0c9" }, - "population": { "min": 0, "max": 12 }, - "anchor": "space" } } } diff --git a/data/stations.json b/data/stations.json index 649bc5b..7d0cbf6 100644 --- a/data/stations.json +++ b/data/stations.json @@ -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..size = the station's keepout radius (px — the wings/ring extent); kinds..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..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..size = the station's keepout radius (px — the wings/ring extent); kinds..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..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, "texture": "assets/images/spacestations.png", "frameWidth": 256, @@ -16,9 +16,6 @@ "deepSpaceStation": { "size": 108, "ringSpeed": 0.08 - }, - "waypoint": { - "size": 46 } } } diff --git a/data/systems.json b/data/systems.json index 1147cb0..a3dcf9b 100644 --- a/data/systems.json +++ b/data/systems.json @@ -1,5 +1,5 @@ { - "_comment": "System archetypes. Each type is themable (theme) and has attributes that steer the SystemGenerator: star classes, binary chance, planet class weights, moon/belt chances, habitability, hazard, and free-space settlement odds (settlements: deepSpaceStation / waypoint). objectCount is a GLOBAL rule, not per-type: the starting system is exempt (it always holds exactly two generated planets — gas giant + rocky — beside the home world, plus at most one free-space station). Every other system rolls its TOTAL object count — planets + free-space stations together — from this table: `barren` of systems hold ZERO objects (a gate-only dead-end LEAF of the jump network — the center is empty, the star is invisible flavor, and the only things there are the single gate and 1–2 asteroid clusters drifting inside its tether, data/asteroids.json → barren), the rest hold `objects` counts. The split is resolved by rolling the free-space stations first (per-type odds in attributes.settlements × the 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.", "objectCount": { "barren": 0.10, @@ -29,8 +29,7 @@ "habitability": 0.1, "hazard": 0.1, "settlements": { - "deepSpaceStation": { "chance": 0.25 }, - "waypoint": { "chance": 0.35 } + "deepSpaceStation": { "chance": 0.25, "second": 0.35 } } } }, @@ -58,8 +57,7 @@ "habitability": 0.22, "hazard": 0.12, "settlements": { - "deepSpaceStation": { "chance": 0.2 }, - "waypoint": { "chance": 0.3 } + "deepSpaceStation": { "chance": 0.2, "second": 0.3 } } } }, @@ -86,8 +84,7 @@ "habitability": 0.06, "hazard": 0.18, "settlements": { - "deepSpaceStation": { "chance": 0.3 }, - "waypoint": { "chance": 0.3 } + "deepSpaceStation": { "chance": 0.3, "second": 0.3 } } } }, @@ -124,8 +121,7 @@ "habitability": 0.5, "hazard": 0.05, "settlements": { - "deepSpaceStation": { "chance": 0.25 }, - "waypoint": { "chance": 0.4 } + "deepSpaceStation": { "chance": 0.25, "second": 0.4 } } } }, @@ -162,8 +158,7 @@ "habitability": 0.02, "hazard": 0.35, "settlements": { - "deepSpaceStation": { "chance": 0.2 }, - "waypoint": { "chance": 0.45 } + "deepSpaceStation": { "chance": 0.2, "second": 0.45 } } } }, @@ -185,8 +180,7 @@ "habitability": 0.02, "hazard": 0.5, "settlements": { - "deepSpaceStation": { "chance": 0.15 }, - "waypoint": { "chance": 0.25 } + "deepSpaceStation": { "chance": 0.15, "second": 0.25 } } } } diff --git a/dev/discovery.test.mjs b/dev/discovery.test.mjs index c4d3b97..83dacbe 100644 --- a/dev/discovery.test.mjs +++ b/dev/discovery.test.mjs @@ -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: 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: 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); const fakeScene = { diff --git a/dev/galaxy.test.mjs b/dev/galaxy.test.mjs index 7da05a3..46225bc 100644 --- a/dev/galaxy.test.mjs +++ b/dev/galaxy.test.mjs @@ -371,7 +371,7 @@ let big; 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); // The all-planets-settled rule (for now): EVERY world hosts a settlement, diff --git a/dev/jumpgate.test.mjs b/dev/jumpgate.test.mjs index 999d7d1..b905a7e 100644 --- a/dev/jumpgate.test.mjs +++ b/dev/jumpgate.test.mjs @@ -188,7 +188,7 @@ gateB.update(3000); check('active gate: the swirl spins (rotation increases with time — clockwise)', 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); check('fresh gate: no route pulse by default', gate.routePulse.on === false && gate.routePulse.ring.visible === false); gate.setRouteTarget(true); diff --git a/dev/route-pulse.test.mjs b/dev/route-pulse.test.mjs index 6c19db0..741ba14 100644 --- a/dev/route-pulse.test.mjs +++ b/dev/route-pulse.test.mjs @@ -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 scene = makeScene(false); // sheet missing → the procedural build 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, }); check('fresh station: the route pulse is off + hidden by default', diff --git a/dev/station-frames.test.mjs b/dev/station-frames.test.mjs index 8b1672d..99db368 100644 --- a/dev/station-frames.test.mjs +++ b/dev/station-frames.test.mjs @@ -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); 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). -const stations = new Map(); // id → { rec, frame, name } +// The station-bearing systems and their stamped frames (via content). +const stations = new Map(); // id → [{ rec, frame, name }] (one per station) for (const rec of g.records) { const c = g.ensureContent(rec.id); for (const s of c.settlements) { 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 @@ -68,16 +69,33 @@ console.log(` (galaxy: ${g.records.length} systems, ${stations.size} deep-space { let inPool = true; let why = ''; - for (const [id, st] of stations) { - if (typeof st.frame !== 'number' || !Number.isInteger(st.frame) || !POOLV.includes(st.frame)) { - inPool = false; - if (!why) why = `${id}: frame ${st.frame} ∉ [${POOLV}]`; + for (const [id, sts] of stations) { + for (const st of sts) { + if (typeof st.frame !== 'number' || !Number.isInteger(st.frame) || !POOLV.includes(st.frame)) { + inPool = false; + if (!why) why = `${id}: frame ${st.frame} ∉ [${POOLV}]`; + } } } check('every deep-space station wears a pool variant (settlement.stationFrame)', inPool, why); check('the galaxy pass covers exactly the station-bearing systems', stations.size === g.stationFrames.size, `${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; for (const sd of SEEDS) { const gg = Galaxy.create(sd); - const sts = new Map(); // id → stamped frame - for (const rec of gg.records) - for (const s of gg.ensureContent(rec.id).settlements) - if (s.kind === 'deepSpaceStation') sts.set(rec.id, s.stationFrame); + const sts = new Map(); // id → [stamped frame, ...] + for (const rec of gg.records) { + for (const s of gg.ensureContent(rec.id).settlements) { + if (s.kind === 'deepSpaceStation') { + if (!sts.has(rec.id)) sts.set(rec.id, []); + sts.get(rec.id).push(s.stationFrame); + } + } + } // Naive: independent random pool picks (seeded, per station). - const naiveF = {}; - for (const id of sts.keys()) - naiveF[id] = POOLV.length === 1 + const naiveF = new Map(); // id → [frame, ...] + for (const [id, frames] of sts) { + naiveF.set(id, frames.map((_, i) => POOLV.length === 1 ? POOLV[0] - : POOLV[Math.floor(Rng.derive(sd, 'naive-station', id).next() * POOLV.length)]; - for (const [id, fr] of sts) { - for (const nb of gg.neighborsOf(id, POOL)) { - checks++; - const nfr = sts.get(nb.id); - if (nfr !== undefined && nfr === fr) passColl++; - const nnfr = naiveF[nb.id]; - if (nnfr !== undefined && nnfr === fr) naiveColl++; + : POOLV[Math.floor(Rng.derive(sd, 'naive-station', id, i).next() * POOLV.length)])); + } + for (const [id, frames] of sts) { + for (const fr of frames) { + for (const nb of gg.neighborsOf(id, POOL)) { + checks++; + const nfs = sts.get(nb.id) ?? []; + const nnfs = naiveF.get(nb.id) ?? []; + if (nfs.includes(fr)) passColl++; + 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 // (with 3 stations' worth of pool breadth and this many stations, // 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)', used.size >= Math.min(2, POOLV.length, stations.size), `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). let stamped = true; let why = ''; - for (const [id, st] of stations) { - if (g2.stationFrames.get(id) !== st.frame) { + for (const [id, sts] of stations) { + const pass = g2.stationFrames.get(id) ?? []; + if (!sts.every((st) => pass.includes(st.frame))) { 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); diff --git a/dev/type-preview.test.mjs b/dev/type-preview.test.mjs index 9c37277..2029932 100644 --- a/dev/type-preview.test.mjs +++ b/dev/type-preview.test.mjs @@ -130,10 +130,10 @@ function verifyPick(spec, pick, why) { const c = contentOf(pick.record.id); if (spec.kind === 'station') { if (!pick.station) return `missing station flag on ${pick.record.id}`; - const s = c.settlements.find((x) => x.kind === 'deepSpaceStation'); - if (!s) return `${pick.record.id} has no deepSpaceStation in content`; - if (!pick.reskin && spec.frame != null && s.stationFrame !== spec.frame) { - return `${pick.record.id} station wears ${s.stationFrame}, spec wants ${spec.frame}`; + const sts = c.settlements.filter((x) => x.kind === 'deepSpaceStation'); + if (sts.length === 0) return `${pick.record.id} has no deepSpaceStation in content`; + if (!pick.reskin && spec.frame != null && !sts.some((s) => s.stationFrame === spec.frame)) { + return `${pick.record.id} stations wear ${sts.map((s) => s.stationFrame).join(',')} spec wants ${spec.frame}`; } return null; } @@ -311,8 +311,9 @@ function verifyPick(spec, pick, why) { const worn = new Set(); for (const rec of g.records) { const c = contentOf(rec.id); - const s = c.settlements.find((x) => x.kind === 'deepSpaceStation'); - if (s?.stationFrame != null) worn.add(s.stationFrame); + for (const s of c.settlements) { + if (s.kind === 'deepSpaceStation' && s.stationFrame != null) worn.add(s.stationFrame); + } } let ok = true; 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}` })), }; const other = stationVariants.find((f) => f !== variant) ?? stationVariants[0]; - gsf.stationFrames.set(sA.id, other); - gsf.stationFrames.set(sB.id, variant); + gsf.stationFrames.set(sA.id, [other]); + gsf.stationFrames.set(sB.id, [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}`); } else { diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index d03aa9e..5679803 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -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 → allPlanetsSettled` + `settledKindByClass`) — `colony` on a habitable rocky world, `miningStation` over every other rocky/ice/lava world, -`cloudBase` riding every gas giant. Plus the free-space kinds: -`deepSpaceStation` (adrift in open space) and `waypoint` (a small beacon -— the faint trace of a crossed galaxy), still rolled per archetype and -thinned core→rim. **Barren systems** (the `objectCount` → 0 stops) are +`cloudBase` riding every gas giant. Plus the free-space kind: +`deepSpaceStation` (adrift in open space — up to two per system), still +rolled per archetype and thinned core→rim. **Barren systems** (the `objectCount` → 0 stops) are 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 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 kept behind the flag). - **Per-type free-space rates** — `types..attributes.settlements` in - `data/systems.json`: `chance` per free-space kind (deep-space station, - waypoint). Same attribute-driven pattern as everything else. + `data/systems.json`: `deepSpaceStation.chance` of a station (+ `second` + of a second one). Same attribute-driven pattern as everything else. - **Home→far gradient** — `galaxy.settlements.gradient` in `data/galaxy.json`: the settled heart (the home corner, `d = 0`) is denser (factor 1.0), the deep corner (`d = 1`) is thinner (clamped to diff --git a/js/economy/Economy.js b/js/economy/Economy.js index edf9087..92e18c0 100644 --- a/js/economy/Economy.js +++ b/js/economy/Economy.js @@ -81,7 +81,7 @@ export function goodsTextureReady(scene) { * 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' * 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} */ 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 set = new Set(comp.classes); 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'); classBySystem.set(id, set); } diff --git a/js/entities/JumpGate.js b/js/entities/JumpGate.js index 89bbf82..37f37b3 100644 --- a/js/entities/JumpGate.js +++ b/js/entities/JumpGate.js @@ -35,7 +35,7 @@ import { Planet } from './Planet.js'; * * 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 - * (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 * pulse around it (js/entities/RoutePulse.js — the shared halo, also * 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.ringBody = 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.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 - * gate is the route's NEXT STOP — the destination's waypoint in the - * current system (routeNextGate). True shows the FULL pulse (update() - * animates it); false clears + hides it. A no-op when unchanged. + * gate is the route's NEXT STOP in the current system (routeNextGate). + * True shows the FULL pulse (update() animates it); false clears + + * hides it. A no-op when unchanged. */ setRouteTarget(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.) */ update(time) { 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 // gate to use it. (A no-op while unmarked.) this.routePulse?.draw(t); diff --git a/js/entities/RoutePulse.js b/js/entities/RoutePulse.js index b2f44d4..aa1903f 100644 --- a/js/entities/RoutePulse.js +++ b/js/entities/RoutePulse.js @@ -5,12 +5,12 @@ * Shape: a steady target ring at `baseRadius` × the object's keepout * radius, plus two radar ripples (staggered half a period) that expand * 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 - * beacon and a gas giant. + * the object's keepout radius, so it reads the same on a small station + * and a gas giant. * * Who wears it (GameScene.update marks exactly one object per frame — * 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 * (data/gates.json → route.pulse), via JumpGate.setRouteTarget * · the DESTINATION OBJECT in the destination system (a planet or a diff --git a/js/entities/Station.js b/js/entities/Station.js index 8bd82fe..fa6aa9b 100644 --- a/js/entities/Station.js +++ b/js/entities/Station.js @@ -7,30 +7,27 @@ import { Planet } from './Planet.js'; /** * A SPACE STATION — a free-space settlement (SystemGenerator: - * `anchor.type === 'space'`, kinds `deepSpaceStation` / `waypoint`) - * rendered as a world object: + * `anchor.type === 'space'`, kind `deepSpaceStation` — the only + * free-space kind) rendered as a world object: * - * DEEP-SPACE STATION — a variant from the shared spritesheet - * (data/stations.json → texture: spacestations.png, 256×256 frames; - * `variants` lists the frames in use, today 0..2 — the first three - * frames). Which variant it wears comes from the galaxy-wide spread - * pass (js/galaxy/StationFrames.js, stamped as `settlement.stationFrame`): + * A variant from the shared spritesheet (data/stations.json → + * texture: spacestations.png, 256×256 frames; `variants` lists the + * frames in use, today 0..2 — the first three frames). Which variant + * it wears comes from the galaxy-wide spread pass + * (js/galaxy/StationFrames.js, stamped as `settlement.stationFrame`): * the frame avoids what the NEAREST stars already wear, so the same * station type is spread across the galaxy instead of clustering. The * SAME frame picks its landing/take-off clips (data/landing.json → * stationVideos) — a station always lands with the clip matching the * 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 * circle rule, GameScene.solids), discoverable (a compass arrow + the * 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 - * fly-here — the ship stays put; GameScene.openCommsPanel). Deep-space - * stations are also a PORT: REQUEST LANDING launches the surface - * sequence (GameScene.startLanding → SurfaceScene, the station's clips). + * fly-here — the ship stays put; GameScene.openCommsPanel). A station + * is a PORT: REQUEST LANDING launches the surface sequence + * (GameScene.startLanding → SurfaceScene, the station's clips). * * The deep-space station's art fills its frame, so it is scaled to 2×size * (size = the keepout RADIUS — data/stations.json → kinds..size): @@ -71,28 +68,24 @@ export class Station extends Phaser.GameObjects.Container { this.discoveryId = settlement.id; 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.bound = this.size; // the discovery radius (the compass/toast scale) this.clearance = config.get('stations.shipClearance', 50); this.ringSpeed = config.get(`stations.kinds.${this.kind}.ringSpeed`, 0.08); this.ringBody = null; this.sprite = null; - // The spacestations.png frame this station wears (null = no sheet art: - // waypoints, or a pool with no frames yet). - this.sheetFrame = null; - if (this.kind === 'deepSpaceStation') { - const v = settlement.stationFrame; - if (Number.isInteger(v) && v >= 0) { - this.sheetFrame = v; // the spread pass's pick (data/stations.json → variants) - } else { - // Fallback for content that predates the pass (dev tools, old - // fixtures): a deterministic pool pick keyed on the settlement's - // own (seed-derived) id — same galaxy ⇒ same face. - const pool = config.get('stations.variants'); - const list = Array.isArray(pool) && pool.length > 0 ? pool : [0]; - this.sheetFrame = Rng.derive('orbit-station-variant', settlement.id).pick(list) ?? 0; - } + // The spacestations.png frame this station wears (null = no pool yet). + const v = settlement.stationFrame; + if (Number.isInteger(v) && v >= 0) { + this.sheetFrame = v; // the spread pass's pick (data/stations.json → variants) + } else { + // Fallback for content that predates the pass (dev tools, old + // fixtures): a deterministic pool pick keyed on the settlement's + // own (seed-derived) id — same galaxy ⇒ same face. + const pool = config.get('stations.variants'); + const list = Array.isArray(pool) && pool.length > 0 ? pool : [0]; + this.sheetFrame = Rng.derive('orbit-station-variant', settlement.id).pick(list) ?? 0; } this.setDepth(o.depth ?? 5); @@ -100,8 +93,7 @@ export class Station extends Phaser.GameObjects.Container { } /** - * The variant art (deep-space stations) — or the procedural hardware - * (waypoints, and the deep-space fallback when the sheet is missing). + * The variant art — or the procedural hardware fallback (sheet missing). */ build() { // The route-target pulse (data/gates.json → route.objectPulse): when @@ -146,83 +138,67 @@ export class Station extends Phaser.GameObjects.Container { const g = scene.add.graphics(); 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 ----------------------------------------- - const wingX0 = S * 0.24; - const wingX1 = S * 0.85; - const wingH = S * 0.18; - // Solar wings — weathered blue-gray panels in a rusty frame. - for (const dir of [-1, 1]) { - const x0 = dir === 1 ? wingX0 : -wingX1; - const w = wingX1 - wingX0; - g.lineStyle(3, 0x57493a, 1); - g.lineBetween(dir * S * 0.1, 0, dir * wingX0, 0); // the truss - g.fillStyle(0x3f5468, 1); - g.fillRect(x0, -wingH / 2, w, wingH); - g.fillStyle(0x6e3a1f, 0.35); // rust streak on the outer edge - g.fillRect(dir === 1 ? wingX1 - 6 : -wingX1, -wingH / 2, 6, wingH); - g.lineStyle(2, 0x22303e, 1); - g.strokeRect(x0, -wingH / 2, w, wingH); - g.lineStyle(1, 0x22303e, 0.9); - for (let i = 1; i <= 4; i++) { - const x = x0 + (w * i) / 5; - g.lineBetween(x, -wingH / 2, x, wingH / 2); - } + // ---- Deep-space station ----------------------------------------- + const wingX0 = S * 0.24; + const wingX1 = S * 0.85; + const wingH = S * 0.18; + // Solar wings — weathered blue-gray panels in a rusty frame. + for (const dir of [-1, 1]) { + const x0 = dir === 1 ? wingX0 : -wingX1; + const w = wingX1 - wingX0; + g.lineStyle(3, 0x57493a, 1); + g.lineBetween(dir * S * 0.1, 0, dir * wingX0, 0); // the truss + g.fillStyle(0x3f5468, 1); + g.fillRect(x0, -wingH / 2, w, wingH); + g.fillStyle(0x6e3a1f, 0.35); // rust streak on the outer edge + g.fillRect(dir === 1 ? wingX1 - 6 : -wingX1, -wingH / 2, 6, wingH); + g.lineStyle(2, 0x22303e, 1); + g.strokeRect(x0, -wingH / 2, w, wingH); + g.lineStyle(1, 0x22303e, 0.9); + for (let i = 1; i <= 4; i++) { + const x = x0 + (w * i) / 5; + g.lineBetween(x, -wingH / 2, x, wingH / 2); } - // The rotating ring (spokes + pods) — its own container so - // update() can spin it. - this.ringBody = new Phaser.GameObjects.Container(scene, 0, 0); - const rg = scene.add.graphics(); - const ringR = S * 0.48; - rg.lineStyle(S * 0.065, 0x6a5a48, 0.95); - rg.strokeCircle(0, 0, ringR); - rg.lineStyle(S * 0.03, 0x57493a, 1); - for (let i = 0; i < 3; i++) { - const a = (i / 3) * Math.PI * 2; - rg.lineBetween( - Math.cos(a) * S * 0.12, - Math.sin(a) * S * 0.12, - Math.cos(a) * ringR, - Math.sin(a) * ringR, - ); - rg.fillStyle(0x8a6a48, 1); - rg.fillCircle(Math.cos(a) * ringR, Math.sin(a) * ringR, S * 0.037); - } - this.ringBody.add(rg); - this.add(this.ringBody); - // The hub (over the ring): a weathered core + rust. - const hubR = S * 0.205; - g.fillStyle(0x57493a, 1); - g.fillCircle(0, 0, hubR); - g.lineStyle(2, 0x2c2118, 1); - g.strokeCircle(0, 0, hubR); - g.fillStyle(0x3a2f26, 1); - g.fillCircle(0, 0, hubR * 0.55); - g.fillStyle(0x8a6a48, 1); - g.fillCircle(0, 0, hubR * 0.22); - g.fillStyle(0x6e3a1f, 0.5); - g.fillCircle(hubR * 0.5, -hubR * 0.35, 3); - g.fillCircle(-hubR * 0.4, hubR * 0.5, 2.5); } + // The rotating ring (spokes + pods) — its own container so + // update() can spin it. + this.ringBody = new Phaser.GameObjects.Container(scene, 0, 0); + const rg = scene.add.graphics(); + const ringR = S * 0.48; + rg.lineStyle(S * 0.065, 0x6a5a48, 0.95); + rg.strokeCircle(0, 0, ringR); + rg.lineStyle(S * 0.03, 0x57493a, 1); + for (let i = 0; i < 3; i++) { + const a = (i / 3) * Math.PI * 2; + rg.lineBetween( + Math.cos(a) * S * 0.12, + Math.sin(a) * S * 0.12, + Math.cos(a) * ringR, + Math.sin(a) * ringR, + ); + rg.fillStyle(0x8a6a48, 1); + rg.fillCircle(Math.cos(a) * ringR, Math.sin(a) * ringR, S * 0.037); + } + this.ringBody.add(rg); + this.add(this.ringBody); + // The hub (over the ring): a weathered core + rust. + const hubR = S * 0.205; + g.fillStyle(0x57493a, 1); + g.fillCircle(0, 0, hubR); + g.lineStyle(2, 0x2c2118, 1); + g.strokeCircle(0, 0, hubR); + g.fillStyle(0x3a2f26, 1); + g.fillCircle(0, 0, hubR * 0.55); + g.fillStyle(0x8a6a48, 1); + g.fillCircle(0, 0, hubR * 0.22); + g.fillStyle(0x6e3a1f, 0.5); + g.fillCircle(hubR * 0.5, -hubR * 0.35, 3); + g.fillCircle(-hubR * 0.4, hubR * 0.5, 2.5); - // The beacon light (both kinds) — the thing that says "occupied". - const hy = this.kind === 'waypoint' ? -S * 0.5 : -S * 0.26; - const hue = this.kind === 'waypoint' ? 0xa8ffc4 : 0xff7a5c; - const glow = this.kind === 'waypoint' ? 0x7dffb0 : 0xff5a3c; - 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); + // The beacon light — the thing that says "occupied". + const hy = -S * 0.26; + this.beaconGlow = scene.add.circle(0, hy, 7, 0xff5a3c, 0.2); + this.beacon = scene.add.circle(0, hy, 2.6, 0xff7a5c, 1); this.add([this.beaconGlow, this.beacon]); } diff --git a/js/galaxy/Galaxy.js b/js/galaxy/Galaxy.js index 54d2996..5fccc90 100644 --- a/js/galaxy/Galaxy.js +++ b/js/galaxy/Galaxy.js @@ -89,7 +89,7 @@ export class Galaxy { this.planetFrames = new Map(); // id → [frame, ...] — the frame-diversity pass this.planetClasses = null; // id → [class, ...] — the even-mix deal (or null, balance off) 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')); } @@ -338,12 +338,12 @@ export class Galaxy { this.homeWorldFrame = homeFrame; // STATION VARIANT SPREAD (js/galaxy/StationFrames.js): each system's - // deep-space station wears one of the spacestations.png variants - // (data/stations.json → variants) — the frame avoids what the - // NEAREST stars already wear, so the same station type is spread - // across the galaxy instead of clustering in one region. Fixed - // roster order ⇒ visit-order independent; read back by the content - // generator (settlement.stationFrame) — lazy === eager preserved. + // deep-space stations (up to two) wear spacestations.png variants + // (data/stations.json → variants) — a frame avoids what the NEAREST + // stars already wear, so the same station type is spread across the + // galaxy instead of clustering in one region. Fixed roster order ⇒ + // visit-order independent; read back by the content generator + // (settlement.stationFrame) — lazy === eager preserved. const { frames: stationFrames } = assignStationFrames({ seed: this.seed, records, @@ -351,7 +351,7 @@ export class Galaxy { params: this.params, 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 — // per-system economic classes + each good's hop-distance to its nearest diff --git a/js/galaxy/PlanetClasses.js b/js/galaxy/PlanetClasses.js index bf7516e..473443a 100644 --- a/js/galaxy/PlanetClasses.js +++ b/js/galaxy/PlanetClasses.js @@ -89,7 +89,7 @@ export function assignPlanetClasses({ seed, records, homeId, params, typeDefs = const comp = rollSystemComposition( 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; demand.push({ id: rec.id, diff --git a/js/galaxy/StationFrames.js b/js/galaxy/StationFrames.js index f7b92bd..b95aeb8 100644 --- a/js/galaxy/StationFrames.js +++ b/js/galaxy/StationFrames.js @@ -15,10 +15,11 @@ import { rollSystemComposition, settlementDensity } from './SystemGenerator.js'; * galaxy's neighbor pool, data/galaxy.json → neighbors), so the same * station type reappears only far away. * - * Only DEEP-SPACE STATIONS take a variant — a system holds at most one - * (rollSystemComposition rolls a boolean), and waypoints keep their - * beacon look (no sheet art for them). The landing handoff reads the - * same frame (settlement.stationFrame → data/landing.json → + * Only DEEP-SPACE STATIONS take a variant — and a system holds up to TWO + * of them (rollSystemComposition rolls a count): each station picks its + * own frame, avoiding the frames its NEAREST stars already wear AND, for + * 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 * art it is drawn with. * @@ -34,9 +35,9 @@ import { rollSystemComposition, settlementDensity } from './SystemGenerator.js'; * (SystemGenerator.rollSystemComposition) — guaranteed to agree, since * the forks are pure functions of (seed, id, type, density). * - * Cost: one pool scan per station-bearing system — trivial at this - * galaxy size (data/galaxy.json → systemCount), computed once at galaxy - * build time (js/galaxy/Galaxy.js). + * Cost: one pool scan per station per station-bearing system — trivial + * at this galaxy size (data/galaxy.json → systemCount), computed once at + * galaxy build time (js/galaxy/Galaxy.js). */ export function assignStationFrames({ seed, records, homeId, params, neighborsOf, typeDefs = null }) { const defs = typeDefs ?? config.get('systems.types', {}); @@ -49,10 +50,10 @@ export function assignStationFrames({ seed, records, homeId, params, neighborsOf const pool = Array.isArray(raw) ? 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 }; - 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 // 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 attr = defs[rec.type]?.attributes ?? {}; const density = settlementDensity(shim, rec); - const { deepSpace } = rollSystemComposition( + const { stations } = rollSystemComposition( 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) : []) ?? []; - // usage(frame) = how many ALREADY-ASSIGNED neighbor stars wear it - // (the spread objective — the same lexicographic pick as the planet - // pass, minus the "own" axis: a system never wears two stations). - const usage = (f) => { - let nb = 0; - for (const n of neighbors) { - if (assigned.get(n.id) === f) nb++; - } - return nb; - }; + // One frame PER STATION (a system holds up to two): a system never + // wears the same face twice (hard — the original "one station per + // system" invariant, now per slot), and among the allowed faces each + // 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; + for (const n of neighbors) { + const nfs = assigned.get(n.id); + if (nfs) nb += nfs.filter((x) => x === f).length; + } + return nb; + }; + let best = Infinity; + for (const f of cand) best = Math.min(best, nbUsage(f)); + const tied = cand.filter((f) => nbUsage(f) === best); + picked.push(tied.length === 1 ? tied[0] : Rng.derive(seed, 'station-frames', rec.id, slot).pick(tied)); + } - // The least-used face among the neighbors; ties broken by a - // deterministic derived pick (seed, id). - let best = Infinity; - for (const f of pool) best = Math.min(best, usage(f)); - const tied = pool.filter((f) => usage(f) === best); - const frame = tied.length === 1 ? tied[0] : Rng.derive(seed, 'station-frames', rec.id).pick(tied); - - frames.set(rec.id, frame); - assigned.set(rec.id, frame); + frames.set(rec.id, picked); + assigned.set(rec.id, picked); } return { frames }; diff --git a/js/galaxy/SystemChart.js b/js/galaxy/SystemChart.js index 01d4107..870bd09 100644 --- a/js/galaxy/SystemChart.js +++ b/js/galaxy/SystemChart.js @@ -233,9 +233,9 @@ export function systemChartSnapshot(systemId, content, o = {}) { kind: 'station', x: s.x, 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, - typeLabel: kind === 'waypoint' ? 'Waypoint' : 'Station', + typeLabel: 'Station', discovered: isDisc(s.id), }); } diff --git a/js/galaxy/SystemGenerator.js b/js/galaxy/SystemGenerator.js index ab4c421..d093b01 100644 --- a/js/galaxy/SystemGenerator.js +++ b/js/galaxy/SystemGenerator.js @@ -119,12 +119,11 @@ const DEG = Math.PI / 180; * `content.homeFrame` here; the renderer prefers those over a random pick. * * STATION VARIANTS (js/galaxy/StationFrames.js): the same spreading for - * the deep-space stations — each system's free-space station wears one of - * the spacestations.png variants (data/stations.json → variants), chosen - * to avoid what the NEAREST stars already wear. The pass stamps - * `settlement.stationFrame` here (the landing handoff reads the same - * frame — data/landing.json → stationVideos); waypoints keep their beacon - * look and take no frame. + * the deep-space stations — each system's free-space stations (up to two) + * wear spacestations.png variants (data/stations.json → variants), chosen + * to avoid what the NEAREST stars already wear (and each other). The pass + * stamps `settlement.stationFrame` here (the landing handoff reads the same + * frame — data/landing.json → stationVideos). * * Place identity (the reputation/trading/faction keys): every planet and * 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') ), density: settlementDensity(galaxy, record), - stations: { deepSpace: composition.deepSpace, waypoint: composition.waypoint }, + stations: composition.stations, isHome, // FACTION ownership (data/factions.json — the roster's faction pass): // 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 - // spread pass): stamp the assigned spacestations.png frame on this - // system's deep-space station, so the same station type is spread - // across the galaxy (the renderer prefers settlement.stationFrame over - // a fallback pick; the landing handoff reads the same frame). - const stationFrame = galaxy?.stationFrames?.get(record.id); - if (typeof stationFrame === 'number') { + // spread pass): stamp the assigned spacestations.png frames on this + // system's deep-space stations (up to two, in draw order), so the same + // station type is spread across the galaxy (the renderer prefers + // settlement.stationFrame over a fallback pick; the landing handoff + // reads the same frame). + const stationFrames = galaxy?.stationFrames?.get(record.id); + if (Array.isArray(stationFrames)) { + let i = 0; 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) * * 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 * 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; @@ -1321,15 +1322,16 @@ function mixTint(hex, strength) { * data/galaxy.json → settlements.gradient), in one deterministic roll: * * home system → fixed: 2 planets (gas giant + rocky) beside the home - * world, + at most one free-space station (deepSpace only — - * by design: the home system stays a small start the player - * expands out of; the arc layout could seat more) + * world, + at most one free-space station (by design: the + * home system stays a small start the player expands out + * of; the arc layout could seat more) * every other → 1) the FINAL object count N from the configured table * (0 = barren, then 2/3/4/5 at the configured weights — the * distribution is on the FINAL count, by design); - * 2) the free-space stations (0–2, per-type odds × the - * density gradient; at most 2, which never exceeds the - * minimum non-barren budget of 2); + * 2) the free-space deep-space stations (0–2 — the first at + * the type's chance, the second at `second`; per-type + * 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 * GALAXY-DECK DEAL when `stampedClasses` is given * (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 * a pure function of (seed, roster, config) (stamped once at galaxy * 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) { if (isHome) { const rng = Rng.derive(seed, 'system', record.id, 'settlements'); - const deepSpace = rng.chance((spec?.deepSpaceStation?.chance ?? 0.12) * density); - return { objects: 2, deepSpace, waypoint: false, classes: ['gas', 'rocky'] }; + // At most one free-space station — the home band can't hold 5 points. + 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. 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. 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 deepSpace = rngS.chance((spec?.deepSpaceStation?.chance ?? 0.12) * density); - const waypoint = rngS.chance((spec?.waypoint?.chance ?? 0.2) * density); + let stations = 0; + 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). // 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 // reach a planet and build out from it — a station-only budget // (N = 2 stations) demotes one station to a planet. - let stations = Number(deepSpace) + Number(waypoint); - let wp = waypoint; - let ds = deepSpace; - while (stations >= objects && stations > 0) { - if (wp) wp = false; - else ds = false; - stations = Number(ds) + Number(wp); - } + stations = Math.min(stations, objects - 1); // The classes: the GALAXY-DECK DEAL when stamped (the even galaxy-wide // mix — PlanetClasses.js), else the per-type weighted draws (standalone // 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 ? stampedClasses.slice() : 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. */ export function rollStationCount(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 - * settlements in orbital order, then free-floating (deep-space station, - * waypoint). Every roll goes through the system's own stream. Each + * settlements in orbital order, then free-floating (the deep-space + * stations). Every roll goes through the system's own stream. Each * settlement gets a stable `id` (`-s`, n = its position in * this order) — the reputation/factions key. `owner` is the FACTION * 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: - // per-type odds × the home→far density gradient; the home system never - // rolls a waypoint — its band can't hold 5 points). A BARREN system + // Free-floating, out in the dark — the PRE-ROLLED count (per-type odds + // × the home→far density gradient; the home system never rolls a second + // station — its band can't hold 5 points). A BARREN system // (objectCount → 0) rolled no stations — it is a jump-gate-only stop, // deliberately. - if (stations?.deepSpace) make('deepSpaceStation', { type: 'space' }, systemFaction); - if (stations?.waypoint) make('waypoint', { type: 'space' }, systemFaction); + for (let i = 0; i < (stations ?? 0); i++) make('deepSpaceStation', { type: 'space' }, systemFaction); return out; } @@ -1528,6 +1527,6 @@ function needs(planet, needsList, defaults) { function logPopulation(rng, pop) { const min = pop?.min ?? 1; const max = Math.max(min, pop?.max ?? min); - if (min <= 0 && rng.chance(0.5)) return 0; // e.g. unmanned waypoints + 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)))); } diff --git a/js/galaxy/TypeSystems.js b/js/galaxy/TypeSystems.js index 6afc80e..41deb31 100644 --- a/js/galaxy/TypeSystems.js +++ b/js/galaxy/TypeSystems.js @@ -210,14 +210,14 @@ export function pickPreviewSystem(galaxy, spec, systemType = null) { continue; } - // STATIONS — at most one deep-space station per system. - if (!comp.deepSpace) continue; - const frame = stationFrames?.get(rec.id) ?? null; + // STATIONS — up to two deep-space stations per system. + if (!comp.stations) continue; + const frames = stationFrames?.get(rec.id) ?? null; if (spec.frame == null) { consider(rec, { station: true }, true); 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 } diff --git a/js/research/SystemCategory.js b/js/research/SystemCategory.js index cc68ecb..be95ff6 100644 --- a/js/research/SystemCategory.js +++ b/js/research/SystemCategory.js @@ -197,7 +197,7 @@ export function buildSystemTree(o = {}) { * central body ('home') — the starting system only, where it is the home * world — then the planets (by name), the free-space stations (settlement * 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) * simply charts its planets, stations, and gates. * diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js index 297d841..3103e40 100644 --- a/js/scenes/GameScene.js +++ b/js/scenes/GameScene.js @@ -164,8 +164,8 @@ export class GameScene extends Phaser.Scene { // The space-station spritesheet (data/stations.json → texture): the // deep-space station variants (variants 0..2 today — the first three - // 256×256 frames); waypoints keep their procedural beacon. Station - // falls back to its procedural hardware if the sheet is missing. + // 256×256 frames). Station falls back to its procedural hardware if + // the sheet is missing. const stationTexture = config.get('stations.texture', ''); if (stationTexture && config.get('stations.enabled', true) !== false) { this.load.spritesheet( @@ -618,7 +618,7 @@ export class GameScene extends Phaser.Scene { } // 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). // Solid (the ship keeps its clearance), discoverable (compass + // 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; if (!t) return null; 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). const rec = (this.systemContent?.settlements ?? []).find((s) => s?.kind === 'deepSpaceStation'); if (!rec) return null; @@ -2802,7 +2802,7 @@ export class GameScene extends Phaser.Scene { * (both wear a spritesheet frame — the planet's class * frame / the station's variant frame — and land with * the matching clips: data/landing.json → videos / - * stationVideos). Waypoints are comms, not a port. + * stationVideos). */ commsTargetFor(obj) { let key = null; @@ -2823,8 +2823,7 @@ export class GameScene extends Phaser.Scene { // Deep-space stations wear a spacestations.png variant (the // galaxy-wide spread pass — js/galaxy/StationFrames.js) and label // themselves by it (data/stations.json → typeLabels — the fallback - // is the kind's label). Waypoints are a nav beacon: comms, not a - // port, and no sheet frame. + // is the kind's label). isStation = obj.kind === 'deepSpaceStation'; const variantLabel = typeof obj.sheetFrame === 'number' ? config.get(`stations.typeLabels.${obj.sheetFrame}`) @@ -2861,8 +2860,7 @@ export class GameScene extends Phaser.Scene { reputation: rep, // Landing = the surface sequence: a PLANTED world (population > 0) // or a deep-space station (always settled) with enough goodwill. - // Waypoints are not a port (isStation stays false), and the home - // world's button is hidden as a UI fact (the comms panel). + // The home world's button is hidden as a UI fact (the comms panel). canLand: settled && rep > -4 && landingOn && (isPlanet || isStation), kindLabel, isPlanet, @@ -2937,8 +2935,8 @@ export class GameScene extends Phaser.Scene { this.startLanding(target); return; } - // 'request-landing' on a waypoint — a nav beacon is not a port; the - // click is acknowledged on the console for now. + // A landing press on a non-port target (defensive — the panel only + // offers it on planets + stations) — acknowledged on the console. console.info(`[orbit] comms: ${id} — ${target.name} (key: ${target.key ?? 'none'})`); } @@ -4427,7 +4425,7 @@ export class GameScene extends Phaser.Scene { y: st.y, radius: st.bound ?? st.size, name: st.discoveryName, - typeLabel: st.kind === 'waypoint' ? 'Waypoint' : 'Station', + typeLabel: 'Station', discovered: isDisc(st.discoveryId), }); } diff --git a/js/scenes/SurfaceScene.js b/js/scenes/SurfaceScene.js index bbaa32b..0f750f8 100644 --- a/js/scenes/SurfaceScene.js +++ b/js/scenes/SurfaceScene.js @@ -82,7 +82,8 @@ export class SurfaceScene extends Phaser.Scene { this.planetType = String(data.type ?? ''); // e.g. "Rocky World" / "Smuggler Outpost" this.tetherLevel = Math.max(0, Math.round(Number(data.tetherLevel ?? 0))); // 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 // (data/landing.json → stationVideos) and a different music key // (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); if (p && p.class) return Economy.economicClass(p); 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 raw = g?.galaxy?.economy?.classBySystem?.get?.(sysId); const classes = raw ? [...raw] : []; diff --git a/js/ui/DiscoveryCompass.js b/js/ui/DiscoveryCompass.js index 3cd606e..938e9b6 100644 --- a/js/ui/DiscoveryCompass.js +++ b/js/ui/DiscoveryCompass.js @@ -427,7 +427,7 @@ function wrapPI(a) { */ export function isFarTarget(t, ship, farDistance) { 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; }