Files
Halcyon/api/admin_map_test.go
T
2026-10-01 22:16:48 +02:00

232 lines
8.3 KiB
Go

package api_test
import (
"context"
"encoding/json"
"math"
"testing"
"halcyon/api"
"halcyon/runner"
"halcyon/sim"
"halcyon/world"
)
type mapData struct {
Day int64 `json:"day"`
Time int64 `json:"time"`
Star struct{ Radius float64 } `json:"star"`
Belt struct{ Inner, Outer float64 } `json:"belt"`
Ranges struct{ Mining, Docking, Container float64 } `json:"ranges"`
Station struct {
X, Y, Z, VX, VY, VZ float64
OrbitRadius float64 `json:"orbit_radius"`
} `json:"station"`
Ores []string `json:"ores"`
Asteroids []struct {
ID int64
X, Y, Z float64
Ore []int64
Total int64
OrbitRadius float64 `json:"orbit_radius"`
Period int64
Inclination float64
} `json:"asteroids"`
Ships []struct {
ID, Owner int64
Name string
Alive bool
X, Y, Z, VX, VY, VZ float64
Fuel float64
Towing int `json:"containers_in_tow"`
TowOre int64 `json:"ore_in_tow"`
} `json:"ships"`
Containers []struct {
ID int64
X, Y, Z float64
Ore int64
Expires int64 `json:"expires_at"`
} `json:"containers"`
}
func (a *admin) mapData() (mapData, []byte) {
a.t.Helper()
raw := a.get("/admin/api/map", 200)
var m mapData
if err := json.Unmarshal(raw, &m); err != nil {
a.t.Fatal(err)
}
return m, raw
}
func TestAdminMap(t *testing.T) {
srv, cfg, st := newSiteStore(t, "", api.WithAdminPassword(adminPW))
a := newAdmin(t, srv.URL)
a.get("/admin/api/map", 401)
a.login()
// Before the first run: the world as the seed will make it.
m, _ := a.mapData()
if m.Day != 0 || len(m.Asteroids) != cfg.AsteroidCount || len(m.Ships) != 0 || len(m.Containers) != 0 {
t.Fatalf("initial map: day %d, %d asteroids, %d ships, %d containers", m.Day, len(m.Asteroids), len(m.Ships), len(m.Containers))
}
if m.Belt.Inner != cfg.BeltInner.Float64() || m.Belt.Outer != cfg.BeltOuter.Float64() || m.Star.Radius != sim.StarRadiusKm {
t.Errorf("belt/star: %+v %+v", m.Belt, m.Star)
}
if m.Ranges.Mining != sim.MineRangeKm || m.Ranges.Docking != sim.DockRangeKm || m.Ranges.Container != sim.ContainerRangeKm {
t.Errorf("ranges: %+v", m.Ranges)
}
if want := (m.Belt.Inner + m.Belt.Outer) / 2; math.Abs(m.Station.OrbitRadius-want) > 1 {
t.Errorf("station orbit radius %v, want %v", m.Station.OrbitRadius, want)
}
if len(m.Ores) != int(world.NumOre) || m.Ores[0] != "iron" {
t.Errorf("ores = %v", m.Ores)
}
// Two players launch ships that visit the station's container service.
ann, bob := newPlayer(t, srv.URL, "ann"), newPlayer(t, srv.URL, "bob")
ann.do("PUT", "/slots/5", map[string]string{"name": "TOW", "source": "ldi r1, 2\n out 0x66, r1\n ldi r1, 1\n out 0x55, r1\n halt"}, 200)
bob.do("PUT", "/slots/5", map[string]string{"name": "DROP", "source": "ldi r1, 3\n out 0x66, r1\n halt"}, 200)
ann.do("POST", "/ships/"+itoa(ann.ship)+"/launch", map[string]int{"slot": 5}, 202)
bob.do("POST", "/ships/"+itoa(bob.ship)+"/launch", map[string]int{"slot": 5}, 202)
runCfg := cfg
runCfg.AsteroidCount = 20
if _, err := runner.RunNextDay(context.Background(), st, runCfg); err != nil {
t.Fatal(err)
}
m, raw1 := a.mapData()
if m.Day != 1 || m.Time != 86400 || len(m.Asteroids) != 20 || len(m.Ships) != 2 {
t.Fatalf("after a day: day %d time %d, %d asteroids, %d ships", m.Day, m.Time, len(m.Asteroids), len(m.Ships))
}
// Asteroids are placed from their orbits at the snapshot time.
asts, _ := world.Generate(runCfg)
for _, i := range []int{0, 7, 19} {
want, _ := asts[i].Orbit.State(m.Time)
got := m.Asteroids[i]
if got.ID != asts[i].ID || math.Abs(got.X-want.X.Float64()) > 0.01 || math.Abs(got.Y-want.Y.Float64()) > 0.01 || math.Abs(got.Z-want.Z.Float64()) > 0.01 {
t.Errorf("asteroid %d at (%v,%v,%v), orbit says (%v,%v,%v)", got.ID, got.X, got.Y, got.Z, want.X.Float64(), want.Y.Float64(), want.Z.Float64())
}
}
if m.Asteroids[0].Total <= 0 || len(m.Asteroids[0].Ore) != int(world.NumOre) {
t.Errorf("asteroid ore: %+v", m.Asteroids[0])
}
// Ships have position and velocity: a ship launched at the station rides its circular orbit.
byName := map[string]int{}
for i, s := range m.Ships {
byName[s.Name] = i
r := math.Hypot(s.X, s.Y)
speed := math.Sqrt(s.VX*s.VX + s.VY*s.VY + s.VZ*s.VZ)
if !s.Alive || math.Abs(r-m.Station.OrbitRadius)/m.Station.OrbitRadius > 0.01 {
t.Errorf("ship %s: alive=%v at radius %.0f km, want about %.0f", s.Name, s.Alive, r, m.Station.OrbitRadius)
}
if math.Abs(speed-3)/3 > 0.02 {
t.Errorf("ship %s speed %.4f km/s, want the 3 km/s orbit speed", s.Name, speed)
}
if cos := (s.X*s.VX + s.Y*s.VY) / (r * speed); math.Abs(cos) > 0.03 {
t.Errorf("ship %s velocity is not tangential to its orbit (cos %.3f)", s.Name, cos)
}
if s.Fuel != float64(sim.CommandShip.FuelCap) {
t.Errorf("ship %s fuel %v", s.Name, s.Fuel)
}
}
if len(byName) != 2 {
t.Fatalf("ships named %v", byName)
}
ai, bi := byName["ann"], byName["bob"]
if m.Ships[ai].ID != ann.ship || m.Ships[ai].Towing != 2 || m.Ships[bi].Towing != 0 {
t.Errorf("containers in tow: ann %d, bob %d (want 2 and 0)", m.Ships[ai].Towing, m.Ships[bi].Towing)
}
if len(m.Containers) != 3 {
t.Fatalf("%d drifting containers, want bob's 3 (ann's 2 are in tow)", len(m.Containers))
}
for _, c := range m.Containers { // delivered on the first turn of day 0, adrift for ten days from then
if c.Expires != int64(sim.ContainerLifeDays)*86400 || c.Ore != 0 {
t.Errorf("drifting container %+v: want empty and expiring at %d", c, int64(sim.ContainerLifeDays)*86400)
}
}
// The response is cached per day, and changes when the world does.
if _, raw2 := a.mapData(); string(raw1) != string(raw2) {
t.Error("two requests on the same day differ")
}
if _, err := runner.RunNextDay(context.Background(), st, runCfg); err != nil {
t.Fatal(err)
}
m2, raw3 := a.mapData()
if m2.Day != 2 || string(raw3) == string(raw1) {
t.Errorf("after another day: day %d, cache stale=%v", m2.Day, string(raw3) == string(raw1))
}
if m2.Asteroids[0].X == m.Asteroids[0].X && m2.Asteroids[0].Y == m.Asteroids[0].Y {
t.Error("asteroids did not move between days")
}
}
type orbitData struct {
Ship int64
Points [][3]float64
Closed bool
HitStar bool `json:"hit_star"`
}
func (a *admin) orbit(ship int64, want int) orbitData {
a.t.Helper()
raw := a.get("/admin/api/map/orbit?ship="+itoa(ship), want)
if want != 200 {
return orbitData{}
}
var o orbitData
if err := json.Unmarshal(raw, &o); err != nil {
a.t.Fatal(err)
}
return o
}
func TestAdminMapOrbit(t *testing.T) {
srv, cfg, st := newSiteStore(t, "", api.WithAdminPassword(adminPW))
a := newAdmin(t, srv.URL)
a.login()
a.orbit(1, 404) // nothing launched yet
// A ship launched at the station, never thrusting, rides the station's own
// circular orbit: the predicted path should close after about one turn and
// stay at (about) the station's radius throughout.
ann := newPlayer(t, srv.URL, "ann")
ann.do("PUT", "/slots/5", map[string]string{"name": "COAST", "source": "halt"}, 200)
ann.do("POST", "/ships/"+itoa(ann.ship)+"/launch", map[string]int{"slot": 5}, 202)
runCfg := cfg
runCfg.AsteroidCount = 20
if _, err := runner.RunNextDay(context.Background(), st, runCfg); err != nil {
t.Fatal(err)
}
o := a.orbit(ann.ship, 200)
if o.Ship != ann.ship || !o.Closed || o.HitStar {
t.Fatalf("coasting orbit: closed=%v hit_star=%v", o.Closed, o.HitStar)
}
if len(o.Points) < 20 {
t.Fatalf("only %d points", len(o.Points))
}
stationR := (cfg.BeltInner.Float64() + cfg.BeltOuter.Float64()) / 2
for i, p := range o.Points {
r := math.Sqrt(p[0]*p[0] + p[1]*p[1] + p[2]*p[2])
if math.Abs(r-stationR)/stationR > 0.02 {
t.Fatalf("point %d at radius %.0f, want about %.0f", i, r, stationR)
}
}
// Roughly circular: the start and end points should be close together.
first, last := o.Points[0], o.Points[len(o.Points)-1]
if d := math.Hypot(first[0]-last[0], first[1]-last[1]); d > stationR*0.1 {
t.Errorf("closed loop does not return near its start: gap %.0f km", d)
}
a.orbit(9999, 404) // no such ship
a.get("/admin/api/map/orbit", 400)
a.get("/admin/api/map/orbit?ship=x", 400)
if resp, _ := a.req("POST", "/admin/api/logout", nil, nil); resp.StatusCode != 204 {
t.Fatalf("logout: %d", resp.StatusCode)
}
a.get("/admin/api/map/orbit?ship="+itoa(ann.ship), 401)
}