# Universe Family — Design Spec **Date:** 2026-04-25 **Status:** Draft **Packages:** 9 new packages across `geo`, `astronomy`, and `gaming` domains **Replaces:** `@suaveplan/day-night` (retired), `@suaveplan/geo` (restructured into 3 packages) --- ## 1. Motivation The existing `@suaveplan/day-night` package mixes gaming concerns (ECS components, gameplay modifiers, in-game clock) with simplified pseudo-astronomy (hardcoded sinusoidal sun arc, 30-day moon cycle, no lat/lon). The existing `@suaveplan/geo` covers Earth-surface geometry only. Neither is enterprise-grade, and neither is extensible. This spec defines a nine-package family that: - Provides real, observatory-accurate astronomical calculations via a pluggable provider architecture - Generalises Earth-surface geometry to any planet via configurable ellipsoid + physical parameters - Supports an observer-centric model: stars, planets, tides, and lighting are computed from the point of view of a single observer position — on any planet surface, in atmosphere, or anywhere in space - Runs all of the above at configurable N× speed for game engines, with physically-derived lighting and gameplay modifiers replacing hardcoded presets - Locks in a binary wire protocol (BitSchemas via `@suaveplan/bitpacker`) for efficient server→client delivery of star query results, deferring the SurrealDB implementation to a later phase --- ## 2. Package Family & Dependency Graph ### Locations in genesis monorepo ``` Layer 0 — Abstract geometry universal/geo/geo @suaveplan/geo Layer 1 — Concrete surfaces universal/geo/geo-earth @suaveplan/geo-earth universal/geo/geo-planet @suaveplan/geo-planet Layer 2 — Astronomical observer + plugin hub universal/astronomy/universe @suaveplan/universe universal/astronomy/solar-system @suaveplan/solar-system universal/astronomy/stars @suaveplan/stars universal/primitives/tides @suaveplan/tides (enterprise rewrite) Layer 3 — Game layer universal/gaming/gaming-universe @suaveplan/gaming-universe universal/gaming/gaming-environment-lighting @suaveplan/gaming-environment-lighting ``` ### Dependency graph ``` geo (zero workspace deps) geo-planet → geo, error, types geo-earth → geo, geo-planet, error, types ← geo-earth IS geo-planet configured for Earth universe → geo, geo-planet, bitpacker, error, types solar-system → universe, error, types stars → universe, bitpacker, error, types tides → universe, geo-planet, error, types gaming-universe → universe, timing, events, error, types gaming-environment-lighting → gaming-universe, events, error, types ``` ### What happens to existing packages - **`@suaveplan/day-night`** — retired. `gaming-environment-lighting` is its enterprise replacement. - **`@suaveplan/geo`** — restructured. The enterprise migration of the existing package becomes `geo-earth`. Abstract math is extracted into `geo`. `geo-planet` is new. - **`@suaveplan/tides`** — enterprise rewrite in place; gains `TidalProvider` adapter for `universe` integration. --- ## 3. `@suaveplan/geo` — Abstract Geometry ### Purpose Pure spherical and ellipsoidal geometry math. No planet-specific constants, no astronomical meaning. Every operation that needs a body's shape takes an explicit `Ellipsoid` parameter. ### Dependencies `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`. Zero other workspace deps. ### Core types ```ts interface Ellipsoid { semiMajorAxis: number; // equatorial radius, metres semiMinorAxis: number; // polar radius, metres flattening: number; // derived: (a − b) / a eccentricity: number; // derived } interface SphericalCoord { latitude: number; // degrees, −90 to 90 longitude: number; // degrees, −180 to 180 altitude?: number; // metres above surface } interface Vec3 { x: number; y: number; z: number } interface Mat3x3 { /* 9-element row-major matrix */ } ``` ### Subpath exports | Subpath | Contents | |---|---| | `geo/ellipsoid` | `Ellipsoid`, `defineEllipsoid(a, b)` | | `geo/transforms` | `sphericalToCartesian`, `cartesianToSpherical`, `toRadians`, `toDegrees` | | `geo/distance` | `greatCircleDistance(a, b, ellipsoid, unit?)`, `vincentyDistance(a, b, ellipsoid, unit?)` | | `geo/bearing` | `bearing(from, to)`, `destination(start, bearingDeg, distM, ellipsoid)`, `midpoint(a, b)` | | `geo/angular` | `angularSeparation(a, b)` — angle between two unit-sphere points | | `geo/bounds` | `BoundingBox`, `createBoundingBox`, `containsPoint`, `expandBox`, `mergeBoxes`, `intersects` | | `geo/polygon` | `pointInPolygon`, `polygonArea`, `polygonPerimeter`, `simplifyPolygon` | | `geo/units` | `DistanceUnit`, `convertDistance` | ### Explicit non-scope - No numeric constant for any specific body (Earth, Mars, etc.) - No geocoding providers - No geohash - No atmospheric models - No astronomical meaning — nothing about observers, stars, or coordinate frames --- ## 4. `@suaveplan/geo-planet` — Configurable Planet Surface ### Purpose Defines `PlanetConfig` — the single source of truth for any planet's physical, rotational, atmospheric, and satellite properties. Implements surface geometry on any configured ellipsoid. ### Dependencies `@suaveplan/geo`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`. ### Core types ```ts interface PlanetConfig { name: string; mass: number; // kg radius: number; // metres, equatorial polarRadius: number; // metres surfaceGravity: number; // m/s² rotationPeriod: number; // seconds (sidereal day) axialTilt: number; // degrees atmosphere: AtmosphereConfig | null; satellites: SatelliteConfig[]; orbital?: OrbitalConfig; // Keplerian elements — for game-defined planets textures?: PlanetTextureManifest; // identifier refs only, no bundled assets } interface AtmosphereConfig { surfacePressure: number; // Pa scaleHeight: number; // metres (pressure halving altitude) rayleighCoefficient: number; // for sky colour computation mieCoefficient: number; // for Mie scattering (dust, haze) extinctionPerAirMass: number; // magnitudes of star dimming per air mass } interface SatelliteConfig { name: string; mass: number; // kg radius: number; // metres orbitalRadius: number; // metres, semi-major axis orbitalPeriod: number; // seconds orbitalInclination: number; // degrees eccentricity: number; albedo: number; // 0–1, reflectivity } interface OrbitalConfig { semiMajorAxisAU: number; eccentricity: number; inclinationDeg: number; argumentOfPerihelionDeg: number; ascendingNodeDeg: number; meanAnomalyAtEpochDeg: number; epochJd: number; // Julian date of reference epoch } interface PlanetTextureManifest { diffuse?: string; // URL or asset identifier normal?: string; specular?: string; clouds?: string; night?: string; // city lights, etc. } ``` ### PlanetSurface ```ts class PlanetSurface { constructor(planet: PlanetConfig) distance(a: SphericalCoord, b: SphericalCoord, unit?: DistanceUnit): number bearing(from: SphericalCoord, to: SphericalCoord): number destination(start: SphericalCoord, bearingDeg: number, distanceM: number): SphericalCoord pointInPolygon(point: SphericalCoord, polygon: SphericalCoord[]): boolean atmosphericDensity(altitudeMetres: number): number // kg/m³, derived from AtmosphereConfig airMass(altitudeDeg: number): number // Kasten–Young formula } ``` --- ## 5. `@suaveplan/geo-earth` — Earth Configuration ### Purpose `geo-earth` is `geo-planet` pre-configured for Earth. It is not a sibling of `geo-planet` — it depends on `geo-planet` and exposes Earth's `PlanetConfig` as a constant, plus Earth-specific additions that do not generalise to other planets. ### Dependencies `@suaveplan/geo`, `@suaveplan/geo-planet`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`. ### What it provides ```ts // Earth's PlanetConfig constant — WGS84 ellipsoid, Moon as satellite, ISA atmosphere export const EARTH: PlanetConfig // Pre-built PlanetSurface for Earth — no config required export const earthSurface: PlanetSurface // Earth-specific additions export { GeocodingProvider, GeocodingResult, createInMemoryProvider, batchGeocode } export { encodeGeohash, decodeGeohash } export { parseCoordinate, formatCoordinate } // DMS parsing / formatting ``` The enterprise migration of the existing `@suaveplan/geo` lands here. The existing public API is preserved — only the internal implementation changes (math now delegates to `geo-planet`). --- ## 6. `@suaveplan/universe` — Observer Model, Frames, Plugin Registry, Wire Protocol ### Purpose The semantic core of the family. Where geometry becomes astronomy. Owns: the unified observer position type, coordinate frame definitions and converters, provider interfaces, the plugin registry, and wire BitSchemas. ### Dependencies `@suaveplan/geo`, `@suaveplan/geo-planet`, `@suaveplan/bitpacker`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`. ### 6a — ObserverPosition ```ts type ObserverPosition = | { type: 'surface'; planet: PlanetConfig; coord: SphericalCoord } | { type: 'atmospheric'; planet: PlanetConfig; coord: SphericalCoord; altitudeMetres: number } | { type: 'space'; position: Vec3; frame: CoordinateFrame } ``` The `PlanetConfig` is carried inline on surface/atmospheric positions — no lookup required at query time. ### 6b — CoordinateFrame ```ts type CoordinateFrame = | { type: 'heliocentric' } // Sun at origin, AU scale, J2000 equatorial | { type: 'galactic' } // Galactic centre, light-year scale | { type: 'custom'; origin: Vec3; basisMatrix: Mat3x3 } // Converters — pure functions, fully testable function convertFrame(position: Vec3, from: CoordinateFrame, to: CoordinateFrame): Vec3 function toHeliocentric(position: Vec3, from: CoordinateFrame): Vec3 function toGalactic(position: Vec3, from: CoordinateFrame): Vec3 ``` Heliocentric is used within the solar system. Galactic for interstellar travel. Custom allows game-defined local frames. ### 6c — Provider interfaces ```ts interface CelestialBody { name: string type: 'star' | 'planet' | 'moon' | 'asteroid' | 'comet' altitude: number // degrees above observer horizon azimuth: number // degrees clockwise from north apparentMagnitude: number angularDiameterArcmin: number // apparent size in arcminutes distanceAU: number heliocentricPosition: Vec3 } // Raw catalog record — no observer-relative fields interface CatalogStar { catalogId: string // e.g. 'HR 7001' (BSC5), 'HIP 91262' (Hipparcos) ra: number // Right Ascension, degrees J2000 dec: number // Declination, degrees J2000 distance?: number // parsecs (undefined if no parallax data) apparentMagnitude: number spectralType: string } // Observer-relative star — returned by getVisibleStars; altitude/azimuth always present interface ObservedStar extends CatalogStar { altitude: number // degrees above observer horizon azimuth: number // degrees clockwise from north extinctedMagnitude: number // apparentMagnitude dimmed by atmosphere (= apparentMagnitude on vacuum/space) } // Union used in StarProvider — queryCone returns CatalogStar, getVisibleStars returns ObservedStar type Star = CatalogStar | ObservedStar interface StarQueryOptions { magnitudeLimit?: number atmosphericExtinction?: boolean minAltitudeDeg?: number maxResults?: number } interface TideExtreme { type: 'high' | 'low' height: number // metres above chart datum time: Date satellite?: string // which moon caused this extreme (multi-moon planets) } interface SolarProvider { getBodies(observer: ObserverPosition, time: Date, signal?: AbortSignal): Promise getBody(name: string, observer: ObserverPosition, time: Date, signal?: AbortSignal): Promise // ObserverPosition carries PlanetConfig for surface/atmospheric; space observer uses heliocentric frame getSunrise(observer: ObserverPosition, date: Date, signal?: AbortSignal): Promise getSunset(observer: ObserverPosition, date: Date, signal?: AbortSignal): Promise } interface StarProvider { // Returns ObservedStar[] — altitude/azimuth always populated getVisibleStars(observer: ObserverPosition, time: Date, options: StarQueryOptions, signal?: AbortSignal): Promise // Returns CatalogStar[] — catalog-space query, no altitude/azimuth queryCone(ra: number, dec: number, radiusDeg: number, options?: StarQueryOptions, signal?: AbortSignal): Promise getStar(catalogId: string, signal?: AbortSignal): Promise } interface TidalProvider { predictHeight(planet: PlanetConfig, coord: SphericalCoord, time: Date, signal?: AbortSignal): Promise getExtremes(planet: PlanetConfig, coord: SphericalCoord, date: Date, signal?: AbortSignal): Promise } // WeatherProvider — locked now, implementation deferred (@suaveplan/weather) // Modulates star visibility (cloud cover occludes), sky colour (Mie scatter from fog/dust), // and gameplay modifier visibilityRangeMetres. UniverseClient passes weather state to // gaming-environment-lighting without breaking changes when @suaveplan/weather lands. interface WeatherState { cloudCoverage: number // 0–1 (0 = clear, 1 = overcast) cloudOpticalDepth: number // effective magnitude extinction from clouds (0 = none) fogDensity: number // 0–1, drives Mie coefficient boost in sky colour precipitationRate: number // mm/hr, for surface wetness / visibility degradation } interface WeatherProvider { getWeather(observer: ObserverPosition, time: Date, signal?: AbortSignal): Promise } ``` ### 6d — UniverseClient ```ts interface SceneOptions { includeSolar?: boolean // default true — compute sun/moon/planet positions includeStars?: boolean // default true — query visible stars includeTides?: boolean // default true — compute tidal extremes (surface only) starOptions?: StarQueryOptions // passed through to StarProvider.getVisibleStars } class UniverseClient { register(domain: 'solar', provider: SolarProvider): this register(domain: 'stars', provider: StarProvider): this register(domain: 'tides', provider: TidalProvider): this // weather slot: locked now, implementation deferred — gaming-environment-lighting // reads WeatherState from UniverseScene without a breaking API change when @suaveplan/weather lands register(domain: 'weather', provider: WeatherProvider): this // signal cancels all in-flight provider calls getScene(observer: ObserverPosition, time: Date, options?: SceneOptions, signal?: AbortSignal): Promise } interface UniverseScene { bodies: CelestialBody[] stars: ObservedStar[] tides?: TideExtreme[] weather?: WeatherState // undefined until WeatherProvider registered } ``` ### 6e — Wire BitSchemas Star data is quantised before packing — rendering does not require float64 precision. ```ts // ~11 bytes per star vs ~200 bytes JSON — ~18× compression const StarPacket = defineBitSchema([ { name: 'ra', type: 'uint', bits: 19 }, // 0–360,000 (0.001° steps) { name: 'dec', type: 'int', bits: 18 }, // ±90,000 { name: 'magnitude', type: 'uint', bits: 11 }, // 0–1,200 (×10 + 20 offset: −2.0 to 10.0) { name: 'altitude', type: 'int', bits: 15 }, // ±9,000 (0.01° steps) { name: 'azimuth', type: 'uint', bits: 16 }, // 0–36,000 { name: 'spectralClass', type: 'uint', bits: 4 }, // OBAFGKM + 8 reserved ]); // CelestialBodyPacket — apparentMagnitude: uint, ×10 + 300 offset, 0.1 magnitude resolution // covers −30.0 to +174.7 (Sun = −26.7 → stored as 33; Jupiter = −2.9 → stored as 271; full 11-bit range used) const CelestialBodyPacket = defineBitSchema([ { name: 'altitude', type: 'int', bits: 15 }, // ±9,000 (0.01° steps) { name: 'azimuth', type: 'uint', bits: 16 }, // 0–36,000 (0.01° steps) { name: 'angularDiameterArcmin', type: 'uint', bits: 14 }, // 0–10,000 (0.01′ steps, covers up to 166°) { name: 'apparentMagnitude', type: 'uint', bits: 11 }, // 0–2,047 (×10 + 300 offset: −30.0 to +74.7) { name: 'bodyType', type: 'uint', bits: 3 }, // 0=star 1=planet 2=moon 3=asteroid 4=comet ]); // PackedObservedStar — only the fields recoverable from StarPacket bits // catalogId and distance are NOT in the wire format; use the catalog source to re-fetch if needed interface PackedObservedStar { ra: number; dec: number; apparentMagnitude: number altitude: number; azimuth: number spectralClass: number // 0-6 = OBAFGKM, 7 = unknown } // Pack/unpack helpers — consumers never touch raw bit widths // packStars requires ObservedStar (altitude/azimuth always present) function packStars(stars: ObservedStar[]): Uint8Array function unpackStars(buf: Uint8Array, count: number): PackedObservedStar[] // PackedCelestialBody — only the fields recoverable from CelestialBodyPacket bits // name, distanceAU, heliocentricPosition are NOT in the wire format interface PackedCelestialBody { altitude: number; azimuth: number angularDiameterArcmin: number; apparentMagnitude: number bodyType: 0 | 1 | 2 | 3 | 4 // 0=star 1=planet 2=moon 3=asteroid 4=comet } function packBodies(bodies: CelestialBody[]): Uint8Array function unpackBodies(buf: Uint8Array, count: number): PackedCelestialBody[] ``` --- ## 7. `@suaveplan/solar-system` ### Purpose Implements `SolarProvider` using [astronomy.js](https://github.com/cosinekitty/astronomy) for real solar system bodies, and a Keplerian propagator for game-defined planets carrying `OrbitalConfig`. ### Dependencies `@suaveplan/universe`, `@suaveplan/error`, `@suaveplan/types`, `astronomy-engine`, `zod`, `@opentelemetry/api`. ### Two-engine model **Real bodies** (Sun, Moon, Mercury–Neptune, Pluto, Ceres): astronomy.js computes positions, rise/set/transit, phase, illumination, angular diameter — correct to arcsecond precision. **Configured bodies** (game planets with `OrbitalConfig`): Keplerian solver propagates position from epoch. Physically plausible, not observatory-grade. Per `CelestialBody` computed from `ObserverPosition`: | Field | Source | |---|---| | `altitude` / `azimuth` | Observer lat/lon + time → horizontal coordinates | | `angularDiameterArcmin` | `2 × arctan(radius / distance) × (180/π × 60)` | | `apparentMagnitude` | Phase angle + distance (astronomy.js for real bodies) | | `heliocentricPosition` | Vec3 in heliocentric frame | For `SurfacePosition` / `AtmosphericPosition` observers, `PlanetConfig.atmosphere` applies extinction to magnitudes near the horizon. ### Additional exports (standalone, outside `SolarProvider`) ```ts // planet defaults to EARTH — pass any PlanetConfig with axialTilt and OrbitalConfig for other planets getSeason(date: Date, planet?: PlanetConfig): Season getNextEquinox(after: Date, planet?: PlanetConfig): Date getNextSolstice(after: Date, planet?: PlanetConfig): Date getLunarPhase(time: Date): LunarPhase // phase 0–1, illumination %, name (Earth/Moon only) predictEclipse(after: Date, type: 'solar' | 'lunar'): EclipseEvent // Earth only getConstellation(ra: number, dec: number): string ``` All functions return `Date` in UTC. Consumers needing timezone-localised sunrise/sunset/season dates should convert via `@suaveplan/datetime`, which handles tz-aware date math and is the monorepo-standard for timezone operations. ### Injectable engine ```ts interface AstronomyEngine { bodyPosition(body: string, observer: AstroObserver, time: AstroTime): EquatorialCoords riseSetTransit(body: string, observer: AstroObserver, date: AstroTime): RiseSetTransit } function createSolarSystemProvider(options?: { engine?: AstronomyEngine }): SolarProvider ``` Tests inject a deterministic engine. Production uses astronomy.js. --- ## 8. `@suaveplan/stars` ### Purpose Implements `StarProvider` with a two-level plugin system: swappable catalog sources within `stars`, and `stars` itself registered as a plugin to `universe`. ### Dependencies `@suaveplan/universe`, `@suaveplan/bitpacker`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`. ### Core types ```ts // Native catalog record — the shape every StarCatalogSource works with internally interface RawStar { catalogId: string ra: number // degrees J2000 dec: number // degrees J2000 apparentMagnitude: number spectralType: string distance?: number // parsecs properMotionRa?: number // mas/yr — present in Hipparcos and above properMotionDec?: number // mas/yr parallax?: number // mas — present in Hipparcos and above } ``` ### Catalog source interface ```ts interface StarCatalogSource { readonly id: string readonly magnitudeLimit: number readonly starCount: number queryCone(ra: number, dec: number, radiusDeg: number, options?: StarQueryOptions, signal?: AbortSignal): Promise getById(catalogId: string, signal?: AbortSignal): Promise } ``` ### Bundled catalog — BSC5 Yale Bright Star Catalog (~9,096 stars, magnitude ≤ 6.5). Ships as compact binary (bitpacked at build time). ~100 KB vs ~2 MB JSON. ```ts import { createBsc5Source } from '@suaveplan/stars/catalogs/bsc5' const source = createBsc5Source() // no config, no network, offline ``` ### External catalog interfaces (contracts locked in, implementations deferred) ```ts createHipparcosSource(options: HipparcosSourceOptions): StarCatalogSource // 118k stars createTycho2Source(options: Tycho2SourceOptions): StarCatalogSource // 2.5M stars createGaiaSource(options: GaiaSourceOptions): StarCatalogSource // 1.5B sources ``` Each takes injectable `fetchFn` (wrap with `@suaveplan/retry`'s circuit breaker + jittered backoff — Hipparcos/Tycho/Gaia are flaky external APIs) and optional `cacheFn` (wire `@suaveplan/cache`'s `get`/`set` — catalog queries are read-mostly and TTL-friendly). ### SurrealDB provider interface (locked in, implementation deferred) `SurrealAdapter` is defined structurally within `stars` — `@suaveplan/surrealdb` satisfies it at runtime but is not a hard dependency, keeping `stars` free of the database package when the SurrealDB source is not used. ```ts // Structural interface — satisfied by SurrealAdapter from @suaveplan/surrealdb interface SurrealAdapterLike { query(sql: string, vars?: Record): Promise } interface SurrealStarProviderOptions { adapter: SurrealAdapterLike namespace: string database: string coneFn: string // SurrealDB JS function name, default: 'stars::cone_search' extinctionFn: string // default: 'stars::apply_extinction' atmosphereDensity?: number // g/cm², derived from observer's PlanetConfig.atmosphere } function createSurrealStarSource(options: SurrealStarProviderOptions): StarCatalogSource ``` Results arrive bitpacked (`StarPacket`), unpacked client-side using `unpackStars()`. ### Atmospheric extinction ```ts // Pogson's formula — applied when ObserverPosition has AtmosphereConfig extinctedMagnitude = rawMagnitude + extinctionCoeff × airMass(altitudeDeg) // Kasten–Young formula — accurate to ~5° altitude function airMass(altitudeDeg: number): number ``` `SpacePosition` and vacuum-planet observers receive raw magnitudes with no extinction. ### StarProvider implementation ```ts class StarProviderImpl implements StarProvider { constructor(sources: StarCatalogSource[]) // priority-ordered getVisibleStars(observer, time, options, signal?): Promise // 1. Compute horizon frame from ObserverPosition // 2. queryCone full visible hemisphere // 3. Filter by minAltitudeDeg, magnitudeLimit // 4. Apply atmospheric extinction from PlanetConfig // 5. Sort by brightness queryCone(ra, dec, radiusDeg, options?, signal?): Promise getStar(catalogId, signal?): Promise } ``` --- ## 9. `@suaveplan/tides` — Enterprise Rewrite ### Purpose Enterprise-grade tidal prediction for Earth (via real harmonic data) and any planet (via physics-based model). Implements `TidalProvider` for `universe` integration. ### Dependencies `@suaveplan/universe`, `@suaveplan/geo-planet`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`. ### What's preserved TICON and ADMIRALTY providers are kept intact — same harmonic constituent engine, same station data, same multi-provider fallback chain. Earth API is unchanged. ### Enterprise additions (cross-cutting) - Zod schemas for all public option types and wire formats - `AbortSignal` on every async method - `TidesError` extending `@suaveplan/error` with stable `TIDES.*` codes - OTel spans, counters, histograms on every provider call - Property-based tests (fast-check) on harmonic prediction engine - Injectable `fetchFn` (ADMIRALTY is a flaky external API — wrap with `@suaveplan/retry` circuit breaker + jitter), `clockFn`, optional `cacheFn` (wire `@suaveplan/cache` — tide tables are read-mostly and TTL-friendly) ### Multi-satellite physics model ```ts // Tidal height from all satellites in PlanetConfig, superposed function tidalHeight(planet: PlanetConfig, coord: SphericalCoord, time: Date): number // Individual satellite contribution — useful for visualisation function satelliteTidalContribution( planet: PlanetConfig, satellite: SatelliteConfig, coord: SphericalCoord, time: Date ): number ``` Physics: `tidalRange ∝ (satelliteMass / orbitalRadius³) × (planetRadius⁴ / planetMass)`. Multiple satellites superpose — orbital resonances produce complex spring/neap interference patterns automatically. ```ts createPhysicsBasedProvider(): TidalProvider // any PlanetConfig with satellites createTiconProvider(): TidalProvider // Earth, offline harmonic data createAdmiraltyProvider(options): TidalProvider // Earth, UKHO API 600+ stations ``` ### TidalProvider routing ```ts class TidalProviderImpl implements TidalProvider { constructor(options: { earthProviders: TidalProvider[] // tried in order for Earth physicsProvider: TidalProvider // fallback for non-Earth planets }) predictHeight(planet, coord, time, signal?): Promise getExtremes(planet, coord, date, signal?): Promise } ``` Earth identification uses `planet.name.toLowerCase() === 'earth'` — a named string match, not a floating-point mass/radius comparison, which would be fragile. The `EARTH` constant exported from `@suaveplan/geo-earth` has `name: 'Earth'`. Game-defined planets must use a different name. Non-Earth → physics model. Planets with no satellites → zero height, empty extremes. --- ## 10. `@suaveplan/gaming-universe` ### Purpose Wraps `UniverseClient` with a configurable N× game clock. All queries use game time. The `startTime` being a real calendar date gives the real historical or projected sky. ### Dependencies `@suaveplan/universe`, `@suaveplan/timing`, `@suaveplan/events`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`. ### Clock design `GamingUniverse` does **not** invent its own scheduler. It wraps `@suaveplan/timing`'s `AsyncScheduler` (which already provides AbortSignal drain, interval tick, and injectable clock) to drive game time. `setTimeScale`/`pause`/`resume` control the multiplier applied to `timing`'s real-time ticks before accumulating into game time. This makes cancellation and clock injection uniform with the rest of the monorepo, and makes `@suaveplan/game-loop` (when it exists) the natural per-tick integration point for calling `getScene()`. ### API ```ts interface GamingUniverseOptions { startTime: Date // real calendar date — determines actual star/planet positions timeScale: number // initial multiplier: 1 = real, 3600 = 1s = 1hr game time universe: UniverseClient clock?: Clock // injectable — from @suaveplan/types; defaults to Date.now } class GamingUniverse { constructor(options: GamingUniverseOptions) setTimeScale(n: number): void setGameTime(date: Date): void pause(): void resume(): void isPaused(): boolean getGameTime(): Date // Full scene at current game time — signal cancels in-flight provider calls getScene(observer: ObserverPosition, options?: SceneOptions, signal?: AbortSignal): Promise readonly universe: UniverseClient // Stops the clock and cancels all in-flight queries; idempotent dispose(): void } ``` ### Events ```ts // All emitted via @suaveplan/events hourChanged { gameTime: Date } dayChanged { gameTime: Date; dayNumber: number } seasonChanged { season: 'spring' | 'summer' | 'autumn' | 'winter' } sunriseStarted { planet: PlanetConfig; coord: SphericalCoord } sunsetStarted { planet: PlanetConfig; coord: SphericalCoord } moonriseStarted { satellite: SatelliteConfig; altitude: number } // altitude at horizon crossing moonsetStarted { satellite: SatelliteConfig; altitude: number } // altitude at horizon crossing (typically ≈0°) eclipseStarted { type: 'solar' | 'lunar'; totality: number } ``` --- ## 11. `@suaveplan/gaming-environment-lighting` ### Purpose Replaces `@suaveplan/day-night`. Light schedule, sky colour, star visibility thresholds, and gameplay modifiers are all physically derived from astronomical state — no hardcoded presets. ### Dependencies `@suaveplan/gaming-universe`, `@suaveplan/events`, `@suaveplan/error`, `@suaveplan/types`, `zod`, `@opentelemetry/api`. ### Sky colour — Rayleigh/Mie scattering ``` sunAltitude → airMass → scatteringPath → RGB sky colour ``` Driven by `AtmosphereConfig.rayleighCoefficient` and `mieCoefficient`: - Earth noon: deep blue - Earth sunset: orange/red (long scatter path) - Mars noon: butterscotch (CO₂ + dust Mie scatter) - Vacuum world: black always, stars always visible ### Twilight star visibility thresholds ``` sun altitude > 0° no stars visible sun altitude ≤ 0° civil twilight magnitude ≤ 1 sun altitude ≤ −6° nautical twilight magnitude ≤ 3 sun altitude ≤ −12° astronomical twilight magnitude ≤ 6 sun altitude ≤ −18° full dark all naked-eye stars ``` On vacuum planets (no `AtmosphereConfig`): threshold collapses, stars always visible. ### Satellite lighting ```ts // Per satellite contribution, summed across all PlanetConfig.satellites moonLight ∝ phaseIllumination × angularDiameter² × albedo ``` Multiple moons sum. Large close moons can brightly illuminate the surface at "night". ### Gameplay modifiers — continuous functions of ambient light ```ts interface EnvironmentModifiers { totalAmbientLight: number // 0–1, sun + all moons stealthBonus: number // 1 − totalAmbientLight visibilityRangeMetres: number // base × totalAmbientLight × atmosphereClarity npcSleepProbability: number // sigmoid of localHour relative to sunrise/sunset } ``` ### Environment component shapes These are **plain TypeScript interfaces** — framework-agnostic data shapes. They are not registered as ECS archetypes and carry no dependency on `@suaveplan/ecs`. Consumers wiring them into an ECS (e.g. bitecs, miniplex) do so by reading these shapes and mapping fields to their own component storage. ```ts interface DayNightClock { gameTime: Date; timeScale: number; paused: boolean } interface LightState { skyColour: RGB; sunAltitude: number; totalAmbientLight: number; moonContributions: MoonLight[]; weather?: WeatherState } interface DayNightAffected { modifiers: EnvironmentModifiers } ``` ### Render payload `gaming-environment-lighting` publishes a `RenderPayload` each tick — the canonical shape for downstream render integrations (`@suaveplan/r3f-environment`, Three.js custom shaders, etc.). Locking this now prevents churn when R3F integration lands. ```ts interface RenderPayload { // Sky skyColour: RGB sunAltitude: number // degrees sunAzimuth: number // degrees horizonColour: RGB // for gradient blending at low sun angles // Lighting totalAmbientLight: number // 0–1 moonContributions: MoonLight[] // Atmosphere fogDensity: number // from AtmosphereConfig + WeatherState rayleighTint: RGB // for shader uniforms // Textures (from PlanetTextureManifest — identifiers only, no assets) textures: PlanetTextureManifest // Weather (undefined if no WeatherProvider registered) weather?: WeatherState } ``` `gaming-environment-lighting` never loads or stores texture assets — `PlanetTextureManifest` identifiers are passed through verbatim. --- ## 12. Implementation Order Packages must land in dependency order: ``` 1. geo 2. geo-planet 3. geo-earth (depends on geo-planet) 4. universe (depends on geo, geo-planet) 5. solar-system (depends on universe) 6. stars (depends on universe) 7. tides (depends on universe, geo-planet) 8. gaming-universe (depends on universe) 9. gaming-environment-lighting (depends on gaming-universe) ``` Each package ships at `0.1.0` per genesis convention, with full 100% test coverage gate before publish. --- ## 13. Deferred (Out of Scope for This Spec) - SurrealDB star storage schema and SurrealQL JS function bodies (`stars::cone_search`, `stars::apply_extinction`) - Hipparcos, Tycho-2, Gaia catalog source implementations - R3F / Three.js rendering integration for sky, terrain, planet textures - Multiplayer synchronisation of game time across clients - `@suaveplan/weather` integration with atmospheric lighting The SurrealDB provider interface (`SurrealStarProviderOptions`, `createSurrealStarSource`) is locked in this spec — any future implementation must satisfy it. --- ## 14. Cross-Cutting Conventions All packages in this family follow the monorepo standard: - **Logging:** `@suaveplan/logger` + `@opentelemetry/api` together — OTel spans carry trace IDs; logger emits structured entries correlated to those spans. Neither is used without the other at public entrypoints. - **Retries:** `@suaveplan/retry` (circuit breaker + jittered backoff) wraps all `fetchFn` parameters that hit external APIs (ADMIRALTY, Hipparcos, Tycho, Gaia). - **Caching:** `@suaveplan/cache` is the canonical `cacheFn` adapter for read-mostly data (star catalog queries, tide tables). Consumers wire it in; packages don't import it directly. - **Dates:** All public APIs return `Date` (UTC). Consumers needing timezone-aware values use `@suaveplan/datetime`. - **ECS:** Component shapes in `gaming-environment-lighting` are plain TypeScript interfaces — no `@suaveplan/ecs` dependency. ECS consumers map fields to their own component storage. ## 15. Acceptance Criteria Each package is complete when: - [ ] `bunx turbo lint typecheck test --filter=@suaveplan/` green - [ ] `bunx turbo test:coverage --filter=@suaveplan/` at 100% - [ ] Every exported symbol has a co-located `.md` - [ ] No `TODO`, `FIXME`, `any`, `ts-ignore`, or `.skip` in `src/` - [ ] Zod schemas for all public option types - [ ] `@suaveplan/logger` + OTel spans + counters on all public entrypoints - [ ] `AbortSignal` on all async methods - [ ] OpenSpec change proposal created and archived on ship