This commit is contained in:
root
2026-09-19 20:21:47 +02:00
commit 0798933b05
62 changed files with 7658 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
// Package world defines the static shape of the belt: bodies on analytic
// (on-rails) orbits and the procedural generation that creates them.
//
// The star pulls with acceleration v^2/r (v = config OrbitSpeed), so a circular
// orbit at any radius has the same speed v. Every body on rails is therefore
// on a circle traversed at speed v; only the period (2*pi*r/v) varies.
package world
import "wh/fixed"
// Orbit is a circular orbit around the origin (the star).
type Orbit struct {
A fixed.F // radius, km
Period int64 // seconds
M0 fixed.F // phase at t=0, radians
Inc fixed.F // inclination to the ecliptic (XY plane), radians
Node fixed.F // longitude of the ascending node, radians
}
// PeriodFor returns the period in seconds of a circle of radius a at speed v.
func PeriodFor(a, v fixed.F) int64 {
return fixed.TwoPi.Mul(a).Div(v).Int()
}
// State returns position (km) and velocity (km/s) at absolute time t seconds.
func (o Orbit) State(t int64) (pos, vel fixed.Vec) {
frac := fixed.FromInt(t % o.Period).Div(fixed.FromInt(o.Period))
s, c := fixed.SinCos(o.M0 + fixed.TwoPi.Mul(frac))
k := fixed.TwoPi.Mul(o.A).Div(fixed.FromInt(o.Period)) // speed
pos = fixed.Vec{X: o.A.Mul(c), Y: o.A.Mul(s)}
vel = fixed.Vec{X: -k.Mul(s), Y: k.Mul(c)}
return o.orient(pos), o.orient(vel)
}
// orient rotates a vector from the orbital plane into the ecliptic frame:
// Rz(Node) * Rx(Inc).
func (o Orbit) orient(v fixed.Vec) fixed.Vec {
return v.RotateX(o.Inc).RotateZ(o.Node)
}
+91
View File
@@ -0,0 +1,91 @@
package world
import (
"wh/config"
"wh/fixed"
"wh/rng"
)
// Ore identifies a raw material.
type Ore int
const (
Iron Ore = iota
Nickel
Ice
Platinum
NumOre
)
func (o Ore) String() string {
return [...]string{"iron", "nickel", "ice", "platinum"}[o]
}
type Asteroid struct {
ID int64
Orbit Orbit
Ore [NumOre]int64 // remaining kilograms of each ore
}
func (a *Asteroid) TotalOre() int64 {
var t int64
for _, v := range a.Ore {
t += v
}
return t
}
// Station is the dropoff point, on a circular orbit in the ecliptic plane.
type Station struct {
Orbit Orbit
}
// Stream labels keep independent generation phases from sharing randomness.
const (
labelBelt = iota + 1
labelStation
)
// Generate builds the asteroid field and station from the config seed.
func Generate(cfg config.Config) ([]Asteroid, Station) {
r := rng.Derive(cfg.Seed, labelBelt)
asts := make([]Asteroid, cfg.AsteroidCount)
for i := range asts {
a := r.FixedRange(cfg.BeltInner, cfg.BeltOuter)
asts[i] = Asteroid{
ID: int64(i + 1),
Orbit: Orbit{
A: a,
Period: PeriodFor(a, cfg.OrbitSpeed),
M0: r.FixedRange(0, fixed.TwoPi),
Node: r.FixedRange(0, fixed.TwoPi),
// Bias inclination low (squared uniform).
Inc: r.Fixed().Mul(r.Fixed()).Mul(cfg.MaxInclination),
},
}
// Each asteroid has a total mass and a random mix of ores.
total := int64(50_000) + int64(r.Intn(950_000))
var weights [NumOre]uint64
var wsum uint64
for o := range weights {
weights[o] = r.Intn(100) + 1
wsum += weights[o]
}
// Platinum is rare: only a fifth of asteroids carry much of it.
if r.Intn(5) != 0 {
wsum -= weights[Platinum]
weights[Platinum] = 0
}
for o := range weights {
asts[i].Ore[o] = total * int64(weights[o]) / int64(wsum)
}
}
sr := rng.Derive(cfg.Seed, labelStation)
a := (cfg.BeltInner + cfg.BeltOuter).DivInt(2)
st := Station{Orbit: Orbit{
A: a,
Period: PeriodFor(a, cfg.OrbitSpeed),
M0: sr.FixedRange(0, fixed.TwoPi),
}}
return asts, st
}
+78
View File
@@ -0,0 +1,78 @@
package world
import (
"math"
"testing"
"wh/config"
"wh/fixed"
)
func TestOrbitsAreCirclesAtCommonSpeed(t *testing.T) {
cfg := config.Default()
asts, _ := Generate(cfg)
want := cfg.OrbitSpeed.Float64()
for _, a := range asts {
o := a.Orbit
for _, ts := range []int64{0, 100_000, 777_777, o.Period / 3, o.Period - 1} {
p, v := o.State(ts)
if r := p.Len().Float64(); math.Abs(r-o.A.Float64()) > 1e-3 {
t.Fatalf("asteroid %d t=%d: radius %g, want %g", a.ID, ts, r, o.A.Float64())
}
if s := v.Len().Float64(); math.Abs(s-want)/want > 1e-5 {
t.Fatalf("asteroid %d t=%d: speed %g, want %g", a.ID, ts, s, want)
}
if d := p.Dot(v).Float64() / (p.Len().Float64() * want); math.Abs(d) > 1e-4 {
t.Fatalf("asteroid %d: velocity not tangent (cos=%g)", a.ID, d)
}
// Inclination bounds the height above the ecliptic.
if z := p.Z.Abs().Float64(); z > o.A.Float64()*math.Sin(o.Inc.Float64())*1.001+1e-3 {
t.Fatalf("asteroid %d: |z|=%g exceeds inclination bound", a.ID, z)
}
}
// Periodic.
p1, _ := o.State(12345)
p2, _ := o.State(12345 + o.Period)
if d := p1.Sub(p2).Len().Float64(); d > 1 {
t.Fatalf("asteroid %d not periodic, off by %g km", a.ID, d)
}
}
}
func TestOrbitPlaneMatchesInclination(t *testing.T) {
o := Orbit{A: fixed.FromInt(2_000_000), Inc: fixed.FromRatio(3, 10), Node: fixed.FromInt(2)}
o.Period = PeriodFor(o.A, fixed.FromInt(3))
// The orbit normal is Rz(Node)Rx(Inc) applied to +Z; positions must be
// perpendicular to it, and the maximum height is A*sin(Inc).
n := fixed.Vec{Z: fixed.One}.RotateX(o.Inc).RotateZ(o.Node)
var maxZ float64
for k := int64(0); k < 50; k++ {
p, _ := o.State(k * o.Period / 50)
if d := p.Dot(n).Float64(); math.Abs(d) > 1e-2 {
t.Fatalf("position not in the orbital plane: p.n = %g", d)
}
maxZ = math.Max(maxZ, p.Z.Float64())
}
if want := o.A.Float64() * math.Sin(0.3); math.Abs(maxZ-want)/want > 0.01 {
t.Fatalf("max z = %g, want %g", maxZ, want)
}
}
func TestGenerateDeterministic(t *testing.T) {
cfg := config.Default()
a1, s1 := Generate(cfg)
a2, s2 := Generate(cfg)
if len(a1) != cfg.AsteroidCount || s1 != s2 {
t.Fatal("mismatch")
}
for i := range a1 {
if a1[i] != a2[i] {
t.Fatalf("asteroid %d differs", i)
}
}
cfg.Seed = 2
a3, _ := Generate(cfg)
if a3[0] == a1[0] {
t.Fatal("different seeds should differ")
}
}