Initial Commit
This commit is contained in:
873
docs/superpowers/specs/2026-04-25-universe-family-design.md
Normal file
873
docs/superpowers/specs/2026-04-25-universe-family-design.md
Normal file
@@ -0,0 +1,873 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user