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

874 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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
```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<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.
```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<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.
```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<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
```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<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
```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<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
```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<UniverseScene>
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/<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