vibes
This commit is contained in:
+269
@@ -0,0 +1,269 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user