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) }