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 }