874 lines
36 KiB
Markdown
874 lines
36 KiB
Markdown
# 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
|