Files
2026-10-01 22:16:48 +02:00

270 lines
8.3 KiB
Go

package api
import (
"encoding/json"
"math"
"net/http"
"strconv"
"sync"
"halcyon/config"
"halcyon/fixed"
"halcyon/runner"
"halcyon/sim"
"halcyon/world"
)
// The admin map shows the world as it stood when the last daily run ended: every
// asteroid (placed from its orbit at that moment), every launched ship with its
// velocity, the station and any containers left drifting. Units are kilometres
// and kilometres per second, in the ecliptic frame centred on the Star.
// mapCache holds the last response, valid while the world stays on the same day.
type mapCache struct {
mu sync.Mutex
day string
body []byte
}
func (s *Server) adminMap(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
day, _, err := s.st.GetMeta(ctx, runner.MetaDay)
if err != nil {
fail(w, err)
return
}
s.mapCache.mu.Lock()
defer s.mapCache.mu.Unlock()
if s.mapCache.body == nil || s.mapCache.day != day {
body, err := s.buildMap(r)
if err != nil {
fail(w, err)
return
}
s.mapCache.day, s.mapCache.body = day, body
}
w.Header().Set("Content-Type", "application/json")
w.Write(s.mapCache.body)
}
func (s *Server) buildMap(r *http.Request) ([]byte, error) {
st, err := s.st.LoadState(r.Context())
if err != nil {
return nil, err
}
if st == nil { // nothing has run yet: show the world as it will begin
st = sim.NewState(s.cfg)
}
names := map[int64]string{}
accounts, err := s.st.ListAccounts(r.Context())
if err != nil {
return nil, err
}
for _, a := range accounts {
names[a.ID] = a.Name
}
t := st.Day * config.SecondsPerDay
type asteroid struct {
ID int64 `json:"id"`
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
Ore []int64 `json:"ore"`
Total int64 `json:"total"`
Radius float64 `json:"orbit_radius"`
Period int64 `json:"period"`
Inc float64 `json:"inclination"`
}
asts := make([]asteroid, 0, len(st.Asteroids))
for i := range st.Asteroids {
a := &st.Asteroids[i]
p, _ := a.Orbit.State(t)
ore := make([]int64, world.NumOre)
copy(ore, a.Ore[:])
asts = append(asts, asteroid{a.ID, p.X.Float64(), p.Y.Float64(), p.Z.Float64(), ore, a.TotalOre(), a.Orbit.A.Float64(), a.Orbit.Period, a.Orbit.Inc.Float64()})
}
towCount := map[int64]int{}
towOre := map[int64]int64{}
type container struct {
ID int64 `json:"id"`
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
Ore int64 `json:"ore"`
Expires int64 `json:"expires_at"`
}
drifting := []container{}
for _, c := range st.Containers {
if c.Towed != 0 {
towCount[c.Towed]++
towOre[c.Towed] += c.OreTotal()
continue
}
drifting = append(drifting, container{c.ID, c.Pos.X.Float64(), c.Pos.Y.Float64(), c.Pos.Z.Float64(), c.OreTotal(), c.ExpiresAt})
}
type ship struct {
ID int64 `json:"id"`
Owner int64 `json:"owner"`
Name string `json:"name"`
Alive bool `json:"alive"`
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
VX float64 `json:"vx"`
VY float64 `json:"vy"`
VZ float64 `json:"vz"`
Fuel float64 `json:"fuel"`
Throttle int32 `json:"throttle"`
Mining bool `json:"mining"`
Earned int64 `json:"earned"`
Towing int `json:"containers_in_tow"`
TowOre int64 `json:"ore_in_tow"`
}
ships := make([]ship, 0, len(st.Ships))
for _, sh := range st.Ships {
ships = append(ships, ship{sh.ID, sh.Owner, names[sh.Owner], sh.Alive,
sh.Pos.X.Float64(), sh.Pos.Y.Float64(), sh.Pos.Z.Float64(), sh.Vel.X.Float64(), sh.Vel.Y.Float64(), sh.Vel.Z.Float64(),
sh.Fuel.Float64(), sh.Throttle, sh.Mining, sh.Earned, towCount[sh.ID], towOre[sh.ID]})
}
sp, sv := st.Station.Orbit.State(t)
oreNames := make([]string, world.NumOre)
for o := range oreNames {
oreNames[o] = world.Ore(o).String()
}
return json.Marshal(map[string]any{
"day": st.Day,
"time": t,
"star": map[string]any{"radius": sim.StarRadiusKm},
"belt": map[string]any{"inner": s.cfg.BeltInner.Float64(), "outer": s.cfg.BeltOuter.Float64()},
"ranges": map[string]any{"mining": sim.MineRangeKm, "docking": sim.DockRangeKm, "container": sim.ContainerRangeKm},
"station": map[string]any{
"x": sp.X.Float64(), "y": sp.Y.Float64(), "z": sp.Z.Float64(),
"vx": sv.X.Float64(), "vy": sv.Y.Float64(), "vz": sv.Z.Float64(), "orbit_radius": st.Station.Orbit.A.Float64(),
},
"ores": oreNames,
"asteroids": asts,
"ships": ships,
"containers": drifting,
})
}
// adminMapOrbit predicts the path a ship would coast along from its current
// position and velocity, with the engine off from here on. It is what the map
// draws when a ship is clicked.
func (s *Server) adminMapOrbit(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
id, err := strconv.ParseInt(r.URL.Query().Get("ship"), 10, 64)
if err != nil || id <= 0 {
httpError(w, http.StatusBadRequest, "ship must be a positive integer")
return
}
st, err := s.st.LoadState(ctx)
if err != nil {
fail(w, err)
return
}
if st == nil {
st = sim.NewState(s.cfg)
}
var sh *sim.Ship
for _, x := range st.Ships {
if x.ID == id {
sh = x
break
}
}
if sh == nil || !sh.Alive {
httpError(w, http.StatusNotFound, "no such ship in the belt")
return
}
points, closed, hitStar := predictOrbit(s.cfg, sh.Pos, sh.Vel)
writeJSON(w, http.StatusOK, map[string]any{"ship": id, "points": points, "closed": closed, "hit_star": hitStar})
}
// predictOrbit integrates a ballistic (engine-off) path under the belt's
// simplified gravity (a pull of OrbitSpeed^2/r toward the Star, the same as
// sim.physics applies), starting from pos and vel. That pull is always
// radial, so angular momentum -- and with it the orbital plane -- never
// changes: "one turn around the Star" is therefore well defined even though,
// unlike real gravity, this force does not in general trace a closed ellipse.
// The path is sampled until it has swept a full turn in that plane, falls
// into the Star, flies far outside the belt, or a time cap is reached
// (whichever comes first), in plain float64: this is a display aid, not part
// of the deterministic simulation.
func predictOrbit(cfg config.Config, pos, vel fixed.Vec) (points [][3]float64, closed, hitStar bool) {
px, py, pz := pos.X.Float64(), pos.Y.Float64(), pos.Z.Float64()
vx, vy, vz := vel.X.Float64(), vel.Y.Float64(), vel.Z.Float64()
orbitSpeed := cfg.OrbitSpeed.Float64()
dt := float64(cfg.TickSeconds())
r0 := math.Sqrt(px*px + py*py + pz*pz)
starR := float64(sim.StarRadiusKm)
if r0 < starR || orbitSpeed <= 0 {
return nil, false, r0 < starR
}
// Plane normal (direction of pos x vel, i.e. of angular momentum), unit length.
nx, ny, nz := py*vz-pz*vy, pz*vx-px*vz, px*vy-py*vx
nlen := math.Sqrt(nx*nx + ny*ny + nz*nz)
planar := nlen > 1e-9
if planar {
nx, ny, nz = nx/nlen, ny/nlen, nz/nlen
}
// A ship on a circular orbit at the belt's own speed takes tens of days to
// come around, so the tick-by-tick budget has to be generous; the actual
// game tick length is used as the step so this matches sim.physics exactly.
const maxTicks = 300_000 // a hard safety cap (~208 sim days), for a wild escape
const targetPoints = 400
periodTicks := int(2 * math.Pi * r0 / orbitSpeed / dt)
capTicks := periodTicks * 3
if capTicks <= 0 || capTicks > maxTicks {
capTicks = maxTicks
}
recordEvery := capTicks / targetPoints
if recordEvery < 1 {
recordEvery = 1
}
escapeR := math.Max(5*r0, 5*cfg.BeltOuter.Float64())
points = [][3]float64{{px, py, pz}}
prevX, prevY, prevZ := px, py, pz
swept := 0.0
for i := 1; i <= capTicks; i++ {
r := math.Sqrt(px*px + py*py + pz*pz)
g := orbitSpeed * orbitSpeed * dt / r
vx -= px / r * g
vy -= py / r * g
vz -= pz / r * g
px += vx * dt
py += vy * dt
pz += vz * dt
if planar {
cx, cy, cz := prevY*pz-prevZ*py, prevZ*px-prevX*pz, prevX*py-prevY*px
sin := cx*nx + cy*ny + cz*nz
cos := prevX*px + prevY*py + prevZ*pz
swept += math.Atan2(sin, cos)
}
prevX, prevY, prevZ = px, py, pz
last := i == capTicks
if i%recordEvery == 0 || last {
points = append(points, [3]float64{px, py, pz})
}
if r := math.Sqrt(px*px + py*py + pz*pz); r < starR {
points = append(points, [3]float64{px, py, pz})
return points, false, true
} else if r > escapeR {
return points, false, false
}
if planar && math.Abs(swept) >= 2*math.Pi {
return points, true, false
}
}
return points, false, false
}