Files
suaveplan/docs/superpowers/specs/2026-04-25-universe-family-design.md
2026-05-27 23:29:53 +01:00

36 KiB
Raw Blame History

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

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

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

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

// 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

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

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

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<CelestialBody[]>
  getBody(name: string, observer: ObserverPosition, time: Date, signal?: AbortSignal): Promise<CelestialBody>
  // ObserverPosition carries PlanetConfig for surface/atmospheric; space observer uses heliocentric frame
  getSunrise(observer: ObserverPosition, date: Date, signal?: AbortSignal): Promise<Date>
  getSunset(observer: ObserverPosition, date: Date, signal?: AbortSignal): Promise<Date>
}

interface StarProvider {
  // Returns ObservedStar[] — altitude/azimuth always populated
  getVisibleStars(observer: ObserverPosition, time: Date, options: StarQueryOptions, signal?: AbortSignal): Promise<ObservedStar[]>
  // Returns CatalogStar[] — catalog-space query, no altitude/azimuth
  queryCone(ra: number, dec: number, radiusDeg: number, options?: StarQueryOptions, signal?: AbortSignal): Promise<CatalogStar[]>
  getStar(catalogId: string, signal?: AbortSignal): Promise<CatalogStar>
}

interface TidalProvider {
  predictHeight(planet: PlanetConfig, coord: SphericalCoord, time: Date, signal?: AbortSignal): Promise<number>
  getExtremes(planet: PlanetConfig, coord: SphericalCoord, date: Date, signal?: AbortSignal): Promise<TideExtreme[]>
}

// 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<WeatherState>
}

6d — UniverseClient

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<UniverseScene>
}

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.

// ~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 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)

// 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

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

// 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

interface StarCatalogSource {
  readonly id: string
  readonly magnitudeLimit: number
  readonly starCount: number
  queryCone(ra: number, dec: number, radiusDeg: number, options?: StarQueryOptions, signal?: AbortSignal): Promise<RawStar[]>
  getById(catalogId: string, signal?: AbortSignal): Promise<RawStar | null>
}

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.

import { createBsc5Source } from '@suaveplan/stars/catalogs/bsc5'
const source = createBsc5Source()   // no config, no network, offline

External catalog interfaces (contracts locked in, implementations deferred)

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.

// Structural interface — satisfied by SurrealAdapter from @suaveplan/surrealdb
interface SurrealAdapterLike {
  query<T>(sql: string, vars?: Record<string, unknown>): Promise<T[]>
}

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

// 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

class StarProviderImpl implements StarProvider {
  constructor(sources: StarCatalogSource[])  // priority-ordered

  getVisibleStars(observer, time, options, signal?): Promise<ObservedStar[]>
    // 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<CatalogStar[]>
  getStar(catalogId, signal?): Promise<CatalogStar>
}

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

// 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.

createPhysicsBasedProvider(): TidalProvider   // any PlanetConfig with satellites
createTiconProvider(): TidalProvider           // Earth, offline harmonic data
createAdmiraltyProvider(options): TidalProvider // Earth, UKHO API 600+ stations

TidalProvider routing

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<number>
  getExtremes(planet, coord, date, signal?): Promise<TideExtreme[]>
}

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

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<UniverseScene>

  readonly universe: UniverseClient
  // Stops the clock and cancels all in-flight queries; idempotent
  dispose(): void
}

Events

// 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

// 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

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.

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.

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/<pkg> green
  • bunx turbo test:coverage --filter=@suaveplan/<pkg> 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