From 9dfdf79fa721f084a68285f102fe428a29bb156e Mon Sep 17 00:00:00 2001 From: root Date: Thu, 1 Oct 2026 22:16:48 +0200 Subject: [PATCH] vibes --- .gitignore | 9 + README.md | 27 ++ api/admin.go | 361 +++++++++++++++++ api/admin_internal_test.go | 83 ++++ api/admin_map_test.go | 231 +++++++++++ api/admin_test.go | 473 ++++++++++++++++++++++ api/adminmap.go | 269 +++++++++++++ api/api.go | 100 ++++- api/api_test.go | 45 +++ api/audit.go | 55 +++ api/site_test.go | 38 +- api/slots.go | 3 + cmd/server/main.go | 11 +- docker-compose.yml | 3 + docs/docs_test.go | 7 +- docs/manual.md | 202 +++++++--- examples/programs/ports.s | 19 +- runner/runner.go | 3 + sim/bus.go | 52 +-- sim/container.go | 289 ++++++++++++++ sim/container_test.go | 758 ++++++++++++++++++++++++++++++++++++ sim/hash.go | 11 +- sim/ports.go | 15 +- sim/sim_test.go | 103 +++-- sim/state.go | 40 +- sim/step.go | 39 +- store/audit.go | 157 ++++++++ store/entities.go | 94 ++++- store/store.go | 13 + web/static/admin/admin.css | 80 ++++ web/static/admin/admin.js | 430 ++++++++++++++++++++ web/static/admin/index.html | 19 + web/static/admin/map.js | 620 +++++++++++++++++++++++++++++ web/static/api.js | 8 +- web/static/status.js | 1 + web/static/view-auth.js | 2 +- web/static/view-downlink.js | 44 ++- web/static/view-fleet.js | 23 +- web/web.go | 4 + 39 files changed, 4560 insertions(+), 181 deletions(-) create mode 100644 .gitignore create mode 100644 api/admin.go create mode 100644 api/admin_internal_test.go create mode 100644 api/admin_map_test.go create mode 100644 api/admin_test.go create mode 100644 api/adminmap.go create mode 100644 api/audit.go create mode 100644 sim/container.go create mode 100644 sim/container_test.go create mode 100644 store/audit.go create mode 100644 web/static/admin/admin.css create mode 100644 web/static/admin/admin.js create mode 100644 web/static/admin/index.html create mode 100644 web/static/admin/map.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8365f04 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# secrets and local state +.env +*.db +*.db-shm +*.db-wal + +# build products +docs/generated/manual.pdf +node_modules/ diff --git a/README.md b/README.md index 8ea79d2..d79c66f 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,33 @@ scripts/manual-pdf.sh # or: render to docs/generated/ fo Until it has been rendered once, `/manual.pdf` is a 404 that says so. The Markdown source is always at `/manual.md`. +## Admin console + +`/admin` is a separate operator console: a list of accounts (ships, code slots, credits, last login and +activity) and an audit log of what players did. It has its own login: one password, taken from the +environment. With no password set the console is off. + +```sh +echo 'HALCYON_ADMIN_PASSWORD=a-long-passphrase-of-12-or-more-characters' > .env # git-ignored +docker compose up -d +``` + +- **Sessions:** a signed, HttpOnly, SameSite=Strict cookie scoped to `/admin`, valid for 8 hours. Changing + the password ends every session. Wrong guesses are rate-limited per client address (5 failures, then a + 15-minute lockout). Use it over HTTPS (Caddy does that) so the cookie is marked Secure. +- **What is audited:** `register`, `login`, `code_save`, `code_clear`, `program_upload`, `launch` and + `uplink`, each with the account, time, client address and the specifics: the slot, name, size and + SHA-256 of saved code; the slot or program launched; the full data of every uplink (at most 1 KB). Actions + that fail are not recorded. A `login` is signing in with an access key through the site's login form (or + `POST /login`); other requests are authenticated by the key alone. The admin can read the code an + account has saved, but not change anything. +- **Map:** the MAP tab is a top-down 2D chart of the belt: asteroids (coloured by dominant ore), the station, + drifting containers and every ship, each with its velocity vector drawn as an arrow (longer = faster). + Drag to pan, wheel or pinch to zoom, click an object for details, or pick a ship from the list to fly to it. + Positions are those at the start of the current day and are served by `GET /admin/api/map`. Clicking a ship + also draws the path it would coast along with its engine off from there, from `GET /admin/api/map/orbit`. +- **Retention:** the log is never pruned. It is the `audit_log` table, so it can be queried or trimmed with SQL. + ## Layout | path | what | diff --git a/api/admin.go b/api/admin.go new file mode 100644 index 0000000..4aa6d7c --- /dev/null +++ b/api/admin.go @@ -0,0 +1,361 @@ +package api + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "halcyon/store" +) + +// The admin interface is for the operator. It has its own login, a single password +// from the environment, and a signed, expiring session cookie. If no password is +// configured the whole interface is off. + +const ( + adminCookie = "halcyon_admin" + adminSessionTTL = 8 * time.Hour + adminMaxFails = 5 + adminLockout = 15 * time.Minute +) + +type adminAuth struct { + pwHash [32]byte + key []byte + + mu sync.Mutex + fails map[string]*failures +} + +type failures struct { + n int + first time.Time + until time.Time // locked out until then +} + +func newAdminAuth(password string) *adminAuth { + h := sha256.Sum256([]byte(password)) + // The session key is derived from the password, so changing the password + // ends every session, and nothing needs storing. + mac := hmac.New(sha256.New, h[:]) + mac.Write([]byte("halcyon admin session v1")) + return &adminAuth{pwHash: h, key: mac.Sum(nil), fails: map[string]*failures{}} +} + +func (a *adminAuth) passwordOK(pw string) bool { + h := sha256.Sum256([]byte(pw)) + return subtle.ConstantTimeCompare(h[:], a.pwHash[:]) == 1 +} + +func (a *adminAuth) sign(expiry string) string { + mac := hmac.New(sha256.New, a.key) + mac.Write([]byte(expiry)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// token is ".". +func (a *adminAuth) token(now time.Time) string { + exp := strconv.FormatInt(now.Add(adminSessionTTL).Unix(), 10) + return exp + "." + a.sign(exp) +} + +func (a *adminAuth) valid(tok string, now time.Time) bool { + exp, mac, ok := strings.Cut(tok, ".") + if !ok { + return false + } + want := a.sign(exp) + if subtle.ConstantTimeCompare([]byte(mac), []byte(want)) != 1 { + return false + } + t, err := strconv.ParseInt(exp, 10, 64) + return err == nil && now.Unix() < t +} + +// lockedOut reports whether ip has failed too often recently, and for how much longer. +func (a *adminAuth) lockedOut(ip string, now time.Time) (time.Duration, bool) { + a.mu.Lock() + defer a.mu.Unlock() + f := a.fails[ip] + if f == nil { + return 0, false + } + if now.After(f.until) && now.Sub(f.first) > adminLockout { + delete(a.fails, ip) // an old record + return 0, false + } + if now.Before(f.until) { + return f.until.Sub(now), true + } + return 0, false +} + +func (a *adminAuth) recordFailure(ip string, now time.Time) { + a.mu.Lock() + defer a.mu.Unlock() + f := a.fails[ip] + if f == nil || now.Sub(f.first) > adminLockout { + f = &failures{first: now} + a.fails[ip] = f + } + f.n++ + if f.n >= adminMaxFails { + f.until = now.Add(adminLockout) + } +} + +func (a *adminAuth) clear(ip string) { + a.mu.Lock() + defer a.mu.Unlock() + delete(a.fails, ip) +} + +// Option configures the API server. +type Option func(*Server) + +// WithAdminPassword turns the admin interface on, guarded by this password. +// An empty password leaves it off. +func WithAdminPassword(pw string) Option { + return func(s *Server) { + if pw != "" { + s.admin = newAdminAuth(pw) + } + } +} + +func noStore(w http.ResponseWriter) { w.Header().Set("Cache-Control", "no-store") } + +// adminOnly wraps a handler so it needs a valid admin session. +func (s *Server) adminOnly(h http.HandlerFunc) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + noStore(w) + if s.admin == nil { + httpError(w, http.StatusNotFound, "the admin interface is off: set HALCYON_ADMIN_PASSWORD to turn it on") + return + } + c, err := r.Cookie(adminCookie) + if err != nil || !s.admin.valid(c.Value, time.Now()) { + httpError(w, http.StatusUnauthorized, "admin login required") + return + } + h(w, r) + }) +} + +func secureRequest(r *http.Request) bool { + return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") +} + +func (s *Server) adminLogin(w http.ResponseWriter, r *http.Request) { + noStore(w) + if s.admin == nil { + httpError(w, http.StatusNotFound, "the admin interface is off: set HALCYON_ADMIN_PASSWORD to turn it on") + return + } + ip, now := clientIP(r), time.Now() + if wait, locked := s.admin.lockedOut(ip, now); locked { + w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1)) + httpError(w, http.StatusTooManyRequests, "too many failed attempts: try again in "+wait.Round(time.Second).String()) + return + } + var req struct { + Password string `json:"password"` + } + body, ok := readBody(w, r, 4<<10) + if !ok { + return + } + if err := json.Unmarshal(body, &req); err != nil { + httpError(w, http.StatusBadRequest, `body must be {"password": "..."}`) + return + } + if !s.admin.passwordOK(req.Password) { + s.admin.recordFailure(ip, now) + log.Printf("admin: failed login from %s", ip) + time.Sleep(400 * time.Millisecond) // slows guessing further + httpError(w, http.StatusUnauthorized, "wrong password") + return + } + s.admin.clear(ip) + log.Printf("admin: login from %s", ip) + http.SetCookie(w, &http.Cookie{ + Name: adminCookie, Value: s.admin.token(now), Path: "/admin", MaxAge: int(adminSessionTTL.Seconds()), + HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: secureRequest(r), + }) + writeJSON(w, http.StatusOK, map[string]any{"admin": true}) +} + +func (s *Server) adminLogout(w http.ResponseWriter, r *http.Request) { + noStore(w) + http.SetCookie(w, &http.Cookie{Name: adminCookie, Value: "", Path: "/admin", MaxAge: -1, + HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: secureRequest(r)}) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) adminSession(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"admin": true, "actions": store.AuditActions}) +} + +func (s *Server) adminAccounts(w http.ResponseWriter, r *http.Request) { + rows, err := s.st.ListAccounts(r.Context()) + if err != nil { + fail(w, err) + return + } + type account struct { + ID int64 `json:"id"` + Name string `json:"name"` + Credits int64 `json:"credits"` + Ships int `json:"ships"` + Active int `json:"in_belt"` + Slots int `json:"slots_used"` + Actions int `json:"actions"` + LastLogin int64 `json:"last_login"` + LastAction int64 `json:"last_action"` + } + out := make([]account, 0, len(rows)) + for _, a := range rows { + out = append(out, account{a.ID, a.Name, a.Credits, a.Ships, a.Active, a.Slots, a.Actions, a.LastLogin, a.LastAction}) + } + writeJSON(w, http.StatusOK, out) +} + +func pathID(w http.ResponseWriter, r *http.Request, name string) (int64, bool) { + id, err := strconv.ParseInt(r.PathValue(name), 10, 64) + if err != nil || id < 1 { + httpError(w, http.StatusBadRequest, "bad "+name) + return 0, false + } + return id, true +} + +func (s *Server) adminAccount(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r, "id") + if !ok { + return + } + p, err := s.st.PlayerByID(r.Context(), id) + if err != nil { + fail(w, err) + return + } + ships, err := s.st.PlayerShips(r.Context(), id) + if err != nil { + fail(w, err) + return + } + slots, err := s.st.ListSlotSummaries(r.Context(), id) + if err != nil { + fail(w, err) + return + } + recent, err := s.st.ListAudit(r.Context(), store.AuditFilter{PlayerID: id, Limit: 25}) + if err != nil { + fail(w, err) + return + } + type ship struct { + ID int64 `json:"id"` + Status string `json:"status"` + ProgramBytes int `json:"program_bytes"` + DownlinkDay int64 `json:"downlink_day"` + } + type slot struct { + Slot int `json:"slot"` + Name string `json:"name"` + Size int `json:"size"` + UpdatedAt string `json:"updated_at"` + } + resp := map[string]any{"id": p.ID, "name": p.Name, "credits": p.Credits, "ships": []ship{}, "slots": []slot{}, "audit": auditJSON(recent)} + sh := []ship{} + for _, x := range ships { + sh = append(sh, ship{x.ID, x.Status, x.ProgramSize, x.DownlinkDay}) + } + sl := []slot{} + for _, x := range slots { + sl = append(sl, slot{x.Slot, x.Name, x.Size, x.UpdatedAt}) + } + resp["ships"], resp["slots"] = sh, sl + writeJSON(w, http.StatusOK, resp) +} + +// adminSlotSource shows the code saved in one of an account's slots (read only). +func (s *Server) adminSlotSource(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r, "id") + if !ok { + return + } + n, err := strconv.Atoi(r.PathValue("n")) + if err != nil || n < 1 { + httpError(w, http.StatusBadRequest, errBadSlot.Error()) + return + } + cs, err := s.st.GetCodeSlot(r.Context(), id, n) + if errors.Is(err, store.ErrNotFound) { + httpError(w, http.StatusNotFound, "that slot is empty") + return + } + if err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"slot": n, "name": cs.Name, "source": cs.Source}) +} + +func auditJSON(rows []store.AuditRow) []map[string]any { + out := make([]map[string]any, 0, len(rows)) + for _, a := range rows { + var detail any + if json.Unmarshal([]byte(a.Detail), &detail) != nil { + detail = a.Detail + } + out = append(out, map[string]any{ + "id": a.ID, "at": a.At, "player_id": a.PlayerID, "name": a.Name, "action": a.Action, + "ship": a.Ship, "slot": a.Slot, "detail": detail, "ip": a.IP, + }) + } + return out +} + +// adminAudit lists audit entries, newest first: ?player=&action=&before=&limit=. +func (s *Server) adminAudit(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + f := store.AuditFilter{Action: strings.ToLower(q.Get("action"))} + if !store.ValidAuditAction(f.Action) { + httpError(w, http.StatusBadRequest, "unknown action") + return + } + for name, dst := range map[string]*int64{"player": &f.PlayerID, "before": &f.Before} { + if v := q.Get(name); v != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n < 0 { + httpError(w, http.StatusBadRequest, "bad "+name) + return + } + *dst = n + } + } + if v := q.Get("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + httpError(w, http.StatusBadRequest, "bad limit") + return + } + f.Limit = n + } + rows, err := s.st.ListAudit(r.Context(), f) + if err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, auditJSON(rows)) +} diff --git a/api/admin_internal_test.go b/api/admin_internal_test.go new file mode 100644 index 0000000..8b8ee55 --- /dev/null +++ b/api/admin_internal_test.go @@ -0,0 +1,83 @@ +package api + +import ( + "strings" + "testing" + "time" +) + +func TestAdminSessionTokens(t *testing.T) { + a := newAdminAuth("correct horse battery") + now := time.Unix(1_800_000_000, 0) + tok := a.token(now) + + if !a.valid(tok, now) || !a.valid(tok, now.Add(adminSessionTTL-time.Second)) { + t.Fatal("a fresh token should be valid for its whole lifetime") + } + if a.valid(tok, now.Add(adminSessionTTL)) || a.valid(tok, now.Add(24*time.Hour)) { + t.Fatal("an expired token was accepted") + } + exp, mac, _ := strings.Cut(tok, ".") + for name, bad := range map[string]string{ + "empty": "", + "no separator": "12345", + "wrong signature": exp + "." + strings.Repeat("0", len(mac)), + "extended expiry": "9999999999." + mac, // keep the signature, lengthen the life + "not a number": "abc." + a.sign("abc"), + "signature missing": exp + ".", + } { + if a.valid(bad, now) { + t.Errorf("%s: forged token accepted", name) + } + } + // Another password derives another key: tokens do not carry over. + if newAdminAuth("a different password").valid(tok, now) { + t.Fatal("a token signed under one password was accepted under another") + } +} + +func TestAdminPasswordCheck(t *testing.T) { + a := newAdminAuth("correct horse battery") + if !a.passwordOK("correct horse battery") { + t.Fatal("the right password was refused") + } + for _, pw := range []string{"", "correct horse batter", "correct horse battery ", "CORRECT HORSE BATTERY"} { + if a.passwordOK(pw) { + t.Errorf("%q accepted", pw) + } + } +} + +func TestAdminLockoutWindow(t *testing.T) { + a := newAdminAuth("pw") + now := time.Unix(1_800_000_000, 0) + for i := 0; i < adminMaxFails-1; i++ { + a.recordFailure("1.2.3.4", now) + if _, locked := a.lockedOut("1.2.3.4", now); locked { + t.Fatalf("locked out after only %d failures", i+1) + } + } + a.recordFailure("1.2.3.4", now) + wait, locked := a.lockedOut("1.2.3.4", now) + if !locked || wait != adminLockout { + t.Fatalf("after %d failures: locked=%v wait=%v", adminMaxFails, locked, wait) + } + if _, locked := a.lockedOut("5.6.7.8", now); locked { + t.Fatal("one address's failures locked out another") + } + if _, locked := a.lockedOut("1.2.3.4", now.Add(adminLockout+time.Second)); locked { + t.Fatal("the lockout never ends") + } + // Failures that are spread out over more than the window do not add up. + b := newAdminAuth("pw") + for i := 0; i < adminMaxFails+3; i++ { + b.recordFailure("9.9.9.9", now.Add(time.Duration(i)*adminLockout)) + } + if _, locked := b.lockedOut("9.9.9.9", now.Add(time.Duration(adminMaxFails+3)*adminLockout)); locked { + t.Fatal("slow guessing should not trip the lockout") + } + a.clear("1.2.3.4") + if _, locked := a.lockedOut("1.2.3.4", now); locked { + t.Fatal("a successful login should clear the failures") + } +} diff --git a/api/admin_map_test.go b/api/admin_map_test.go new file mode 100644 index 0000000..6d6643c --- /dev/null +++ b/api/admin_map_test.go @@ -0,0 +1,231 @@ +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) +} diff --git a/api/admin_test.go b/api/admin_test.go new file mode 100644 index 0000000..054d2ea --- /dev/null +++ b/api/admin_test.go @@ -0,0 +1,473 @@ +package api_test + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "net/http/cookiejar" + "strings" + "testing" + + "halcyon/api" +) + +const adminPW = "correct horse battery" + +// admin is a browser-like client (it keeps the session cookie) for the admin API. +type admin struct { + t *testing.T + base string + c *http.Client +} + +func newAdmin(t *testing.T, base string) *admin { + jar, _ := cookiejar.New(nil) + return &admin{t: t, base: base, c: &http.Client{Jar: jar}} +} + +func (a *admin) req(method, path string, body any, headers map[string]string) (*http.Response, []byte) { + a.t.Helper() + var rdr io.Reader + if body != nil { + b, _ := json.Marshal(body) + rdr = bytes.NewReader(b) + } + req, _ := http.NewRequest(method, a.base+path, rdr) + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := a.c.Do(req) + if err != nil { + a.t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp, b +} + +func (a *admin) get(path string, want int) []byte { + a.t.Helper() + resp, b := a.req("GET", path, nil, nil) + if resp.StatusCode != want { + a.t.Fatalf("GET %s: status %d want %d: %s", path, resp.StatusCode, want, b) + } + return b +} + +func (a *admin) login() { a.t.Helper(); a.mustLogin(adminPW) } + +func (a *admin) mustLogin(pw string) { + a.t.Helper() + resp, b := a.req("POST", "/admin/api/login", map[string]string{"password": pw}, nil) + if resp.StatusCode != 200 { + a.t.Fatalf("admin login: %d %s", resp.StatusCode, b) + } +} + +type auditRow struct { + ID int64 `json:"id"` + At int64 `json:"at"` + PlayerID int64 `json:"player_id"` + Name string `json:"name"` + Action string `json:"action"` + Ship int64 `json:"ship"` + Slot int `json:"slot"` + Detail map[string]any `json:"detail"` + IP string `json:"ip"` +} + +func (a *admin) audit(query string) []auditRow { + a.t.Helper() + var rows []auditRow + if err := json.Unmarshal(a.get("/admin/api/audit"+query, 200), &rows); err != nil { + a.t.Fatal(err) + } + return rows +} + +func actionsOf(rows []auditRow) []string { + var out []string + for i := len(rows) - 1; i >= 0; i-- { // oldest first + out = append(out, rows[i].Action) + } + return out +} + +func TestAdminIsOffWithoutAPassword(t *testing.T) { + srv, _ := newSite(t, "") + a := newAdmin(t, srv.URL) + resp, b := a.req("POST", "/admin/api/login", map[string]string{"password": ""}, nil) + if resp.StatusCode != 404 || !strings.Contains(string(b), "HALCYON_ADMIN_PASSWORD") { + t.Fatalf("login with the interface off: %d %s", resp.StatusCode, b) + } + a.get("/admin/api/accounts", 404) + a.get("/admin/api/audit", 404) +} + +func TestAdminLoginAndSession(t *testing.T) { + srv, _ := newSite(t, "", api.WithAdminPassword(adminPW)) + a := newAdmin(t, srv.URL) + + a.get("/admin/api/session", 401) + a.get("/admin/api/accounts", 401) + a.get("/admin/api/audit", 401) + + if resp, _ := a.req("POST", "/admin/api/login", map[string]string{"password": "wrong"}, nil); resp.StatusCode != 401 { + t.Fatalf("wrong password: %d", resp.StatusCode) + } + if resp, _ := a.req("POST", "/admin/api/login", "not json", nil); resp.StatusCode != 400 { + t.Fatalf("malformed body: %d", resp.StatusCode) + } + a.get("/admin/api/session", 401) // failed attempts do not log anyone in + + resp, _ := a.req("POST", "/admin/api/login", map[string]string{"password": adminPW}, nil) + if resp.StatusCode != 200 { + t.Fatalf("login: %d", resp.StatusCode) + } + var cookie *http.Cookie + for _, c := range resp.Cookies() { + if c.Name == "halcyon_admin" { + cookie = c + } + } + if cookie == nil || !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode || cookie.Path != "/admin" || cookie.MaxAge <= 0 { + t.Fatalf("session cookie = %+v: it must be HttpOnly, SameSite=Strict, scoped to /admin and expire", cookie) + } + if cookie.Secure { + t.Error("a plain-HTTP request should not get a Secure cookie (the browser would drop it)") + } + a.get("/admin/api/session", 200) + a.get("/admin/api/accounts", 200) + + // Behind the TLS-terminating proxy the cookie is Secure. + b := newAdmin(t, srv.URL) + resp, _ = b.req("POST", "/admin/api/login", map[string]string{"password": adminPW}, map[string]string{"X-Forwarded-Proto": "https"}) + if c := resp.Cookies(); len(c) == 0 || !c[0].Secure { + t.Errorf("cookie behind https = %+v, want Secure", c) + } + + // Every admin response is uncacheable. + if resp, _ := a.req("GET", "/admin/api/accounts", nil, nil); resp.Header.Get("Cache-Control") != "no-store" { + t.Errorf("Cache-Control = %q", resp.Header.Get("Cache-Control")) + } + + // A tampered cookie is refused. + tampered := newAdmin(t, srv.URL) + req, _ := http.NewRequest("GET", srv.URL+"/admin/api/accounts", nil) + req.AddCookie(&http.Cookie{Name: "halcyon_admin", Value: "9999999999." + strings.Repeat("a", 64)}) + if resp, err := tampered.c.Do(req); err != nil || resp.StatusCode != 401 { + t.Fatalf("forged cookie: %v %v", resp, err) + } + + // Logout ends the session. + if resp, _ := a.req("POST", "/admin/api/logout", nil, nil); resp.StatusCode != 204 { + t.Fatalf("logout: %d", resp.StatusCode) + } + a.get("/admin/api/session", 401) +} + +func TestAdminLoginIsRateLimitedPerAddress(t *testing.T) { + srv, _ := newSite(t, "", api.WithAdminPassword(adminPW)) + a := newAdmin(t, srv.URL) + // Behind the proxy the client's address is X-Forwarded-For; the test server's own peer is loopback. + from := func(ip string) map[string]string { return map[string]string{"X-Forwarded-For": ip} } + for i := 0; i < 5; i++ { + if resp, _ := a.req("POST", "/admin/api/login", map[string]string{"password": "guess"}, from("203.0.113.9")); resp.StatusCode != 401 { + t.Fatalf("attempt %d: %d", i+1, resp.StatusCode) + } + } + // Now even the right password is refused for that address... + resp, _ := a.req("POST", "/admin/api/login", map[string]string{"password": adminPW}, from("203.0.113.9")) + if resp.StatusCode != 429 || resp.Header.Get("Retry-After") == "" { + t.Fatalf("locked out address: %d retry-after=%q", resp.StatusCode, resp.Header.Get("Retry-After")) + } + // ...but not for anyone else. + if resp, _ := a.req("POST", "/admin/api/login", map[string]string{"password": adminPW}, from("198.51.100.7")); resp.StatusCode != 200 { + t.Fatalf("a different address was locked out too: %d", resp.StatusCode) + } +} + +func TestAdminAndPlayerCredentialsAreSeparate(t *testing.T) { + srv, _ := newSite(t, "", api.WithAdminPassword(adminPW)) + p := newPlayer(t, srv.URL, "ann") + a := newAdmin(t, srv.URL) + a.login() + + // A player's API key opens nothing under /admin, and the admin session opens nothing of a player's. + resp, _ := http.DefaultClient.Do(mustReq("GET", srv.URL+"/admin/api/accounts", "Bearer "+p.key)) + if resp.StatusCode != 401 { + t.Fatalf("a player key on the admin API: %d", resp.StatusCode) + } + if resp, _ := a.req("GET", "/ships", nil, nil); resp.StatusCode != 401 { + t.Fatalf("the admin cookie on a player endpoint: %d", resp.StatusCode) + } + // The admin password is not an API key. + resp, _ = http.DefaultClient.Do(mustReq("GET", srv.URL+"/me", "Bearer "+adminPW)) + if resp.StatusCode != 401 { + t.Fatalf("the admin password as an API key: %d", resp.StatusCode) + } +} + +func mustReq(method, url, auth string) *http.Request { + r, _ := http.NewRequest(method, url, nil) + r.Header.Set("Authorization", auth) + return r +} + +func TestAuditTrailOfPlayerActions(t *testing.T) { + srv, cfg := newSite(t, "", api.WithAdminPassword(adminPW)) + a := newAdmin(t, srv.URL) + a.login() + if rows := a.audit(""); len(rows) != 0 { + t.Fatalf("a new world has %d audit entries", len(rows)) + } + + p := newPlayer(t, srv.URL, "ann") // register + post := func(path string, body any, want int, hdr map[string]string) { + t.Helper() + var rdr io.Reader + if body != nil { + b, _ := json.Marshal(body) + rdr = bytes.NewReader(b) + } + req, _ := http.NewRequest("POST", srv.URL+path, rdr) + req.Header.Set("Authorization", "Bearer "+p.key) + for k, v := range hdr { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != want { + t.Fatalf("POST %s: %d, want %d", path, resp.StatusCode, want) + } + } + post("/login", nil, 200, map[string]string{"User-Agent": "TestBrowser/1.0", "X-Forwarded-For": "203.0.113.55, 10.0.0.1"}) + src := "ldi r1, 5\nhalt" + p.do("PUT", "/slots/5", map[string]string{"name": "MINER", "source": src}, 200) + p.do("PUT", "/slots/5", map[string]string{"name": "MINER 2", "source": "halt"}, 200) + p.do("DELETE", "/slots/5", nil, 204) + p.do("PUT", "/slots/6", map[string]string{"name": "TINY", "source": src}, 200) + post("/ships/"+itoa(p.ship)+"/launch", map[string]int{"slot": 6}, 202, nil) + msg := []byte{0x48, 0x45, 0x4c, 0x4c, 0x4f, 0x00, 0xff} + req, _ := http.NewRequest("PUT", srv.URL+"/ships/"+itoa(p.ship)+"/uplink", bytes.NewReader(msg)) + req.Header.Set("Authorization", "Bearer "+p.key) + if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != 204 { + t.Fatalf("uplink: %v %v", resp, err) + } + + rows := a.audit("") + want := []string{"register", "login", "code_save", "code_save", "code_clear", "code_save", "launch", "uplink"} + if got := actionsOf(rows); strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("audit trail = %v\nwant %v", got, want) + } + if rows[0].Action != "uplink" || rows[len(rows)-1].Action != "register" { + t.Fatal("entries should be listed newest first") + } + byAction := map[string][]auditRow{} + for i := len(rows) - 1; i >= 0; i-- { + byAction[rows[i].Action] = append(byAction[rows[i].Action], rows[i]) + } + for _, r := range rows { + if r.Name != "ann" || r.PlayerID != 1 || r.At == 0 || r.IP == "" { + t.Errorf("entry %d lacks who/when/where: %+v", r.ID, r) + } + } + if d := byAction["register"][0].Detail; d["name"] != "ann" || byAction["register"][0].Ship != p.ship { + t.Errorf("register: %+v", byAction["register"][0]) + } + lg := byAction["login"][0] + if lg.IP != "203.0.113.55" || lg.Detail["user_agent"] != "TestBrowser/1.0" { + t.Errorf("login: ip=%q detail=%v (the client address behind the proxy, and its browser)", lg.IP, lg.Detail) + } + s1 := byAction["code_save"][0] + if s1.Slot != 5 || s1.Detail["name"] != "MINER" || s1.Detail["bytes"] != float64(len(src)) || s1.Detail["sha256"] != sha256hex(src) { + t.Errorf("first save: %+v", s1) + } + if s2 := byAction["code_save"][1]; s2.Slot != 5 || s2.Detail["name"] != "MINER 2" { + t.Errorf("second save: %+v", s2) + } + if c := byAction["code_clear"][0]; c.Slot != 5 { + t.Errorf("clear: %+v", c) + } + l := byAction["launch"][0] + if l.Ship != p.ship || l.Slot != 6 || l.Detail["program_bytes"] != float64(8) || l.Detail["slot_name"] != "TINY" { + t.Errorf("launch: %+v", l) + } + u := byAction["uplink"][0] + if u.Ship != p.ship || u.Detail["bytes"] != float64(len(msg)) || u.Detail["hex"] != hex.EncodeToString(msg) { + t.Errorf("uplink: %+v (the data itself must be recorded)", u) + } + + // Filters and paging. + if got := actionsOf(a.audit("?action=code_save")); len(got) != 3 { + t.Errorf("action filter returned %v", got) + } + if got := a.audit("?action=CODE_SAVE"); len(got) != 3 { + t.Errorf("action filters ignore case: got %d", len(got)) + } + if got := a.audit("?player=1"); len(got) != len(want) { + t.Errorf("player filter: %d", len(got)) + } + if got := a.audit("?player=99"); len(got) != 0 { + t.Errorf("unknown player: %d entries", len(got)) + } + var seen []int64 + before := "" + for i := 0; i < 10; i++ { + page := a.audit("?limit=3" + before) + if len(page) == 0 { + break + } + for _, r := range page { + seen = append(seen, r.ID) + } + before = "&before=" + itoa(page[len(page)-1].ID) + } + if len(seen) != len(want) { + t.Fatalf("paging returned %d entries %v, want %d", len(seen), seen, len(want)) + } + for i := 1; i < len(seen); i++ { + if seen[i] >= seen[i-1] { + t.Fatalf("paging repeated or reordered entries: %v", seen) + } + } + for _, q := range []string{"?action=bogus", "?player=abc", "?before=-1", "?limit=0", "?limit=x"} { + a.get("/admin/api/audit"+q, 400) + } + + // Things that failed are not actions taken, and are not logged. + n := len(a.audit("")) + p.do("PUT", "/slots/1", map[string]string{"name": "X", "source": "halt"}, 403) // read-only example + p.do("POST", "/ships/"+itoa(p.ship)+"/launch", map[string]int{"slot": 6}, 409) // already launching + p.do("PUT", "/ships/"+itoa(p.ship)+"/program", string(make([]byte, cfg.ProgramBytes+4)), 413) // too large + bad := &player{t: t, base: srv.URL, key: "not-a-key"} + bad.do("POST", "/login", nil, 401) // a wrong key is not a login + if got := len(a.audit("")); got != n { + t.Errorf("%d entries recorded for actions that failed", got-n) + } +} + +func TestAuditRecordsProgramUploadAndPlainLaunch(t *testing.T) { + srv, _ := newSite(t, "", api.WithAdminPassword(adminPW)) + a := newAdmin(t, srv.URL) + a.login() + p := newPlayer(t, srv.URL, "bob") + prog := []byte{0x02, 0, 0, 0} // HALT + req, _ := http.NewRequest("PUT", srv.URL+"/ships/"+itoa(p.ship)+"/program", bytes.NewReader(prog)) + req.Header.Set("Authorization", "Bearer "+p.key) + if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != 204 { + t.Fatalf("upload: %v %v", resp, err) + } + p.do("POST", "/ships/"+itoa(p.ship)+"/launch", nil, 202) + rows := a.audit("") + if got := strings.Join(actionsOf(rows), ","); got != "register,program_upload,launch" { + t.Fatalf("trail = %s", got) + } + if up := rows[1]; up.Detail["bytes"] != float64(4) || up.Detail["sha256"] != sha256hex(string(prog)) || up.Ship != p.ship { + t.Errorf("program upload: %+v", up) + } + if l := rows[0]; l.Slot != 0 || l.Ship != p.ship || l.Detail["program"] == nil { + t.Errorf("plain launch: %+v", l) + } +} + +func TestAdminAccountsAndDetail(t *testing.T) { + srv, _ := newSite(t, "", api.WithAdminPassword(adminPW)) + a := newAdmin(t, srv.URL) + a.login() + + ann := newPlayer(t, srv.URL, "ann") + bob := newPlayer(t, srv.URL, "") // hostile names must come back as plain data + req, _ := http.NewRequest("POST", srv.URL+"/login", nil) + req.Header.Set("Authorization", "Bearer "+ann.key) + if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != 200 { + t.Fatalf("login: %v %v", resp, err) + } + ann.do("PUT", "/slots/4", map[string]string{"name": "ALPHA", "source": "; alpha\nhalt"}, 200) + ann.do("PUT", "/slots/9", map[string]string{"name": "OMEGA", "source": "; omega\nhalt"}, 200) + ann.do("POST", "/ships/"+itoa(ann.ship)+"/launch", map[string]int{"slot": 4}, 202) + + var accts []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Credits int64 `json:"credits"` + Ships int `json:"ships"` + SlotsUsed int `json:"slots_used"` + Actions int `json:"actions"` + LastLogin int64 `json:"last_login"` + LastAction int64 `json:"last_action"` + } + if err := json.Unmarshal(a.get("/admin/api/accounts", 200), &accts); err != nil || len(accts) != 2 { + t.Fatalf("accounts: %v %v", err, accts) + } + if x := accts[0]; x.ID != 1 || x.Name != "ann" || x.Ships != 1 || x.SlotsUsed != 2 || x.Actions != 5 || x.LastLogin == 0 || x.LastAction == 0 { + t.Errorf("ann's row: %+v", x) + } + if x := accts[1]; x.Name != "" || x.Actions != 1 || x.LastLogin != 0 || x.SlotsUsed != 0 { + t.Errorf("bob's row: %+v", x) + } + _ = bob + + var d struct { + Name string `json:"name"` + Ships []struct { + ID int64 `json:"id"` + Status string `json:"status"` + ProgramBytes int `json:"program_bytes"` + } `json:"ships"` + Slots []struct { + Slot int `json:"slot"` + Name string `json:"name"` + Size int `json:"size"` + } `json:"slots"` + Audit []auditRow `json:"audit"` + } + if err := json.Unmarshal(a.get("/admin/api/accounts/1", 200), &d); err != nil { + t.Fatal(err) + } + if d.Name != "ann" || len(d.Ships) != 1 || d.Ships[0].Status != "launching" || d.Ships[0].ProgramBytes == 0 { + t.Errorf("detail ships: %+v", d) + } + if len(d.Slots) != 2 || d.Slots[0].Slot != 4 || d.Slots[0].Name != "ALPHA" || d.Slots[1].Slot != 9 || d.Slots[0].Size != len("; alpha\nhalt") { + t.Errorf("detail slots: %+v", d.Slots) + } + if len(d.Audit) != 5 || d.Audit[0].Action != "launch" { + t.Errorf("detail audit: %d entries, first %v", len(d.Audit), d.Audit) + } + // Another account's actions do not leak into this one's. + for _, r := range d.Audit { + if r.PlayerID != 1 { + t.Errorf("foreign entry in ann's audit: %+v", r) + } + } + + // The code saved in a slot can be read. + var src struct { + Name string `json:"name"` + Source string `json:"source"` + } + json.Unmarshal(a.get("/admin/api/accounts/1/slots/9", 200), &src) + if src.Name != "OMEGA" || src.Source != "; omega\nhalt" { + t.Errorf("slot source: %+v", src) + } + a.get("/admin/api/accounts/1/slots/12", 404) // empty + a.get("/admin/api/accounts/1/slots/x", 400) + a.get("/admin/api/accounts/99", 404) + a.get("/admin/api/accounts/abc", 400) + a.get("/admin/api/accounts/99/slots/4", 404) +} + +func sha256hex(s string) string { + return sha256HexOf([]byte(s)) +} + +func sha256HexOf(b []byte) string { + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} diff --git a/api/adminmap.go b/api/adminmap.go new file mode 100644 index 0000000..9ab7d00 --- /dev/null +++ b/api/adminmap.go @@ -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 +} diff --git a/api/api.go b/api/api.go index af8a9b5..db66dc8 100644 --- a/api/api.go +++ b/api/api.go @@ -4,6 +4,7 @@ package api import ( "context" "encoding/base64" + "encoding/hex" "encoding/json" "errors" "io" @@ -20,8 +21,11 @@ import ( ) type Server struct { - st *store.Store - cfg config.Config + st *store.Store + cfg config.Config + admin *adminAuth // nil when the admin interface is off + + mapCache mapCache // the admin map's last response } // New returns the HTTP handler. @@ -37,26 +41,44 @@ type Server struct { // slot's code is assembled and sealed in first) // GET /slots, GET/PUT/DELETE /slots/{n} the sixteen code slots (1-3 are read-only examples) // PUT /ships/{id}/uplink raw bytes (<= comm size) (replaces any queued uplink) -// GET /ships/{id}/downlink raw bytes, X-Downlink-Day header +// POST /ships/{id}/scrap scrap a ship in the belt (on the next run it vanishes and a new ship +// appears in your inventory) +// GET /ships/{id}/downlink raw bytes, X-Downlink-Day header; ?day=N picks an earlier one +// GET /ships/{id}/downlinks {"days":[...]} the days with a stored downlink // // Authenticated routes take "Authorization: Bearer ". -func New(st *store.Store, cfg config.Config) *http.ServeMux { +func New(st *store.Store, cfg config.Config, opts ...Option) *http.ServeMux { s := &Server{st: st, cfg: cfg} + for _, o := range opts { + o(s) + } mux := http.NewServeMux() mux.HandleFunc("POST /register", s.register) mux.HandleFunc("GET /market", s.market) mux.HandleFunc("GET /info", s.info) mux.HandleFunc("POST /assemble", s.assemble) + mux.Handle("POST /login", s.auth(s.playerLogin)) mux.Handle("GET /me", s.auth(s.me)) mux.Handle("GET /ships", s.auth(s.ships)) mux.Handle("PUT /ships/{id}/program", s.auth(s.program)) mux.Handle("POST /ships/{id}/launch", s.auth(s.launch)) + mux.Handle("POST /ships/{id}/scrap", s.auth(s.scrap)) + mux.HandleFunc("POST /admin/api/login", s.adminLogin) + mux.HandleFunc("POST /admin/api/logout", s.adminLogout) + mux.Handle("GET /admin/api/session", s.adminOnly(s.adminSession)) + mux.Handle("GET /admin/api/accounts", s.adminOnly(s.adminAccounts)) + mux.Handle("GET /admin/api/accounts/{id}", s.adminOnly(s.adminAccount)) + mux.Handle("GET /admin/api/accounts/{id}/slots/{n}", s.adminOnly(s.adminSlotSource)) + mux.Handle("GET /admin/api/audit", s.adminOnly(s.adminAudit)) + mux.Handle("GET /admin/api/map", s.adminOnly(s.adminMap)) + mux.Handle("GET /admin/api/map/orbit", s.adminOnly(s.adminMapOrbit)) // ?ship=N: its predicted coasting path mux.Handle("GET /slots", s.auth(s.listSlots)) mux.Handle("GET /slots/{n}", s.auth(s.getSlot)) mux.Handle("PUT /slots/{n}", s.auth(s.putSlot)) mux.Handle("DELETE /slots/{n}", s.auth(s.deleteSlot)) mux.Handle("PUT /ships/{id}/uplink", s.auth(s.uplink)) mux.Handle("GET /ships/{id}/downlink", s.auth(s.downlink)) + mux.Handle("GET /ships/{id}/downlinks", s.auth(s.downlinks)) return mux } @@ -117,9 +139,25 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) { fail(w, err) return } + s.audit(r, p.ID, "register", ship, 0, map[string]any{"name": p.Name}) writeJSON(w, http.StatusCreated, map[string]any{"player_id": p.ID, "api_key": key, "ship_id": ship}) } +// playerLogin is what the sign-in form calls once it has been given an access key: +// it checks the key, returns the account, and puts the sign-in in the audit log. +// (Every other request is authenticated by the key alone, so it is not a "login".) +func (s *Server) playerLogin(w http.ResponseWriter, r *http.Request, p store.Player) { + s.audit(r, p.ID, "login", 0, 0, map[string]any{"user_agent": trunc(r.UserAgent(), 200)}) + s.me(w, r, p) +} + +func trunc(s string, n int) string { + if r := []rune(s); len(r) > n { + return string(r[:n]) + } + return s +} + func (s *Server) currentDay(ctx context.Context) int64 { v, ok, _ := s.st.GetMeta(ctx, runner.MetaDay) if !ok { @@ -240,6 +278,7 @@ func (s *Server) program(w http.ResponseWriter, r *http.Request, p store.Player) fail(w, err) return } + s.audit(r, p.ID, "program_upload", id, 0, map[string]any{"bytes": len(b), "sha256": sha256Hex(b)}) w.WriteHeader(http.StatusNoContent) } @@ -271,6 +310,20 @@ func (s *Server) launch(w http.ResponseWriter, r *http.Request, p store.Player) fail(w, err) return } + s.audit(r, p.ID, "launch", id, 0, map[string]any{"program": "already sealed in the ship"}) + w.WriteHeader(http.StatusAccepted) +} + +func (s *Server) scrap(w http.ResponseWriter, r *http.Request, p store.Player) { + id, ok := shipID(w, r) + if !ok { + return + } + if err := s.st.Scrap(r.Context(), p.ID, id); err != nil { + fail(w, err) + return + } + s.audit(r, p.ID, "scrap", id, 0, nil) w.WriteHeader(http.StatusAccepted) } @@ -291,15 +344,52 @@ func (s *Server) uplink(w http.ResponseWriter, r *http.Request, p store.Player) fail(w, err) return } + // The whole message is kept: it is at most one kilobyte. + s.audit(r, p.ID, "uplink", id, 0, map[string]any{"bytes": len(b), "sha256": sha256Hex(b), "hex": hex.EncodeToString(b)}) w.WriteHeader(http.StatusNoContent) } +func (s *Server) downlinks(w http.ResponseWriter, r *http.Request, p store.Player) { + id, ok := shipID(w, r) + if !ok { + return + } + if _, _, err := s.st.Downlink(r.Context(), p.ID, id); err != nil { + fail(w, err) // not this player's ship + return + } + days, err := s.st.DownlinkDays(r.Context(), p.ID, id) + if err != nil { + fail(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"days": days}) +} + func (s *Server) downlink(w http.ResponseWriter, r *http.Request, p store.Player) { id, ok := shipID(w, r) if !ok { return } - data, day, err := s.st.Downlink(r.Context(), p.ID, id) + var ( + data []byte + day int64 + err error + ) + if q := r.URL.Query().Get("day"); q != "" { + day, err = strconv.ParseInt(q, 10, 64) + if err != nil || day < 0 { + httpError(w, http.StatusBadRequest, "day must be a non-negative integer") + return + } + data, err = s.st.DownlinkAt(r.Context(), p.ID, id, day) + if errors.Is(err, store.ErrNotFound) { + httpError(w, http.StatusNotFound, "no downlink for that day") + return + } + } else { + data, day, err = s.st.Downlink(r.Context(), p.ID, id) + } if err != nil { fail(w, err) return diff --git a/api/api_test.go b/api/api_test.go index 0b4702f..17efc24 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -118,6 +118,7 @@ func TestEndToEnd(t *testing.T) { if b := do("GET", ship+"/downlink", nil, 200); len(b) != cfg.CommBytes || string(b[:4]) != "PING" { t.Fatalf("downlink = %q", b[:8]) } + do("GET", ship+"/downlinks", nil, 200) var ships []struct{ Status string } json.Unmarshal(do("GET", "/ships", nil, 200), &ships) if len(ships) != 1 || ships[0].Status != store.StatusActive { @@ -131,6 +132,20 @@ func TestEndToEnd(t *testing.T) { if err != nil { t.Fatal(err) } + // Both days' downlinks are kept: day 0 carries PING. + var hist struct{ Days []int64 } + json.Unmarshal(do("GET", ship+"/downlinks", nil, 200), &hist) + if len(hist.Days) != 2 || hist.Days[0] != 0 || hist.Days[1] != 1 { + t.Fatalf("downlink days = %v", hist.Days) + } + if b := do("GET", ship+"/downlink?day=0", nil, 200); string(b[:4]) != "PING" { + t.Fatalf("day 0 downlink = %q", b[:8]) + } + do("GET", ship+"/downlink?day=1", nil, 200) + do("GET", ship+"/downlink?day=7", nil, 404) + do("GET", ship+"/downlink?day=x", nil, 400) + do("GET", ship+"/downlink?day=-1", nil, 400) + mem := sim.NewState(cfg) in := sim.DayInput{ Launches: []sim.Launch{{ShipID: reg.ShipID, Owner: 1, Program: prog}}, @@ -147,6 +162,36 @@ func TestEndToEnd(t *testing.T) { t.Fatal("state after DB round-trip differs from in-memory run") } + // Scrapping: only a ship in the belt, effective on the next run, which also hands out a new ship. + var reg2 struct { + APIKey string `json:"api_key"` + ShipID int64 `json:"ship_id"` + } + json.Unmarshal(do("POST", "/register", []byte(`{"name":"bob"}`), 201), ®2) + do("POST", "/ships/"+itoa(reg2.ShipID)+"/scrap", nil, 404) // ann cannot scrap bob's ship + do("POST", "/ships/9999/scrap", nil, 404) + key = reg2.APIKey + do("POST", ship+"/scrap", nil, 404) // bob cannot scrap ann's ship + do("POST", "/ships/"+itoa(reg2.ShipID)+"/scrap", nil, 409) // docked, not in the belt + key = reg.APIKey + do("POST", ship+"/scrap", nil, 202) + do("POST", ship+"/scrap", nil, 409) // already queued + var fleet []struct { + ID int64 + Status string + } + json.Unmarshal(do("GET", "/ships", nil, 200), &fleet) + if len(fleet) != 1 || fleet[0].Status != store.StatusScrapping { + t.Fatalf("fleet while scrapping = %+v", fleet) + } + if _, err := runner.RunNextDay(ctx, st, cfg); err != nil { + t.Fatal(err) + } + json.Unmarshal(do("GET", "/ships", nil, 200), &fleet) + if len(fleet) != 1 || fleet[0].Status != store.StatusInventory || fleet[0].ID == reg.ShipID { + t.Fatalf("fleet after scrapping = %+v, want one new docked ship", fleet) + } + // Changing the config on an existing world is refused. cfg.Seed = 99 if _, err := runner.RunNextDay(ctx, st, cfg); err == nil { diff --git a/api/audit.go b/api/audit.go new file mode 100644 index 0000000..fc5aa9d --- /dev/null +++ b/api/audit.go @@ -0,0 +1,55 @@ +package api + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "log" + "net" + "net/http" + "strings" + "time" + + "halcyon/store" +) + +// clientIP is the address a request really came from. Behind our own reverse +// proxy (a loopback or private peer) the proxy's X-Forwarded-For names it; +// otherwise the header is untrusted and ignored. +func clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + if ip := net.ParseIP(host); ip != nil && (ip.IsLoopback() || ip.IsPrivate()) { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + first := strings.TrimSpace(strings.Split(xff, ",")[0]) + if net.ParseIP(first) != nil { + return first + } + } + } + return host +} + +func sha256Hex(b []byte) string { + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} + +// audit records something a player did. It is best effort: a failure to write +// the log is reported but never fails the player's request. +func (s *Server) audit(r *http.Request, player int64, action string, ship int64, slot int, detail map[string]any) { + if detail == nil { + detail = map[string]any{} + } + b, _ := json.Marshal(detail) + err := s.st.AddAudit(context.WithoutCancel(r.Context()), store.AuditEntry{ + At: time.Now().Unix(), PlayerID: player, Action: action, Ship: ship, Slot: slot, + Detail: string(b), IP: clientIP(r), + }) + if err != nil { + log.Printf("audit: could not record %s for player %d: %v", action, player, err) + } +} diff --git a/api/site_test.go b/api/site_test.go index 0c71698..d526aad 100644 --- a/api/site_test.go +++ b/api/site_test.go @@ -20,13 +20,13 @@ import ( ) // newSite starts the API with the web front end mounted, as cmd/server does. -func newSite(t *testing.T, manualPDF string) (*httptest.Server, config.Config) { - srv, cfg, _ := newSiteStore(t, manualPDF) +func newSite(t *testing.T, manualPDF string, opts ...api.Option) (*httptest.Server, config.Config) { + srv, cfg, _ := newSiteStore(t, manualPDF, opts...) return srv, cfg } // newSiteStore is newSite, also returning the database so tests can run a simulated day. -func newSiteStore(t *testing.T, manualPDF string) (*httptest.Server, config.Config, *store.Store) { +func newSiteStore(t *testing.T, manualPDF string, opts ...api.Option) (*httptest.Server, config.Config, *store.Store) { t.Helper() cfg := config.Default() dsn := "sqlite:" + filepath.Join(t.TempDir(), "t.db") @@ -49,7 +49,7 @@ func newSiteStore(t *testing.T, manualPDF string) (*httptest.Server, config.Conf if err := st.Migrate(context.Background()); err != nil { t.Fatal(err) } - mux := api.New(st, cfg) + mux := api.New(st, cfg, opts...) mux.Handle("/", web.Handler(manualPDF)) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) @@ -182,3 +182,33 @@ func TestManualPDFServedFromFileAndPickedUpLive(t *testing.T) { t.Fatalf("range: %d %q", rr.StatusCode, part) } } + +func TestAdminPageIsServedWithSafeHeaders(t *testing.T) { + srv, _ := newSite(t, "") + resp, body := get(t, srv.URL+"/admin/") + if resp.StatusCode != 200 || !strings.Contains(body, "Halcyon Admin") { + t.Fatalf("/admin/: %d", resp.StatusCode) + } + if !strings.Contains(resp.Header.Get("Content-Security-Policy"), "default-src 'self'") { + t.Error("the admin page needs the site's Content-Security-Policy") + } + if !strings.Contains(resp.Header.Get("X-Robots-Tag"), "noindex") || resp.Header.Get("Cache-Control") != "no-store" { + t.Errorf("admin page headers: robots=%q cache=%q", resp.Header.Get("X-Robots-Tag"), resp.Header.Get("Cache-Control")) + } + for _, path := range []string{"/admin/admin.js", "/admin/admin.css"} { + if r, _ := get(t, srv.URL+path); r.StatusCode != 200 { + t.Errorf("%s: %d", path, r.StatusCode) + } + } + // /admin (no slash) reaches the same page. + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + r, err := client.Get(srv.URL + "/admin") + if err != nil { + t.Fatal(err) + } + r.Body.Close() + // The Location is relative ("admin/"), which a browser resolves to /admin/. + if loc := r.Header.Get("Location"); r.StatusCode != 301 || (loc != "admin/" && loc != "/admin/") { + t.Errorf("/admin: %d -> %q, want a redirect to /admin/", r.StatusCode, loc) + } +} diff --git a/api/slots.go b/api/slots.go index e54bfcc..8389a1c 100644 --- a/api/slots.go +++ b/api/slots.go @@ -129,6 +129,7 @@ func (s *Server) putSlot(w http.ResponseWriter, r *http.Request, p store.Player) fail(w, err) return } + s.audit(r, p.ID, "code_save", 0, n, map[string]any{"name": req.Name, "bytes": len(req.Source), "sha256": sha256Hex([]byte(req.Source))}) writeJSON(w, http.StatusOK, slotView{Slot: n, Name: req.Name}) } @@ -146,6 +147,7 @@ func (s *Server) deleteSlot(w http.ResponseWriter, r *http.Request, p store.Play fail(w, err) return } + s.audit(r, p.ID, "code_clear", 0, n, nil) w.WriteHeader(http.StatusNoContent) } @@ -173,5 +175,6 @@ func (s *Server) launchFromSlot(w http.ResponseWriter, r *http.Request, p store. fail(w, err) return } + s.audit(r, p.ID, "launch", ship, n, map[string]any{"slot_name": sl.Name, "program_bytes": len(prog), "program_sha256": sha256Hex(prog)}) writeJSON(w, http.StatusAccepted, map[string]any{"slot": n, "program_bytes": len(prog)}) } diff --git a/cmd/server/main.go b/cmd/server/main.go index 6140e41..01efd5d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -35,7 +35,16 @@ func main() { if err := st.Migrate(context.Background()); err != nil { log.Fatal(err) } - mux := api.New(st, *cfg) + adminPassword := os.Getenv("HALCYON_ADMIN_PASSWORD") + switch { + case adminPassword == "": + log.Print("admin interface is OFF: set HALCYON_ADMIN_PASSWORD to turn it on at /admin") + case len(adminPassword) < 12: + log.Print("admin interface is on at /admin, but its password is short: use at least 12 characters") + default: + log.Print("admin interface is on at /admin") + } + mux := api.New(st, *cfg, api.WithAdminPassword(adminPassword)) mux.Handle("/", web.Handler(*manual)) // the site; API routes are more specific and win log.Printf("listening on %s", *addr) log.Fatal(http.ListenAndServe(*addr, mux)) diff --git a/docker-compose.yml b/docker-compose.yml index ceed531..3a9e993 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,9 @@ services: HALCYON_RAM_BYTES: ${HALCYON_RAM_BYTES:-8192} HALCYON_COMM_BYTES: ${HALCYON_COMM_BYTES:-1024} HALCYON_ASTEROIDS: ${HALCYON_ASTEROIDS:-500} + # The operator console at /admin. Set the password in the environment or in a .env + # file next to this one (which is git-ignored). Empty leaves the console switched off. + HALCYON_ADMIN_PASSWORD: ${HALCYON_ADMIN_PASSWORD:-} volumes: - data:/data - manual:/manual:ro # the PDF manual, rendered separately (see below) diff --git a/docs/docs_test.go b/docs/docs_test.go index 829ac12..c7f9738 100644 --- a/docs/docs_test.go +++ b/docs/docs_test.go @@ -24,11 +24,12 @@ var portValues = map[string]int{ "RELVX": sim.PortScanRelVX, "RELVY": sim.PortScanRelVY, "RELVZ": sim.PortScanRelVZ, "DIST": sim.PortScanDist, "ORE0": sim.PortScanOre, "ORE1": sim.PortScanOre + 1, "ORE2": sim.PortScanOre + 2, "ORE3": sim.PortScanOre + 3, - "MINE": sim.PortMine, "CARGO": sim.PortCargo, "CARGOCAP": sim.PortCargoCap, - "CARGO0": sim.PortCargoOre, "CARGO1": sim.PortCargoOre + 1, "CARGO2": sim.PortCargoOre + 2, "CARGO3": sim.PortCargoOre + 3, + "MINE": sim.PortMine, "CONTS": sim.PortConts, "CONTKG": sim.PortContKg, "CONTROOM": sim.PortContRoom, "CONTMASS": sim.PortContMass, + "ATTACH": sim.PortAttach, "DETACH": sim.PortDetach, "NEARBY": sim.PortNearby, + "CONTORE0": sim.PortContOre, "CONTORE1": sim.PortContOre + 1, "CONTORE2": sim.PortContOre + 2, "CONTORE3": sim.PortContOre + 3, "STNX": sim.PortStnRelX, "STNY": sim.PortStnRelY, "STNZ": sim.PortStnRelZ, "STNVX": sim.PortStnRelVX, "STNVY": sim.PortStnRelVY, "STNVZ": sim.PortStnRelVZ, - "SELL": sim.PortSell, "EARNED": sim.PortCredits, + "ORDER": sim.PortOrder, "EARNED": sim.PortCredits, "UPNEW": sim.PortUplinkNew, "UPLEN": sim.PortUplinkLen, "MATHX": sim.PortMathX, "MATHY": sim.PortMathY, "MATHZ": sim.PortMathZ, "ATAN2": sim.PortMathAtan2, "HYPOT": sim.PortMathHypot, "NORM3": sim.PortMathNorm3, diff --git a/docs/manual.md b/docs/manual.md index e11fdc5..e06f2cf 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -625,7 +625,7 @@ chosen by the throttle. 0x21 AZIMUTH output Thrust direction azimuth, milliradians. 0x22 PITCH output Thrust direction elevation, milliradians. 0x23 FUEL input Fuel remaining, kg. -0x24 MASS input Total ship mass (hull + fuel + cargo), kg. +0x24 MASS input Total ship mass (hull + fuel + containers in tow), kg. ``` THE ENGINE FIRES ALONG THE DIRECTION GIVEN, for the whole of the turn, at @@ -644,7 +644,8 @@ Thrust and fuel. For a Prospector at standard turn length: Full thrust ................ 6000 newtons Exhaust velocity ........... 30,000 m/s Fuel used at full throttle . 12 kg per turn (thrust x turn / exhaust vel.) -Acceleration ............... thrust / mass, 0.33 to 0.75 m/s^2 +Acceleration ............... thrust / mass: 0.75 m/s^2 with an empty tank + and nothing in tow, less with containers ``` Fuel use is proportional to throttle. When fuel runs out the engine falls @@ -659,16 +660,17 @@ shortened burn. The scanner tracks one asteroid at a time, which you choose by number. Asteroids are numbered from 1. The standard belt has 500. The scanner has -unlimited range: every asteroid in the belt is visible from anywhere in it. +a range of 500,000 km: an asteroid farther away than that cannot be seen. ``` 0x30 SELECT output Asteroid number to track. Zero, or a number that does not exist, clears the selection. -0x31 NEAREST input Number of the asteroid nearest to the ship. +0x31 NEAREST input Number of the asteroid nearest to the ship, or 0 + if none is within 500,000 km. ``` The following ports describe the TRACKED asteroid and all read zero if none -is tracked. +is tracked, or if it is more than 500,000 km away. ``` 0x32 RELX input Position of the target relative to the ship, X, km @@ -690,43 +692,80 @@ the +X side of you. To fly towards the target, point the engine along (RELX, RELY, RELZ). To match its velocity, apply a thrust along (RELVX, RELVY, RELVZ). -NEAREST is decided among all asteroids by true three-dimensional distance; -if two are exactly equally near, the lower number wins. +NEAREST is decided among the asteroids within scanner range by true +three-dimensional distance, and reads zero if there are none; if two are +exactly equally near, the lower number wins. > **Note:** Every asteroid in the belt moves at the same speed, 3 km/s (section > 7.2). For a ship travelling at that speed, the velocity of a target relative > to the ship is therefore decided by the difference in DIRECTION of travel, not > by any difference in speed. -## 5.7 Mining Laser and Cargo Hold +## 5.7 Mining Laser and Containers + +A ship has no hold. Ore travels in CONTAINERS, which the ship TOWS. A +container weighs 500 kg empty and holds 2000 kg of ore. Containers belong to +no one: any ship can tow any container. A container is always in one of two +conditions: + +- IN TOW behind a ship: it moves exactly as the ship moves. +- ADRIFT: it moves on its own, pulled by the Star like anything else (section + 7.2). A container let go with the speed of a ship in orbit stays in orbit + beside it. One that loses its speed falls inward, and is destroyed with its + ore if it comes within 200,000 km of the Star. A container that stays adrift + for TEN DAYS disappears, with whatever is in it, so containers that are lost + cannot pile up. While a container is in tow its days do not count. ``` -0x50 MINE output Non-zero: mine the tracked asteroid. Zero: stop. -0x51 CARGO input Total mass in the hold, kg. -0x52 CARGOCAP input Capacity of the hold, kg (Prospector: 6000). -0x58 CARGO0 input Iron in the hold, kg -0x59 CARGO1 input Nickel in the hold, kg -0x5A CARGO2 input Ice in the hold, kg -0x5B CARGO3 input Platinum in the hold, kg +0x50 MINE output Non-zero: mine the tracked asteroid. Zero: stop. +0x51 CONTS input Number of containers in tow. +0x52 CONTKG input Ore in the containers in tow, kg. +0x53 CONTROOM input Room left in the containers in tow, kg. +0x54 CONTMASS input Mass of the containers in tow, ore included, kg. +0x55 ATTACH output Non-zero: take the containers near the ship in tow. +0x56 DETACH output Non-zero: let go of every container in tow. +0x57 NEARBY input How many drifting containers ATTACH would take. +0x58 CONTORE0 input Iron in the containers in tow, kg +0x59 CONTORE1 input Nickel in the containers in tow, kg +0x5A CONTORE2 input Ice in the containers in tow, kg +0x5B CONTORE3 input Platinum in the containers in tow, kg (0x5C-0x5F exist for future ores and read zero.) ``` -The laser stays on, once MINE has been set, until MINE is set to zero. In -each turn in which ALL of the following are true, it moves ore from the -asteroid into the hold: +MINING. The laser stays on, once MINE has been set, until MINE is set to zero. +In each turn in which ALL of the following are true, it moves ore from the +asteroid into the containers in tow: - an asteroid is tracked and still has ore; - the asteroid is within 5 km of the ship; - the ship's velocity relative to the asteroid is at most 100 m/s; -- the hold is not full. +- at least one container in tow has room. The Prospector mines 10 kg per turn. The ore taken is a mixture in the same proportions as the asteroid's remaining ore. You cannot choose what to mine. -The conditions are tested at the END of the turn, after the ship has moved. +It goes into the containers in the order they were made, each filled before the +next. The conditions are tested at the END of the turn, after the ship has +moved. With nothing in tow, or every container full, the laser does nothing. -To fill an empty hold takes 600 turns, 10 hours of ship time. +One container takes 200 turns, a little over three hours of ship time, to fill. -Ore has to be brought to the Station before it is worth anything. An asteroid +ATTACH. Writing a non-zero value to ATTACH takes in tow every container that +is adrift and near the ship: within 10 km, and moving at most 100 m/s relative +to it. It makes no difference who ordered a container or whose ship last +towed it. A container that another ship is towing is left alone. Attached +containers move at once to the ship. Reading NEARBY beforehand says how many +ATTACH would take. + +DETACH. Writing a non-zero value to DETACH lets go of every container in tow. +Each carries on drifting with the velocity the ship had at that moment, and +its ten days start from then. + +MASS. Everything in tow counts towards the ship's mass, empty containers as +well as the ore in them, and the engine's thrust does not change. Acceleration +therefore falls as the tow grows: see section 7.1. MASS (section 5.5) reports +the total. + +Ore is worth nothing until it reaches the Station (section 5.8). An asteroid does not replenish. > **Note:** The scanner reports whole kilometres (rounded down). The range to @@ -746,21 +785,35 @@ ship is launched from it. 0x63 STNVX input Velocity of the Station relative to the ship, X, m/s 0x64 STNVY input ... Y, m/s 0x65 STNVZ input ... Z, m/s -0x66 SELL output Non-zero: sell the entire hold. +0x66 ORDER output 1 to 10: the Station delivers that many empty containers. 0x67 EARNED input Credits earned by this ship so far ``` As with the scanner, relative values are STATION MINUS SHIP. -Writing a non-zero value to SELL sells everything in the hold at once, if -ALL of the following are true, and otherwise does nothing (there is no error -signal): +SELLING IS AUTOMATIC. At the start of every turn the Station buys every +container that is ADRIFT within 20 km of it and has ore in it, and pays for the +ore at the day's prices. THE STATION TAKES THE CONTAINER WITH THE ORE: a sold +container disappears. Empty containers are not bought. Containers in tow are +never touched: to sell, detach near the Station. -- the ship is within 20 km of the Station; -- its velocity relative to the Station is at most 100 m/s; -- the hold is not empty. +The credits go to the player whose ship last let go of the container, and to +EARNED of that ship. Containers have no owner, so ore left adrift belongs to +whoever brings it to the Station first. -The hold is emptied, the credits go to your account, and EARNED goes up. +ORDERING CONTAINERS. When the ship is within 20 km of the Station, writing a +number from 1 to 10 to ORDER has the Station deliver that many EMPTY containers +to the ship: they appear at the ship's position, adrift with the ship's own +velocity, ready for ATTACH. Larger numbers act as 10; zero or less does +nothing. The Station will not deliver more once 50 containers that you ordered +still exist. A sale takes containers away, and so does the ten-day rule +(section 5.7), so containers you lose never leave you stuck. +Away from the Station ORDER does nothing, and there is no error signal. +Containers are free. + +A round trip is therefore: order containers, attach them, fly to an asteroid, +mine until they are full, fly back, detach near the Station (which takes the +containers and pays for the ore), and order fresh ones for the next trip. PRICES. Each ore has a standard price per kilogram. The price FALLS as ore floods the market and recovers as it is used up. Prices are set once per day @@ -849,13 +902,14 @@ Sample program 3 (Chapter 9) does exactly this. ## 6.1 Life Cycle of a Ship -A ship is always in one of four conditions. +A ship is always in one of five conditions. ``` INVENTORY Built and waiting on the dock. It can be launched. LAUNCHING Cleared to launch. It will enter the belt on the next daily run. Its program is sealed and can no longer be changed. ACTIVE In the belt. +SCRAPPING Ordered to be scrapped. It is removed on the next daily run. DESTROYED Lost. It cannot be recovered. ``` @@ -890,7 +944,9 @@ produce the same day, bit for bit. There is no luck in it. Order of events within the run: -1. Ships waiting to launch are placed at the Station. +1. Ships waiting to launch are placed at the Station. Ships being scrapped + are removed; any containers they tow are left adrift, and each owner is + given a new ship, in INVENTORY, with no program. 2. Uplinks are delivered to their ships. 3. For each turn of the day, for each ship in order of ship number: a. the computer runs until it yields or exhausts its budget; @@ -947,9 +1003,18 @@ DELETE /slots/{n} Empty slot 4 to 16. PUT /ships/{n}/uplink Body: up to 1024 raw bytes. Replaces any message queued for the next run. Allowed while LAUNCHING or ACTIVE. +POST /ships/{n}/scrap Scrap a ship that is ACTIVE (409 otherwise). + On the next run the ship is removed from the belt and you receive a + new ship in INVENTORY. Its program and fuel are lost. + GET /ships/{n}/downlink The latest downlink, exactly 1024 raw bytes. The header X-Downlink-Day tells you which day's run produced it. A ship's first downlink appears after its first day. + With ?day=D you get the downlink of that earlier day instead (404 + if there is none). + +GET /ships/{n}/downlinks {"days":[0,1,2,...]}: the days that have a + stored downlink, oldest first. Every day's downlink is kept. ``` Typical use: @@ -978,13 +1043,23 @@ request, 413 when your data is too large, 400 for malformed data. ``` Dry mass ............................. 8000 kg Fuel tank ............................ 4000 kg -Hold ................................. 6000 kg -Total mass, full tank, empty hold .... 12,000 kg -Total mass, empty tank, full hold .... 14,000 kg +Cargo hold ........................... none: ore travels in containers +Container, empty ..................... 500 kg (holds 2000 kg of ore) +Container, full ...................... 2500 kg +Total mass, full tank, nothing in tow 12,000 kg +Total mass, empty tank, nothing in tow 8,000 kg Engine ............................... 6000 N, exhaust velocity 30 km/s Mining laser ......................... 10 kg per turn ``` +Acceleration is thrust divided by the total mass, containers in tow included: + +``` +full tank, nothing in tow ............ 6000 / 12,000 = 0.50 m/s^2 +full tank, 3 full containers ......... 6000 / 19,500 = 0.31 m/s^2 +empty tank, nothing in tow ........... 6000 / 8,000 = 0.75 m/s^2 +``` + The ship is a point. It has no orientation of its own; the engine simply pushes in whatever direction the two angles command. There are no thrusters, no spin, no fuel used to rotate. @@ -1043,7 +1118,7 @@ The total change of velocity available from one tank is given by dv = exhaust velocity x ln( mass before / mass after ) ``` -For a Prospector with an empty hold and full tank: +For a Prospector with nothing in tow and a full tank: ``` 30 km/s x ln( 12000 / 8000 ) = about 12.2 km/s @@ -1051,7 +1126,8 @@ For a Prospector with an empty hold and full tank: That is roughly four times the orbit speed. Nothing is ever free: every metre per second gained going out must be paid again to slow down, and every -kilogram of ore carried home makes the ship harder to reverse. The +kilogram of ore towed home, and every empty container towed out for it, +makes the ship harder to reverse. The engine at full power uses the whole tank in 333 turns, a little over five hours of ship time, and yet the day is 24. Plan accordingly. @@ -1318,14 +1394,19 @@ SCANNER 0x42 ORE2 in ice in target kg 0x43 ORE3 in platinum in target kg -MINING AND HOLD -0x50 MINE out non-zero = laser on, latched -0x51 CARGO in hold contents kg -0x52 CARGOCAP in hold capacity kg -0x58 CARGO0 in iron in hold kg -0x59 CARGO1 in nickel in hold kg -0x5A CARGO2 in ice in hold kg -0x5B CARGO3 in platinum in hold kg +MINING AND CONTAINERS +0x50 MINE out non-zero = laser on, latched +0x51 CONTS in containers in tow +0x52 CONTKG in ore in the containers in tow kg +0x53 CONTROOM in room left in the containers in tow kg +0x54 CONTMASS in mass of the containers in tow kg +0x55 ATTACH out non-zero = tow the containers nearby +0x56 DETACH out non-zero = let go of everything in tow +0x57 NEARBY in drifting containers ATTACH would take +0x58 CONTORE0 in iron in tow kg +0x59 CONTORE1 in nickel in tow kg +0x5A CONTORE2 in ice in tow kg +0x5B CONTORE3 in platinum in tow kg STATION 0x60 STNX in station minus ship, X km @@ -1334,7 +1415,7 @@ STATION 0x63 STNVX in station velocity minus ship, X m/s 0x64 STNVY in ... Y m/s 0x65 STNVZ in ... Z m/s -0x66 SELL out non-zero = sell hold (if docked) +0x66 ORDER out 1 to 10 = station delivers empty containers 0x67 EARNED in credits earned by this ship WORMHOLE LINK @@ -1392,19 +1473,24 @@ Paste this at the top of any program. Unused equates cost nothing. .equ P_ORE2 0x42 .equ P_ORE3 0x43 .equ P_MINE 0x50 - .equ P_CARGO 0x51 - .equ P_CARGOCAP 0x52 - .equ P_CARGO0 0x58 - .equ P_CARGO1 0x59 - .equ P_CARGO2 0x5A - .equ P_CARGO3 0x5B + .equ P_CONTS 0x51 + .equ P_CONTKG 0x52 + .equ P_CONTROOM 0x53 + .equ P_CONTMASS 0x54 + .equ P_ATTACH 0x55 + .equ P_DETACH 0x56 + .equ P_NEARBY 0x57 + .equ P_CONTORE0 0x58 + .equ P_CONTORE1 0x59 + .equ P_CONTORE2 0x5A + .equ P_CONTORE3 0x5B .equ P_STNX 0x60 .equ P_STNY 0x61 .equ P_STNZ 0x62 .equ P_STNVX 0x63 .equ P_STNVY 0x64 .equ P_STNVZ 0x65 - .equ P_SELL 0x66 + .equ P_ORDER 0x66 .equ P_EARNED 0x67 .equ P_UPNEW 0x70 .equ P_UPLEN 0x71 @@ -1463,13 +1549,17 @@ program (HALT), commanding a throttle greater than 1000 (clamped). | 10-12 pos km 13-15 vel m/s | | 20 throttle 0-1000 21 azimuth 22 pitch 23 fuel 24 mass | | 30 select 31 nearest 32-34 rel pos 35-37 rel vel 38 dist 40-43 ore | -| 50 mine 51 cargo 52 cap 58-5B cargo by ore | -| 60-62 stn pos 63-65 stn vel 66 sell 67 earned | +| 50 mine 51 conts 52 ore 53 room 54 mass 55 attach 56 detach | +| 57 nearby 58-5B ore by type in tow | +| 60-62 stn pos 63-65 stn vel 66 order 67 earned | | 70 uplink flag / ack 71 uplink length | | 80 x 81 y 82 z 83 atan2 84 hypot 85 norm3 | +------------------------------------------------------------------------------+ | MINE within 5 km, rel speed <= 100 m/s 10 kg/turn | -| SELL within 20 km, rel speed <= 100 m/s whole hold | +| ATTACH containers within 10 km, rel speed <= 100 m/s | +| SALE drifting containers with ore within 20 km: taken and paid | +| ORDER 1-10 empty containers, near the Station (max 50 ordered) | +| ADRIFT a container adrift for 10 days vanishes, ore and all | | STAR a = v^2/r, v = 3 km/s. Inside 200,000 km = destroyed. | +------------------------------------------------------------------------------+ ``` diff --git a/examples/programs/ports.s b/examples/programs/ports.s index 1ff6518..1dd9c8b 100644 --- a/examples/programs/ports.s +++ b/examples/programs/ports.s @@ -33,19 +33,24 @@ .equ P_ORE2 0x42 .equ P_ORE3 0x43 .equ P_MINE 0x50 - .equ P_CARGO 0x51 - .equ P_CARGOCAP 0x52 - .equ P_CARGO0 0x58 - .equ P_CARGO1 0x59 - .equ P_CARGO2 0x5A - .equ P_CARGO3 0x5B + .equ P_CONTS 0x51 + .equ P_CONTKG 0x52 + .equ P_CONTROOM 0x53 + .equ P_CONTMASS 0x54 + .equ P_ATTACH 0x55 + .equ P_DETACH 0x56 + .equ P_NEARBY 0x57 + .equ P_CONTORE0 0x58 + .equ P_CONTORE1 0x59 + .equ P_CONTORE2 0x5A + .equ P_CONTORE3 0x5B .equ P_STNX 0x60 .equ P_STNY 0x61 .equ P_STNZ 0x62 .equ P_STNVX 0x63 .equ P_STNVY 0x64 .equ P_STNVZ 0x65 - .equ P_SELL 0x66 + .equ P_ORDER 0x66 .equ P_EARNED 0x67 .equ P_UPNEW 0x70 .equ P_UPLEN 0x71 diff --git a/runner/runner.go b/runner/runner.go index 30ceb78..9d85b1c 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -55,6 +55,9 @@ func RunNextDay(ctx context.Context, s *store.Store, cfg config.Config) (sim.Day if in.Launches, err = q.PendingLaunches(ctx); err != nil { return err } + if in.Scraps, err = q.PendingScraps(ctx); err != nil { + return err + } if in.Uplinks, err = q.PendingUplinks(ctx); err != nil { return err } diff --git a/sim/bus.go b/sim/bus.go index 80cf767..8558a0d 100644 --- a/sim/bus.go +++ b/sim/bus.go @@ -94,12 +94,13 @@ func (b *shipBus) In(port uint16) int32 { case port == PortFuel: return sat32(sh.Fuel.Floor()) case port == PortMass: - return sat32(sh.Mass().Floor()) + return sat32(tc.s.shipMass(sh).Floor()) case port == PortScanNearest: + scanRange := fixed.FromInt(ScanRangeKm) best, bestD := int64(0), fixed.Max for i := range tc.s.Asteroids { p, _ := tc.asteroid(i) - if d := p.Sub(sh.Pos).Len(); d < bestD { + if d := p.Sub(sh.Pos).Len(); d <= scanRange && d < bestD { best, bestD = int64(i+1), d } } @@ -110,6 +111,9 @@ func (b *shipBus) In(port uint16) int32 { return 0 } rel, relV := p.Sub(sh.Pos), v.Sub(sh.Vel) + if rel.Len() > fixed.FromInt(ScanRangeKm) { + return 0 + } switch { case port <= PortScanRelZ: return axis(rel, port, PortScanRelX, kmInt) @@ -119,16 +123,25 @@ func (b *shipBus) In(port uint16) int32 { return kmInt(rel.Len()) case port >= PortScanOre && port < PortScanOre+8: if a := tc.s.asteroid(sh.Target); a != nil && port-PortScanOre < uint16(world.NumOre) { + if p, _, _ := b.target(); p.Sub(sh.Pos).Len() > fixed.FromInt(ScanRangeKm) { + return 0 + } return sat32(a.Ore[port-PortScanOre]) } return 0 - case port == PortCargo: - return sat32(sh.CargoTotal()) - case port == PortCargoCap: - return sat32(sh.Hull.CargoCap) - case port >= PortCargoOre && port < PortCargoOre+8: - if port-PortCargoOre < uint16(world.NumOre) { - return sat32(sh.Cargo[port-PortCargoOre]) + case port == PortConts: + return int32(tc.s.towOf(sh.ID).Count) + case port == PortContKg: + return sat32(tc.s.towOf(sh.ID).OreTotal) + case port == PortContRoom: + return sat32(tc.s.towOf(sh.ID).Room) + case port == PortContMass: + return sat32(tc.s.towOf(sh.ID).Mass) + case port == PortNearby: + return int32(tc.s.nearbyFree(sh)) + case port >= PortContOre && port < PortContOre+8: + if port-PortContOre < uint16(world.NumOre) { + return sat32(tc.s.towOf(sh.ID).Ore[port-PortContOre]) } return 0 case port >= PortStnRelX && port <= PortStnRelVZ: @@ -170,21 +183,16 @@ func (b *shipBus) Out(port uint16, v int32) { sh.Target = int64(v) case PortMine: sh.Mining = v != 0 - case PortSell: - if v == 0 || !tc.s.canReach(sh, tc.stPos, tc.stVel, DockRangeKm) { - return + case PortAttach: + if v != 0 { + tc.s.attach(sh, tc) } - value := tc.s.Market.Value(sh.Cargo) - if value == 0 { - return + case PortDetach: + if v != 0 { + tc.s.detach(sh, tc) } - tc.s.Credits[sh.Owner] += value - sh.Earned += value - for o, kg := range sh.Cargo { - tc.sold[o] += kg - sh.Cargo[o] = 0 - } - tc.res.Events = append(tc.res.Events, Event{tc.tick, sh.ID, "sold", itoa(value) + " credits"}) + case PortOrder: + tc.s.order(sh, int(v), tc) case PortUplinkNew: sh.UplinkNew = false case PortMathX: diff --git a/sim/container.go b/sim/container.go new file mode 100644 index 0000000..79ccf9f --- /dev/null +++ b/sim/container.go @@ -0,0 +1,289 @@ +package sim + +import ( + "halcyon/config" + "halcyon/fixed" + "halcyon/world" +) + +// Ore travels in containers. A ship has no hold of its own: it tows containers +// behind it, and every kilogram of them (the container and the ore in it) adds +// to the mass its engine has to push. Containers belong to no one: any ship can +// tow any container that is adrift near it. +const ( + ContainerMassKg = 500 // an empty container + ContainerCapKg = 2000 // ore one container holds + ContainerRangeKm = 10 // "near the ship": how close a container must be to be attached + MaxOrder = 10 // empty containers the station delivers per request + MaxContainersPerPlayer = 50 // the station will not deliver past this many of a player's ordered containers + ContainerLifeDays = 10 // a container adrift this long vanishes, so lost ones cannot pile up +) + +// Container is a free-floating body. It has no owner. +type Container struct { + ID int64 + // OrderedBy is the player who had the station deliver it. It is bookkeeping for + // the ordering allowance only (MaxContainersPerPlayer): it gives no right to the + // container, which anyone may tow, and a sale ends it. + OrderedBy int64 + Pos fixed.Vec + Vel fixed.Vec + Ore [world.NumOre]int64 // kg of each ore inside + Towed int64 // the ship towing it, or 0 while it drifts on its own + // Dropped is the ship that last let it go: when the container is sold, that ship + // and its player are paid. + Dropped int64 + // ExpiresAt is when a container that stays adrift vanishes (seconds since the + // world began); 0 while it is in tow. + ExpiresAt int64 +} + +func (c *Container) OreTotal() int64 { + var t int64 + for _, v := range c.Ore { + t += v + } + return t +} + +// Mass is the container plus the ore in it, in kg. +func (c *Container) Mass() int64 { return ContainerMassKg + c.OreTotal() } + +// Room is how much more ore fits, in kg. +func (c *Container) Room() int64 { return ContainerCapKg - c.OreTotal() } + +// Tow summarises the containers a ship is towing. +type Tow struct { + Count int + Ore [world.NumOre]int64 + OreTotal int64 + Room int64 + Mass int64 +} + +func (s *State) towOf(shipID int64) Tow { + var t Tow + for _, c := range s.Containers { + if c.Towed != shipID { + continue + } + t.Count++ + for o, kg := range c.Ore { + t.Ore[o] += kg + } + t.OreTotal += c.OreTotal() + t.Room += c.Room() + t.Mass += c.Mass() + } + return t +} + +// shipMass is everything the engine pushes: hull, fuel and towed containers. +func (s *State) shipMass(sh *Ship) fixed.F { + return sh.baseMass() + fixed.FromInt(s.towOf(sh.ID).Mass) +} + +func (s *State) newContainer(orderedBy int64, pos, vel fixed.Vec) *Container { + s.NextContainer++ + c := &Container{ID: s.NextContainer, OrderedBy: orderedBy, Pos: pos, Vel: vel} + s.Containers = append(s.Containers, c) // IDs only grow, so the slice stays sorted + return c +} + +// canAttach reports whether container c is adrift and near enough to take in tow. +func (s *State) canAttach(sh *Ship, c *Container) bool { + return c.Towed == 0 && s.canReach(sh, c.Pos, c.Vel, ContainerRangeKm) +} + +// nearbyFree counts the drifting containers the ship could attach now. +func (s *State) nearbyFree(sh *Ship) int { + n := 0 + for _, c := range s.Containers { + if s.canAttach(sh, c) { + n++ + } + } + return n +} + +// attach takes every drifting container near the ship in tow. +func (s *State) attach(sh *Ship, tc *tickCtx) { + n := 0 + for _, c := range s.Containers { + if s.canAttach(sh, c) { + c.Towed, c.Pos, c.Vel, c.ExpiresAt = sh.ID, sh.Pos, sh.Vel, 0 + n++ + } + } + if n > 0 { + tc.res.Events = append(tc.res.Events, Event{tc.tick, sh.ID, "attach", itoa(int64(n)) + " containers"}) + } +} + +// detach lets go of everything the ship is towing. The containers keep drifting +// with the ship's velocity. +func (s *State) detach(sh *Ship, tc *tickCtx) { + n := 0 + for _, c := range s.Containers { + if c.Towed == sh.ID { + s.letGo(c, sh.ID, tc.now) + n++ + } + } + if n > 0 { + tc.res.Events = append(tc.res.Events, Event{tc.tick, sh.ID, "detach", itoa(int64(n)) + " containers"}) + } +} + +// order has the station deliver up to n empty containers to the ship's position, +// provided the ship is close to the station. It returns how many were delivered. +func (s *State) order(sh *Ship, n int, tc *tickCtx) int { + if n > MaxOrder { + n = MaxOrder + } + if n <= 0 || sh.Pos.Sub(tc.stPos).Len() > fixed.FromInt(DockRangeKm) { + return 0 + } + owned := 0 + for _, c := range s.Containers { + if c.OrderedBy == sh.Owner { + owned++ + } + } + n = min(n, MaxContainersPerPlayer-owned) + for i := 0; i < n; i++ { + c := s.newContainer(sh.Owner, sh.Pos, sh.Vel) + c.ExpiresAt = tc.now + ContainerLifeDays*config.SecondsPerDay // delivered adrift + } + if n > 0 { + tc.res.Events = append(tc.res.Events, Event{tc.tick, sh.ID, "delivery", itoa(int64(n)) + " containers"}) + } + return n +} + +// fillTow pours mined ore into the ship's containers, first come first filled. +// The caller has checked that it all fits. +func (s *State) fillTow(shipID int64, taken [world.NumOre]int64) { + for o, kg := range taken { + for _, c := range s.Containers { + if kg == 0 { + break + } + if c.Towed != shipID { + continue + } + put := min(kg, c.Room()) + c.Ore[o] += put + kg -= put + } + } +} + +// syncTow keeps a ship's containers where the ship is. +func (s *State) syncTow(sh *Ship) { + for _, c := range s.Containers { + if c.Towed == sh.ID { + c.Pos, c.Vel = sh.Pos, sh.Vel + } + } +} + +// releaseTow sets a lost ship's containers adrift. +func (s *State) releaseTow(sh *Ship, now int64) { + for _, c := range s.Containers { + if c.Towed == sh.ID { + s.letGo(c, sh.ID, now) + } + } +} + +// letGo sets a towed container adrift and starts its ten days. +func (s *State) letGo(c *Container, ship, now int64) { + c.Towed, c.Dropped = 0, ship + c.ExpiresAt = now + ContainerLifeDays*config.SecondsPerDay +} + +// expireContainers removes containers that have been adrift for their ten days, +// with whatever ore they hold. A container adrift with no timer at all (from a +// snapshot made before there was one) is given a full ten days from now. +func (s *State) expireContainers(tc *tickCtx) { + kept := s.Containers[:0] + for _, c := range s.Containers { + if c.Towed == 0 { + if c.ExpiresAt == 0 { + c.ExpiresAt = tc.now + ContainerLifeDays*config.SecondsPerDay + } else if tc.now >= c.ExpiresAt { + tc.res.Events = append(tc.res.Events, Event{tc.tick, c.Dropped, "expired", "container " + itoa(c.ID) + " adrift for " + itoa(ContainerLifeDays) + " days"}) + continue + } + } + kept = append(kept, c) + } + s.Containers = kept +} + +// sellNearStation buys every drifting container that has ore in it and is close +// to the station, at the tick's positions. The station takes the container along +// with the ore, so a sold container is gone. The ship that last let it go, and its +// player, are paid. Empty containers are not bought. +func (s *State) sellNearStation(tc *tickCtx) { + kept := s.Containers[:0] + for _, c := range s.Containers { + payee := s.ship(c.Dropped) + if c.Towed != 0 || c.OreTotal() == 0 || payee == nil || c.Pos.Sub(tc.stPos).Len() > fixed.FromInt(DockRangeKm) { + kept = append(kept, c) + continue + } + value := s.Market.Value(c.Ore) + s.Credits[payee.Owner] += value + payee.Earned += value + for o, kg := range c.Ore { + tc.sold[o] += kg + } + tc.res.Events = append(tc.res.Events, Event{tc.tick, payee.ID, "sold", itoa(value) + " credits (container " + itoa(c.ID) + " taken)"}) + } + s.Containers = kept +} + +// driftContainers moves the containers nobody is towing. The star pulls them +// exactly as it pulls ships; one that falls in is lost with its ore. +func (s *State) driftContainers(orbitSpeed fixed.F, dt int64, tick int, res *DayResult) { + dtF := fixed.FromInt(dt) + kept := s.Containers[:0] + for _, c := range s.Containers { + if c.Towed == 0 { + r := c.Pos.Len() + if r < fixed.FromInt(StarRadiusKm) { + res.Events = append(res.Events, Event{tick, c.Dropped, "lost", "container " + itoa(c.ID) + " fell into the star"}) + continue + } + g := orbitSpeed.Mul(orbitSpeed).Mul(dtF).Div(r) + c.Vel = c.Vel.Sub(c.Pos.Unit().Scale(g)) + c.Pos = c.Pos.Add(c.Vel.Scale(dtF)) + } + kept = append(kept, c) + } + s.Containers = kept +} + +// migrateLegacyCargo turns ore that a ship still carries in the cargo hold of +// the earlier design into towed containers, so nothing is lost across the change. +func (s *State) migrateLegacyCargo() { + for _, sh := range s.Ships { + var left int64 + for _, kg := range sh.Cargo { + left += kg + } + for left > 0 { + c := s.newContainer(0, sh.Pos, sh.Vel) // not counted against anyone's ordering allowance + c.Towed = sh.ID + for o := range sh.Cargo { + put := min(sh.Cargo[o], c.Room()) + c.Ore[o] += put + sh.Cargo[o] -= put + left -= put + } + } + sh.Cargo = [world.NumOre]int64{} + } +} diff --git a/sim/container_test.go b/sim/container_test.go new file mode 100644 index 0000000..357988d --- /dev/null +++ b/sim/container_test.go @@ -0,0 +1,758 @@ +package sim + +import ( + "bytes" + "encoding/binary" + "encoding/gob" + "math" + "testing" + + "halcyon/config" + "halcyon/fixed" + "halcyon/market" + "halcyon/vm" + "halcyon/world" +) + +const mineProgram = ` + ldi r0, 1 + out 0x30, r0 ; select asteroid 1 + out 0x50, r0 ; mine + yield + jmp -2 +` + +// ctxAt is a turn context at absolute time now, for calling the container rules directly. +func ctxAt(s *State, cfg config.Config, now int64) *tickCtx { + pos, vel := s.Station.Orbit.State(now) + return newTickCtx(s, cfg, 0, now, pos, vel, &DayResult{}, new([world.NumOre]int64)) +} + +// dockAt puts the ship at the station as it is at absolute time now. +func dockAt(s *State, sh *Ship, now int64) { + sh.Pos, sh.Vel = s.Station.Orbit.State(now) +} + +func expiredEvents(tc *tickCtx) (n int) { + for _, e := range tc.res.Events { + if e.Kind == "expired" { + n++ + } + } + return n +} + +func towed(s *State, sh *Ship, n int) []*Container { + var out []*Container + for i := 0; i < n; i++ { + c := s.newContainer(sh.Owner, sh.Pos, sh.Vel) + c.Towed = sh.ID + out = append(out, c) + } + return out +} + +func TestMiningFillsTowedContainers(t *testing.T) { + cfg := testCfg() + + // With no container in tow there is nowhere to put ore: nothing is mined. + s := NewState(cfg) + pos, vel := s.Asteroids[0].Orbit.State(0) + before := s.Asteroids[0].TotalOre() + addShip(t, s, cfg, 1, mustAsm(t, mineProgram), pos, vel) + if _, err := s.RunDay(cfg, DayInput{}); err != nil { + t.Fatal(err) + } + if s.Asteroids[0].TotalOre() != before { + t.Fatal("ore was taken from the asteroid with no container to hold it") + } + + // With a container in tow, ore goes into it. (Gravity soon carries the ship off + // the rails-mounted asteroid, so only the first stretch mines.) + s = NewState(cfg) + pos, vel = s.Asteroids[0].Orbit.State(0) + sh := addShip(t, s, cfg, 1, mustAsm(t, mineProgram), pos, vel) + cs := towed(s, sh, 2) + cs[0].Ore[world.Iron] = ContainerCapKg - 15 // nearly full: takes 15 kg, the rest spills into the next + if _, err := s.RunDay(cfg, DayInput{}); err != nil { + t.Fatal(err) + } + mined := before - s.Asteroids[0].TotalOre() + if mined <= 15 { + t.Fatalf("only %d kg mined", mined) + } + if cs[0].OreTotal() != ContainerCapKg { + t.Fatalf("the first container holds %d kg, want it full (%d)", cs[0].OreTotal(), ContainerCapKg) + } + if got := cs[0].OreTotal() + cs[1].OreTotal() - (ContainerCapKg - 15); got != mined { + t.Fatalf("containers gained %d kg but the asteroid lost %d", got, mined) + } +} + +func TestMiningStopsWhenContainersAreFull(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + pos, vel := s.Asteroids[0].Orbit.State(0) + before := s.Asteroids[0].TotalOre() + sh := addShip(t, s, cfg, 1, mustAsm(t, mineProgram), pos, vel) + c := towed(s, sh, 1)[0] + c.Ore[world.Iron] = ContainerCapKg + if _, err := s.RunDay(cfg, DayInput{}); err != nil { + t.Fatal(err) + } + if s.Asteroids[0].TotalOre() != before || c.OreTotal() != ContainerCapKg { + t.Fatal("a full container should stop the laser") + } +} + +func TestOrderContainersAtTheStation(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + stPos, stVel := s.Station.Orbit.State(0) + sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), stPos, stVel) + + if n := s.order(sh, 3, ctxAt(s, cfg, 0)); n != 3 || len(s.Containers) != 3 { + t.Fatalf("ordered 3, got %d (%d exist)", n, len(s.Containers)) + } + for i, c := range s.Containers { + if c.ID != int64(i+1) || c.OrderedBy != 1 || c.Towed != 0 || c.OreTotal() != 0 || c.Pos != sh.Pos || c.Vel != sh.Vel { + t.Fatalf("delivered container %d: %+v (want empty, adrift, ordered by 1, at the ship)", i, c) + } + } + if n := s.order(sh, 25, ctxAt(s, cfg, 0)); n != MaxOrder { + t.Fatalf("a request for 25 delivered %d, want the maximum %d", n, MaxOrder) + } + if n := s.order(sh, 0, ctxAt(s, cfg, 0)); n != 0 { + t.Fatal("ordering none delivered some") + } + if n := s.order(sh, -4, ctxAt(s, cfg, 0)); n != 0 { + t.Fatal("a negative order delivered some") + } + + // Never past the per-player ordering allowance. + for i := 0; i < 10; i++ { + s.order(sh, MaxOrder, ctxAt(s, cfg, 0)) + } + if len(s.Containers) != MaxContainersPerPlayer { + t.Fatalf("%d containers, want the limit %d", len(s.Containers), MaxContainersPerPlayer) + } + // Another player has their own allowance. + other := &Ship{ID: 2, Owner: 2, Pos: stPos, Vel: stVel, Alive: true} + if n := s.order(other, 4, ctxAt(s, cfg, 0)); n != 4 { + t.Fatalf("second player got %d", n) + } + + // Not near the station: refused. + far := NewState(cfg) + apos, avel := far.Asteroids[0].Orbit.State(0) + fsh := addShip(t, far, cfg, 1, mustAsm(t, "halt"), apos, avel) + if n := far.order(fsh, 5, ctxAt(far, cfg, 0)); n != 0 || len(far.Containers) != 0 { + t.Fatalf("delivered %d far from the station", n) + } + // Just inside and just outside the station's reach. + edge := NewState(cfg) + inside := &Ship{ID: 1, Owner: 1, Pos: stPos.Add(fixed.Vec{X: fixed.FromInt(DockRangeKm - 1)}), Alive: true} + outside := &Ship{ID: 2, Owner: 1, Pos: stPos.Add(fixed.Vec{X: fixed.FromInt(DockRangeKm + 1)}), Alive: true} + if edge.order(inside, 1, ctxAt(edge, cfg, 0)) != 1 || edge.order(outside, 1, ctxAt(edge, cfg, 0)) != 0 { + t.Fatal("the station's reach is not DockRangeKm") + } +} + +func TestAttachAndDetach(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + pos, vel := s.Station.Orbit.State(0) + sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), pos, vel) + + km := func(x int64) fixed.Vec { return fixed.Vec{X: fixed.FromInt(x)} } + near1 := s.newContainer(1, pos, vel) + near2 := s.newContainer(1, pos.Add(km(ContainerRangeKm-1)), vel) + fromElsewhere := s.newContainer(2, pos, vel) // ordered by another player: containers are not owned + tooFar := s.newContainer(1, pos.Add(km(ContainerRangeKm+1)), vel) + tooFast := s.newContainer(1, pos, vel.Add(fixed.Vec{Y: fixed.FromRatio(200, 1000)})) // 200 m/s away + already := s.newContainer(1, pos, vel) + already.Towed = 99 // being towed by another ship + + if n := s.nearbyFree(sh); n != 3 { + t.Fatalf("%d containers reported near, want 3", n) + } + s.attach(sh, ctxAt(s, cfg, 0)) + for name, c := range map[string]*Container{"near1": near1, "near2": near2, "fromElsewhere": fromElsewhere} { + if c.Towed != sh.ID { + t.Errorf("%s was not attached", name) + } + } + if near2.Pos != sh.Pos { // attaching brings it alongside + t.Error("an attached container should move to the ship") + } + for name, c := range map[string]*Container{"tooFar": tooFar, "tooFast": tooFast} { + if c.Towed != 0 { + t.Errorf("%s should not have been attached", name) + } + } + if already.Towed != 99 { + t.Error("took a container that another ship was towing") + } + if tow := s.towOf(sh.ID); tow.Count != 3 || tow.Mass != 3*ContainerMassKg || tow.Room != 3*ContainerCapKg { + t.Fatalf("tow = %+v", tow) + } + if s.nearbyFree(sh) != 0 { + t.Error("nothing should be left to attach") + } + + s.detach(sh, ctxAt(s, cfg, 0)) + if near1.Towed != 0 || near2.Towed != 0 || fromElsewhere.Towed != 0 || near1.Dropped != sh.ID { + t.Fatalf("detach: %+v %+v", near1, near2) + } + if already.Towed != 99 { + t.Error("detach touched another ship's tow") + } + if s.nearbyFree(sh) != 3 { + t.Error("detached containers next to the ship can be attached again") + } +} + +func TestTowedMassReducesAcceleration(t *testing.T) { + cfg := testCfg() + dvOf := func(containers int, ore int64) (dv float64, mass float64) { + s := NewState(cfg) + pos, vel := s.Station.Orbit.State(0) + sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), pos, vel) + for _, c := range towed(s, sh, containers) { + c.Ore[world.Iron] = ore + } + sh.Throttle = 1000 + sh.Azimuth = 0 + mass = s.shipMass(sh).Float64() + before := sh.Vel + // One burning turn; gravity's share of dv is negligible next to the engine's, + // and identical in both runs. + var res DayResult + s.physics(cfg, sh, 60, 0, &res) + return sh.Vel.Sub(before).Len().Float64(), mass + } + dv0, m0 := dvOf(0, 0) + dv4, m4 := dvOf(4, ContainerCapKg) + if want := float64(CommandShip.DryMass + CommandShip.FuelCap); math.Abs(m0-want) > 1e-6 { + t.Fatalf("bare mass = %v, want %v", m0, want) + } + if want := m0 + 4*(ContainerMassKg+ContainerCapKg); math.Abs(m4-want) > 1e-6 { + t.Fatalf("towing mass = %v, want %v", m4, want) + } + if !(dv4 < dv0) { + t.Fatalf("towing four full containers did not slow the acceleration: %v vs %v", dv4, dv0) + } + if got, want := dv0/dv4, m4/m0; math.Abs(got-want)/want > 0.01 { + t.Fatalf("acceleration ratio %.3f, mass ratio %.3f: acceleration should be inversely proportional to mass", got, want) + } +} + +func TestTowedContainersTravelWithTheShip(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + pos, vel := s.Station.Orbit.State(0) + sh := addShip(t, s, cfg, 1, mustAsm(t, burner), pos, vel) + cs := towed(s, sh, 3) + loose := s.newContainer(1, pos.Add(fixed.Vec{X: fixed.FromInt(3)}), vel) // near, but not attached + if _, err := s.RunDay(cfg, DayInput{}); err != nil { + t.Fatal(err) + } + if sh.Vel.Sub(vel).Len() < fixed.FromRatio(1, 10) { + t.Fatal("the ship did not burn") + } + for i, c := range cs { + if c.Pos != sh.Pos || c.Vel != sh.Vel { + t.Fatalf("towed container %d fell behind: %+v vs ship %+v %+v", i, c.Pos, sh.Pos, sh.Vel) + } + } + if loose.Pos.Sub(sh.Pos).Len() < fixed.FromInt(1000) { + t.Fatal("an unattached container should have been left behind by the burn") + } +} + +func TestSoldContainersDisappearAndTheDropperIsPaid(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + stPos, stVel := s.Station.Orbit.State(0) + away := stPos.Add(fixed.Vec{Z: fixed.FromInt(50_000)}) // well outside the station's reach + a := addShip(t, s, cfg, 1, mustAsm(t, "halt"), away, stVel) + b := addShip(t, s, cfg, 2, mustAsm(t, "halt"), away, stVel) + b.Owner = 2 // addShip makes every ship player 1's + + // Player 1's ship let this one go. Player 2 ordered it: that gives them no claim. + sold := s.newContainer(2, stPos, stVel) + sold.Ore[world.Iron] = 1000 // 2 credits/kg + sold.Ore[world.Ice] = 100 // 3 credits/kg + sold.Dropped = a.ID + inReach := s.newContainer(1, stPos.Add(fixed.Vec{X: fixed.FromInt(DockRangeKm - 1)}), stVel) + inReach.Ore[world.Iron] = 10 + inReach.Dropped = b.ID + tooFar := s.newContainer(1, stPos.Add(fixed.Vec{X: fixed.FromInt(DockRangeKm + 10)}), stVel) + tooFar.Ore[world.Iron] = 500 + tooFar.Dropped = a.ID + inTow := s.newContainer(1, stPos, stVel) + inTow.Ore[world.Iron] = 700 + inTow.Towed = a.ID // a ship towing ore past the station does not sell it + empty := s.newContainer(1, stPos, stVel) + empty.Dropped = a.ID // nothing in it: the station does not buy it + nobody := s.newContainer(1, stPos, stVel) + nobody.Ore[world.Iron] = 40 // no ship ever let it go, so there is no one to pay + nobody.Dropped = 0 + + if _, err := s.RunDay(cfg, DayInput{}); err != nil { + t.Fatal(err) + } + if want := int64(1000*2 + 100*3); s.Credits[1] != want { + t.Errorf("player 1 (the ship that let go) is paid %d, want %d", s.Credits[1], want) + } + if want := int64(10 * 2); s.Credits[2] != want { + t.Errorf("player 2 is paid %d, want %d (only for the container their ship let go)", s.Credits[2], want) + } + if a.Earned != int64(1000*2+100*3) || b.Earned != 20 { + t.Errorf("ships earned %d and %d", a.Earned, b.Earned) + } + + // Sold containers are gone, with their ore. Everything else is still there. + var left []*Container + for _, c := range s.Containers { + left = append(left, c) + } + want := map[*Container]bool{tooFar: true, inTow: true, empty: true, nobody: true} + if len(left) != len(want) { + t.Fatalf("%d containers left, want %d", len(left), len(want)) + } + for _, c := range left { + if !want[c] { + t.Errorf("container %d survived its sale", c.ID) + } + } + if tooFar.OreTotal() != 500 || inTow.OreTotal() != 700 || nobody.OreTotal() != 40 { + t.Error("ore that was not sold must stay in its container") + } + if s.Market.Supply[world.Iron] != 1010 || s.Market.Supply[world.Ice] != 100 { + t.Errorf("market supply %+v: sold ore should count", s.Market.Supply) + } +} + +// The ordering allowance counts the containers a player ordered that still exist. +// A sale removes them, so selling a load frees room to order the next. +func TestSaleFreesTheOrderingAllowance(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + stPos, stVel := s.Station.Orbit.State(0) + sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), stPos, stVel) + var res DayResult + for i := 0; i < 5; i++ { + s.order(sh, MaxOrder, ctxAt(s, cfg, 0)) + } + if len(s.Containers) != MaxContainersPerPlayer || s.order(sh, 1, ctxAt(s, cfg, 0)) != 0 { + t.Fatalf("expected to be at the allowance (%d containers)", len(s.Containers)) + } + for _, c := range s.Containers[:10] { + c.Ore[world.Iron] = 100 + c.Dropped = sh.ID + c.Pos = stPos + } + tc := newTickCtx(s, cfg, 0, 0, stPos, stVel, &res, new([world.NumOre]int64)) + s.sellNearStation(tc) + if len(s.Containers) != MaxContainersPerPlayer-10 { + t.Fatalf("%d containers after selling 10", len(s.Containers)) + } + if n := s.order(sh, MaxOrder, ctxAt(s, cfg, 0)); n != 10 { + t.Fatalf("after the sale the player could order %d, want 10", n) + } +} + +func TestFreeContainersOrbitAndFallIntoTheStar(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + pos, vel := s.Station.Orbit.State(0) + // A container let go at the station's velocity stays in the station's orbit. + drifter := s.newContainer(1, pos, vel) + r0 := pos.Len().Float64() + // One with no speed, closer in, falls straight into the star within a few days + // (well inside the ten a container adrift is allowed). + faller := s.newContainer(1, fixed.Vec{X: fixed.FromInt(700_000)}, fixed.Vec{}) + faller.Ore[world.Platinum] = 1000 + addShip(t, s, cfg, 1, mustAsm(t, "halt"), fixed.Vec{X: fixed.FromInt(10_000_000)}, fixed.Vec{}) + + var res DayResult + for d := 0; d < 6; d++ { + r, err := s.RunDay(cfg, DayInput{}) + if err != nil { + t.Fatal(err) + } + res.Events = append(res.Events, r.Events...) + } + if r := drifter.Pos.Len().Float64(); r < r0*0.99 || r > r0*1.01 { + t.Fatalf("drifting container left its orbit: %.0f -> %.0f km", r0, r) + } + lost := false + for _, e := range res.Events { + lost = lost || e.Kind == "lost" + } + if !lost || len(s.Containers) != 1 || s.Containers[0] != drifter { + t.Fatalf("the container with no speed should have fallen in and been lost (%d left, lost=%v)", len(s.Containers), lost) + } +} + +func TestLosingAShipSetsItsContainersAdrift(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + // A ship inside the star's exclusion zone is destroyed on the first tick. + sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), fixed.Vec{X: fixed.FromInt(StarRadiusKm - 1)}, fixed.Vec{}) + c := towed(s, sh, 1)[0] + if _, err := s.RunDay(cfg, DayInput{}); err != nil { + t.Fatal(err) + } + if sh.Alive { + t.Fatal("the ship should have been destroyed") + } + if c.Towed != 0 && len(s.Containers) != 0 { + t.Fatal("a destroyed ship's tow must be released, not left attached to a dead ship") + } +} + +func TestLegacyCargoBecomesContainers(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + pos, vel := s.Station.Orbit.State(0) + sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), pos, vel) + sh.Cargo = [world.NumOre]int64{world.Iron: 5000, world.Nickel: 1200, world.Platinum: 7} + if _, err := s.RunDay(cfg, DayInput{}); err != nil { + t.Fatal(err) + } + tow := s.towOf(sh.ID) + if tow.Count != 4 || tow.OreTotal != 6207 || tow.Ore[world.Iron] != 5000 || tow.Ore[world.Nickel] != 1200 || tow.Ore[world.Platinum] != 7 { + t.Fatalf("tow = %+v, want 4 containers holding all 6207 kg", tow) + } + for _, c := range s.Containers { + if c.OreTotal() > ContainerCapKg { + t.Fatalf("container %d holds %d kg", c.ID, c.OreTotal()) + } + } + if sh.Cargo != ([world.NumOre]int64{}) { + t.Fatal("the old hold should be empty after conversion") + } +} + +// Snapshots written before containers existed must still load: their ships +// have a Cargo array and a CargoCap in the hull, and no container list. +func TestSnapshotFromBeforeContainersStillLoads(t *testing.T) { + type legacyHull struct{ DryMass, FuelCap, CargoCap, Thrust, ExhaustVel, MineRate int64 } + type legacyShip struct { + ID, Owner int64 + Hull legacyHull + Pos, Vel fixed.Vec + Fuel fixed.F + Cargo [world.NumOre]int64 + CPU *vm.CPU + Alive bool + Earned int64 + Throttle, Azimuth int32 + Pitch int32 + Target int64 + Mining, UplinkNew bool + UplinkLen int32 + MathX, MathY, MathZ int32 + } + type legacyState struct { + Day int64 + Asteroids []world.Asteroid + Station world.Station + Ships []*legacyShip + Credits map[int64]int64 + Market market.Market + } + cfg := testCfg() + fresh := NewState(cfg) + pos, vel := fresh.Station.Orbit.State(0) + cpu, _ := vm.New(mustAsm(t, "halt"), cfg.RAMBytes) + old := legacyState{ + Day: 5, Asteroids: fresh.Asteroids, Station: fresh.Station, Credits: map[int64]int64{1: 123}, Market: fresh.Market, + Ships: []*legacyShip{{ + ID: 7, Owner: 1, Hull: legacyHull{DryMass: 8000, FuelCap: 4000, CargoCap: 6000, Thrust: 6000, ExhaustVel: 30000, MineRate: 10}, + Pos: pos, Vel: vel, Fuel: fixed.FromInt(4000), CPU: cpu, Alive: true, + Cargo: [world.NumOre]int64{world.Iron: 3000}, + }}, + } + var buf bytes.Buffer + if err := gob.NewEncoder(&buf).Encode(&old); err != nil { + t.Fatal(err) + } + var s State + if err := gob.NewDecoder(&buf).Decode(&s); err != nil { + t.Fatalf("an old snapshot no longer decodes: %v", err) + } + if s.Day != 5 || len(s.Ships) != 1 || s.Ships[0].Cargo[world.Iron] != 3000 || s.Credits[1] != 123 || len(s.Containers) != 0 { + t.Fatalf("decoded = day %d, %d ships, cargo %v, credits %v", s.Day, len(s.Ships), s.Ships[0].Cargo, s.Credits) + } + if _, err := s.RunDay(cfg, DayInput{}); err != nil { + t.Fatal(err) + } + if tow := s.towOf(7); tow.Count != 2 || tow.Ore[world.Iron] != 3000 { + t.Fatalf("the old ship's 3000 kg of iron should now ride in 2 containers: %+v", tow) + } +} + +// A program that orders, attaches, reads and detaches, through the real ports. +const haulProgram = ` + .equ P_CONTS 0x51 + .equ P_CONTROOM 0x53 + .equ P_CONTMASS 0x54 + .equ P_ATTACH 0x55 + .equ P_DETACH 0x56 + .equ P_NEARBY 0x57 + .equ P_ORDER 0x66 + .equ P_MASS 0x24 + .equ TX 1024 + + ldi r0, 0 + ldi r1, 3 + out P_ORDER, r1 ; three empty containers, delivered to the ship + in r2, P_NEARBY + stw r2, [r0+1024] ; 3 nearby before attaching + ldi r1, 1 + out P_ATTACH, r1 + in r2, P_CONTS + stw r2, [r0+1028] ; 3 in tow + in r2, P_CONTROOM + stw r2, [r0+1032] ; 6000 kg of room + in r2, P_CONTMASS + stw r2, [r0+1036] ; 1500 kg of container + in r2, P_NEARBY + stw r2, [r0+1040] ; 0 nearby: all attached + in r2, P_MASS + stw r2, [r0+1044] ; total ship mass + out P_DETACH, r1 + in r2, P_CONTS + stw r2, [r0+1048] ; 0 in tow + in r2, P_NEARBY + stw r2, [r0+1052] ; 3 nearby again + in r2, P_MASS + stw r2, [r0+1056] ; mass without them + halt +` + +func TestContainerPorts(t *testing.T) { + cfg := testCfg() + run := func() (DayResult, *State) { + s := NewState(cfg) + pos, vel := s.Station.Orbit.State(0) + addShip(t, s, cfg, 1, mustAsm(t, haulProgram), pos, vel) + res, err := s.RunDay(cfg, DayInput{}) + if err != nil { + t.Fatal(err) + } + return res, s + } + res, s := run() + d := res.Downlinks[0].Data + w := func(i int) int32 { return int32(binary.LittleEndian.Uint32(d[i*4:])) } + base := int32(CommandShip.DryMass + CommandShip.FuelCap) + want := []int32{3, 3, 3 * ContainerCapKg, 3 * ContainerMassKg, 0, base + 3*ContainerMassKg, 0, 3, base} + for i, v := range want { + if w(i) != v { + t.Errorf("word %d = %d, want %d", i, w(i), v) + } + } + if len(s.Containers) != 3 { + t.Errorf("%d containers exist", len(s.Containers)) + } + // The same program on the same world is the same day, containers and all. + if res2, _ := run(); res2.Hash != res.Hash { + t.Error("ordering, attaching and detaching are not deterministic") + } +} + +const life = ContainerLifeDays * config.SecondsPerDay + +func TestContainersAdriftForTenDaysVanish(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + stPos, stVel := s.Station.Orbit.State(0) + sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), stPos, stVel) + + t0 := int64(12345) + dockAt(s, sh, t0) + if n := s.order(sh, 3, ctxAt(s, cfg, t0)); n != 3 { + t.Fatalf("ordered %d", n) + } + if s.Containers[0].ExpiresAt != t0+life { + t.Fatalf("a delivered container expires at %d, want %d", s.Containers[0].ExpiresAt, t0+life) + } + just := ctxAt(s, cfg, t0+life-1) + s.expireContainers(just) + if len(s.Containers) != 3 || expiredEvents(just) != 0 { + t.Fatal("a container vanished before its ten days were up") + } + end := ctxAt(s, cfg, t0+life) + s.expireContainers(end) + if len(s.Containers) != 0 || expiredEvents(end) != 3 { + t.Fatalf("%d containers left, %d expiry events: after ten days they should be gone", len(s.Containers), expiredEvents(end)) + } +} + +func TestTowingStopsTheClockAndLettingGoRestartsIt(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + stPos, stVel := s.Station.Orbit.State(0) + sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), stPos, stVel) + t0 := int64(1000) + dockAt(s, sh, t0) + s.order(sh, 1, ctxAt(s, cfg, t0)) + c := s.Containers[0] + + s.attach(sh, ctxAt(s, cfg, t0+5*config.SecondsPerDay)) + if c.Towed != sh.ID || c.ExpiresAt != 0 { + t.Fatalf("attached container: towed=%d expires=%d, want towed with no timer", c.Towed, c.ExpiresAt) + } + // A month in tow, and it is still here. + s.expireContainers(ctxAt(s, cfg, t0+30*config.SecondsPerDay)) + if len(s.Containers) != 1 { + t.Fatal("a container in tow expired") + } + // Let go, and the ten days start again from that moment, not from the delivery. + letGoAt := t0 + 30*config.SecondsPerDay + s.detach(sh, ctxAt(s, cfg, letGoAt)) + if c.ExpiresAt != letGoAt+life { + t.Fatalf("expires at %d, want %d", c.ExpiresAt, letGoAt+life) + } + s.expireContainers(ctxAt(s, cfg, letGoAt+life-1)) + if len(s.Containers) != 1 { + t.Fatal("expired early") + } + s.expireContainers(ctxAt(s, cfg, letGoAt+life)) + if len(s.Containers) != 0 { + t.Fatal("did not expire ten days after being let go") + } +} + +func TestLosingContainersDoesNotBlockOrdering(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + stPos, stVel := s.Station.Orbit.State(0) + sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), stPos, stVel) + for i := 0; i < 5; i++ { + s.order(sh, MaxOrder, ctxAt(s, cfg, 0)) + } + if len(s.Containers) != MaxContainersPerPlayer || s.order(sh, 1, ctxAt(s, cfg, 0)) != 0 { + t.Fatalf("not at the allowance: %d containers", len(s.Containers)) + } + // Lost and forgotten: ten days later they are gone and the allowance is back. + s.expireContainers(ctxAt(s, cfg, life)) + if len(s.Containers) != 0 { + t.Fatalf("%d containers survived", len(s.Containers)) + } + dockAt(s, sh, life) // the station has moved on in ten days + if n := s.order(sh, MaxOrder, ctxAt(s, cfg, life)); n != MaxOrder { + t.Fatalf("after they expired the player could order %d, want %d", n, MaxOrder) + } +} + +func TestContainerWithNoTimerGetsAFreshTenDays(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + pos, vel := s.Station.Orbit.State(0) + old := s.newContainer(1, pos, vel) // as decoded from a snapshot made before containers expired + now := int64(50 * config.SecondsPerDay) + s.expireContainers(ctxAt(s, cfg, now)) + if len(s.Containers) != 1 || old.ExpiresAt != now+life { + t.Fatalf("expires at %d (want %d), %d containers", old.ExpiresAt, now+life, len(s.Containers)) + } +} + +func TestLostShipsContainersStartTheClock(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + pos, vel := s.Station.Orbit.State(0) + sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), pos, vel) + c := towed(s, sh, 1)[0] + s.releaseTow(sh, 777) + if c.Towed != 0 || c.ExpiresAt != 777+life { + t.Fatalf("released container: towed=%d expires=%d", c.Towed, c.ExpiresAt) + } +} + +// Through the real daily loop: an adrift container that nobody collects is +// removed on schedule, while one in tow and the day's other rules carry on. +func TestExpiryThroughTheDailyRun(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + pos, vel := s.Station.Orbit.State(0) + sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), pos.Add(fixed.Vec{Z: fixed.FromInt(50_000)}), vel) + drifting := s.newContainer(1, pos, vel) + drifting.ExpiresAt = 3*config.SecondsPerDay + 7200 // two hours into the fourth day + inTow := towed(s, sh, 1)[0] + + var events []Event + var alive []bool + for d := 0; d < 5; d++ { + r, err := s.RunDay(cfg, DayInput{}) + if err != nil { + t.Fatal(err) + } + events = append(events, r.Events...) + gone := true + for _, c := range s.Containers { + if c == drifting { + gone = false + } + } + alive = append(alive, !gone) + } + if want := []bool{true, true, true, false, false}; !equalBools(alive, want) { + t.Fatalf("drifting container present after each day: %v, want %v", alive, want) + } + expired := 0 + for _, e := range events { + if e.Kind == "expired" { + expired++ + } + } + if expired != 1 { + t.Fatalf("%d expiry events, want 1", expired) + } + if inTow.Towed != sh.ID { + t.Fatal("the container in tow was affected") + } +} + +func equalBools(a, b []bool) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// On the very turn its ten days end, a container that is at the station with ore +// is bought, not thrown away: the sale comes first. +func TestSaleBeatsExpiry(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + s.Day = 20 + now := s.Day * config.SecondsPerDay + stPos, stVel := s.Station.Orbit.State(now) + sh := addShip(t, s, cfg, 1, mustAsm(t, "halt"), stPos.Add(fixed.Vec{Z: fixed.FromInt(50_000)}), stVel) + c := s.newContainer(0, stPos, stVel) + c.Ore[world.Iron] = 100 + c.Dropped = sh.ID + c.ExpiresAt = now // due on the first turn of the day + res, err := s.RunDay(cfg, DayInput{}) + if err != nil { + t.Fatal(err) + } + if s.Credits[1] != 200 { + t.Fatalf("credits = %d: the ore should have been sold before the container expired", s.Credits[1]) + } + for _, e := range res.Events { + if e.Kind == "expired" { + t.Fatal("the container was reported expired as well as sold") + } + } +} diff --git a/sim/hash.go b/sim/hash.go index 29f1091..9a7fc5b 100644 --- a/sim/hash.go +++ b/sim/hash.go @@ -34,11 +34,16 @@ func (s *State) Hash() [32]byte { w(sh.ID, sh.Owner, alive, int64(sh.Pos.X), int64(sh.Pos.Y), int64(sh.Pos.Z), int64(sh.Vel.X), int64(sh.Vel.Y), int64(sh.Vel.Z), int64(sh.Fuel), sh.Earned, int64(sh.Throttle), int64(sh.Azimuth), int64(sh.Pitch), sh.Target) - for _, c := range sh.Cargo { - w(c) - } h.Write(sh.CPU.MarshalState()) } + w(int64(len(s.Containers)), s.NextContainer) + for _, c := range s.Containers { + w(c.ID, c.OrderedBy, int64(c.Pos.X), int64(c.Pos.Y), int64(c.Pos.Z), + int64(c.Vel.X), int64(c.Vel.Y), int64(c.Vel.Z), c.Towed, c.Dropped, c.ExpiresAt) + for _, o := range c.Ore { + w(o) + } + } owners := make([]int64, 0, len(s.Credits)) for o := range s.Credits { owners = append(owners, o) diff --git a/sim/ports.go b/sim/ports.go index cc6fe5b..f2fa7c9 100644 --- a/sim/ports.go +++ b/sim/ports.go @@ -37,11 +37,16 @@ const ( PortScanDist = 0x38 // in: distance to target, km PortScanOre = 0x40 // in: PortScanOre+ore = kg of ore remaining (8 ports) - // Mining laser and cargo hold. + // Mining laser and the containers in tow. PortMine = 0x50 // out: 1 to mine the selected asteroid, 0 to stop - PortCargo = 0x51 // in: total cargo kg - PortCargoCap = 0x52 // in: cargo capacity kg - PortCargoOre = 0x58 // in: PortCargoOre+ore = kg of ore carried (8 ports) + PortConts = 0x51 // in: number of containers in tow + PortContKg = 0x52 // in: kg of ore in the containers in tow + PortContRoom = 0x53 // in: kg of ore the containers in tow can still take + PortContMass = 0x54 // in: mass of the containers in tow, ore included, kg + PortAttach = 0x55 // out: non-zero: take every one of your containers near the ship in tow + PortDetach = 0x56 // out: non-zero: let go of every container in tow + PortNearby = 0x57 // in: how many of your drifting containers are near enough to attach + PortContOre = 0x58 // in: PortContOre+ore = kg of that ore in the containers in tow (8 ports) // Dropoff station. PortStnRelX = 0x60 // in: station position relative to ship, km @@ -50,7 +55,7 @@ const ( PortStnRelVX = 0x63 // in: station velocity relative to ship, m/s PortStnRelVY = 0x64 PortStnRelVZ = 0x65 - PortSell = 0x66 // out: 1 to sell all cargo (needs to be docked) + PortOrder = 0x66 // out: 1..10: the station delivers that many empty containers to the ship (near the station) PortCredits = 0x67 // in: credits earned by this ship (saturating) // Comms buffers live in RAM; these ports coordinate them. diff --git a/sim/sim_test.go b/sim/sim_test.go index b4d02c4..3908352 100644 --- a/sim/sim_test.go +++ b/sim/sim_test.go @@ -7,6 +7,7 @@ import ( "halcyon/config" "halcyon/fixed" "halcyon/vm" + "halcyon/world" ) func testCfg() config.Config { @@ -147,43 +148,6 @@ func TestInclinedOrbitLeavesPlane(t *testing.T) { } } -func TestMineAndSell(t *testing.T) { - cfg := testCfg() - s := NewState(cfg) - // Sit on asteroid 1, matching its motion for the whole first tick, and mine. - pos, vel := s.Asteroids[0].Orbit.State(0) - sh := addShip(t, s, cfg, 1, mustAsm(t, ` - ldi r0, 1 - out 0x30, r0 ; select asteroid 1 - out 0x50, r0 ; mine - yield - jmp -2 - `), pos, vel) - if _, err := s.RunDay(cfg, DayInput{}); err != nil { - t.Fatal(err) - } - // Gravity acts on the ship differently than the rails, so it soon drifts - // out of range; it should still have mined something at the start. - if sh.CargoTotal() == 0 { - t.Fatal("nothing mined") - } - - // Now dock at the station and sell. - s2 := NewState(cfg) - sp, sv := s2.Station.Orbit.State(0) - sh2 := addShip(t, s2, cfg, 1, mustAsm(t, "ldi r0, 1\n out 0x66, r0\n halt"), sp, sv) - sh2.Cargo[0] = 1000 // iron - if _, err := s2.RunDay(cfg, DayInput{}); err != nil { - t.Fatal(err) - } - if s2.Credits[1] != 2000 || sh2.CargoTotal() != 0 { - t.Fatalf("credits=%d cargo=%d", s2.Credits[1], sh2.CargoTotal()) - } - if s2.Market.Prices[0] >= 2 && s2.Market.Supply[0] != 1000 { - t.Fatalf("market not updated: %+v", s2.Market) - } -} - func TestCommsBuffers(t *testing.T) { cfg := testCfg() s := NewState(cfg) @@ -209,3 +173,68 @@ func TestCommsBuffers(t *testing.T) { t.Fatalf("downlink = %q", res.Downlinks) } } + +func TestScannerRange(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + stPos, stVel := s.Station.Orbit.State(0) + sh := addShip(t, s, cfg, 1, mustAsm(t, "halt\n"), stPos, stVel) + tc := newTickCtx(s, cfg, 0, 0, stPos, stVel, &DayResult{}, new([world.NumOre]int64)) + b := &shipBus{tc: tc, sh: sh} + + // Find the true nearest asteroid and its distance from the ship. + var near int + var nd fixed.F = fixed.Max + for i := range s.Asteroids { + p, _ := tc.asteroid(i) + if d := p.Sub(sh.Pos).Len(); d < nd { + near, nd = i+1, d + } + } + limit := fixed.FromInt(ScanRangeKm) + if nd <= limit { + if got := b.In(PortScanNearest); got != int32(near) { + t.Fatalf("nearest = %d, want %d", got, near) + } + } else if got := b.In(PortScanNearest); got != 0 { + t.Fatalf("nearest = %d beyond range, want 0", got) + } + + // Move the ship out of range of everything: the scanner goes blind. + sh.Pos = fixed.Vec{X: fixed.FromInt(50_000_000)} + if got := b.In(PortScanNearest); got != 0 { + t.Fatalf("nearest out of range = %d, want 0", got) + } + b.Out(PortScanSelect, int32(near)) + for _, p := range []uint16{PortScanRelX, PortScanRelVX, PortScanDist, PortScanOre} { + if got := b.In(p); got != 0 { + t.Fatalf("port %#x = %d out of range, want 0", p, got) + } + } +} + +func TestScrapRemovesShipAndReleasesTow(t *testing.T) { + cfg := testCfg() + s := NewState(cfg) + stPos, stVel := s.Station.Orbit.State(0) + sh := addShip(t, s, cfg, 1, mustAsm(t, "halt\n"), stPos, stVel) + s.Containers = append(s.Containers, &Container{ID: 1, Towed: sh.ID, Pos: stPos, Vel: stVel}) + res, err := s.RunDay(cfg, DayInput{Scraps: []int64{1, 42}}) // an unknown id is ignored + if err != nil { + t.Fatal(err) + } + if len(s.Ships) != 0 { + t.Fatalf("%d ships left after scrapping", len(s.Ships)) + } + c := s.Containers[0] + if c.Towed != 0 || c.Dropped != 1 { + t.Fatalf("container after scrapping: towed %d dropped %d, want adrift", c.Towed, c.Dropped) + } + found := false + for _, e := range res.Events { + found = found || (e.Kind == "scrapped" && e.ShipID == 1) + } + if !found { + t.Fatal("no scrapped event") + } +} diff --git a/sim/state.go b/sim/state.go index 13a751a..bca0966 100644 --- a/sim/state.go +++ b/sim/state.go @@ -16,7 +16,6 @@ import ( type Hull struct { DryMass int64 // kg FuelCap int64 // kg - CargoCap int64 // kg Thrust int64 // newtons at full throttle ExhaustVel int64 // m/s MineRate int64 // kg mined per tick @@ -26,7 +25,6 @@ type Hull struct { var CommandShip = Hull{ DryMass: 8000, FuelCap: 4000, - CargoCap: 6000, Thrust: 6000, ExhaustVel: 30000, MineRate: 10, @@ -35,18 +33,21 @@ var CommandShip = Hull{ // Docking / mining limits. const ( MineRangeKm = 5 // max distance to an asteroid for mining - DockRangeKm = 20 // max distance to the station for selling + DockRangeKm = 20 // how close counts as "near the station" MaxRelSpeedM = 100 // max relative speed (m/s) for mining or docking StarRadiusKm = 200_000 + ScanRangeKm = 500_000 // the scanner sees nothing farther away than this ) type Ship struct { - ID int64 - Owner int64 - Hull Hull - Pos fixed.Vec - Vel fixed.Vec - Fuel fixed.F + ID int64 + Owner int64 + Hull Hull + Pos fixed.Vec + Vel fixed.Vec + Fuel fixed.F + // Cargo is the hold of the earlier design, before ore travelled in containers. + // It is only read to convert what old ships still carry (migrateLegacyCargo). Cargo [world.NumOre]int64 CPU *vm.CPU Alive bool @@ -65,16 +66,9 @@ type Ship struct { MathZ int32 } -func (s *Ship) CargoTotal() int64 { - var t int64 - for _, v := range s.Cargo { - t += v - } - return t -} - -func (s *Ship) Mass() fixed.F { - return fixed.FromInt(s.Hull.DryMass+s.CargoTotal()) + s.Fuel +// baseMass is the hull and its fuel; towed containers add to it (State.shipMass). +func (s *Ship) baseMass() fixed.F { + return fixed.FromInt(s.Hull.DryMass) + s.Fuel } // State is everything that persists between daily runs. @@ -83,8 +77,11 @@ type State struct { Asteroids []world.Asteroid // sorted by ID Station world.Station Ships []*Ship // sorted by ID - Credits map[int64]int64 - Market market.Market + // Containers are sorted by ID; NextContainer is the last ID handed out. + Containers []*Container + NextContainer int64 + Credits map[int64]int64 + Market market.Market } // NewState builds the initial world from the config seed. @@ -123,6 +120,7 @@ type Uplink struct { type DayInput struct { Launches []Launch // processed in slice order (must be deterministic) Uplinks []Uplink + Scraps []int64 // ships to remove from the world before the day starts, in slice order } // Downlink is the content of a ship's transmit buffer at the end of the day. diff --git a/sim/step.go b/sim/step.go index 81c23bf..02a721f 100644 --- a/sim/step.go +++ b/sim/step.go @@ -37,6 +37,22 @@ func (s *State) RunDay(cfg config.Config, in DayInput) (DayResult, error) { res.Events = append(res.Events, Event{0, l.ShipID, "launch", ""}) } sort.Slice(s.Ships, func(i, j int) bool { return s.Ships[i].ID < s.Ships[j].ID }) + s.migrateLegacyCargo() + + // Scrapped ships vanish; whatever they were towing is left adrift where it is. + for _, id := range in.Scraps { + for i, sh := range s.Ships { + if sh.ID != id { + continue + } + if sh.Alive { + s.releaseTow(sh, dayStart) + } + s.Ships = append(s.Ships[:i], s.Ships[i+1:]...) + res.Events = append(res.Events, Event{0, id, "scrapped", ""}) + break + } + } // Uplinks land in the RX buffer at the start of RAM. for _, u := range in.Uplinks { @@ -57,16 +73,24 @@ func (s *State) RunDay(cfg config.Config, in DayInput) (DayResult, error) { now := dayStart + int64(tick)*dt stPos, stVel = s.Station.Orbit.State(now) tc := newTickCtx(s, cfg, tick, now, stPos, stVel, &res, &sold) + s.sellNearStation(tc) // ore in containers left near the station is sold... + s.expireContainers(tc) // ...and containers adrift for ten days vanish for _, sh := range s.Ships { if !sh.Alive { continue } sh.CPU.Run(&shipBus{tc: tc, sh: sh}, cfg.CyclesPerTick) s.physics(cfg, sh, dt, tick, &res) - if sh.Alive && sh.Mining { - s.mine(sh, tc.now+dt) + if sh.Alive { + s.syncTow(sh) + if sh.Mining { + s.mine(sh, tc.now+dt) + } + } else { + s.releaseTow(sh, tc.now) } } + s.driftContainers(cfg.OrbitSpeed, dt, tick, &res) } for _, sh := range s.Ships { @@ -109,7 +133,7 @@ func (s *State) physics(cfg config.Config, sh *Ship, dt int64, tick int, res *Da burn = sh.Fuel } // dv (km/s) = thrust*dt / mass / 1000. - dv := fixed.FromInt(thrust * dt).Div(sh.Mass()).DivInt(1000) + dv := fixed.FromInt(thrust * dt).Div(s.shipMass(sh)).DivInt(1000) sh.Vel = sh.Vel.Add(thrustDir(sh).Scale(dv)) sh.Fuel -= burn } @@ -132,8 +156,9 @@ func (s *State) canReach(sh *Ship, pos, vel fixed.Vec, rangeKm int64) bool { relV.Len() <= fixed.FromRatio(MaxRelSpeedM, 1000) } -// mine moves ore from the target asteroid into the ship's hold, split -// proportionally to the asteroid's composition. at is the absolute time (s) +// mine moves ore from the target asteroid into the containers the ship is towing, +// split proportionally to the asteroid's composition. With no container in tow, or +// none with room, nothing is mined. at is the absolute time (s) // at which range is evaluated. func (s *State) mine(sh *Ship, at int64) { a := s.asteroid(sh.Target) @@ -141,7 +166,7 @@ func (s *State) mine(sh *Ship, at int64) { return } total := a.TotalOre() - room := sh.Hull.CargoCap - sh.CargoTotal() + room := s.towOf(sh.ID).Room amt := min(sh.Hull.MineRate, room, total) if amt <= 0 { return @@ -163,8 +188,8 @@ func (s *State) mine(sh *Ship, at int64) { } for o, kg := range taken { a.Ore[o] -= kg - sh.Cargo[o] += kg } + s.fillTow(sh.ID, taken) } // thrustDir converts the ship's azimuth/pitch (milliradians) to a unit vector. diff --git a/store/audit.go b/store/audit.go new file mode 100644 index 0000000..09f06b1 --- /dev/null +++ b/store/audit.go @@ -0,0 +1,157 @@ +package store + +import ( + "context" + "database/sql" + "strings" +) + +// AuditEntry is one thing a player did, recorded for the operator. +type AuditEntry struct { + At int64 // unix seconds + PlayerID int64 // the account that acted + Action string // register, login, code_save, code_clear, program_upload, launch, scrap, uplink + Ship int64 // the ship concerned, or 0 + Slot int // the code slot concerned, or 0 + Detail string // JSON with the specifics + IP string +} + +// AuditRow is an entry as listed, with its id and the account's name. +type AuditRow struct { + AuditEntry + ID int64 + Name string +} + +// AddAudit appends an entry to the audit log. +func (q *Q) AddAudit(ctx context.Context, e AuditEntry) error { + _, err := q.exec(ctx, `INSERT INTO audit_log (at, player_id, action, ship_id, slot, detail, ip) VALUES (?, ?, ?, ?, ?, ?, ?)`, + e.At, e.PlayerID, e.Action, e.Ship, e.Slot, e.Detail, e.IP) + return err +} + +// AuditFilter narrows ListAudit. Zero values mean "any". +type AuditFilter struct { + PlayerID int64 + Action string + Before int64 // only entries with an id below this, for paging back through the log + Limit int +} + +// ListAudit returns entries newest first. +func (q *Q) ListAudit(ctx context.Context, f AuditFilter) ([]AuditRow, error) { + if f.Limit <= 0 || f.Limit > 500 { + f.Limit = 100 + } + rows, err := q.query(ctx, `SELECT a.id, a.at, a.player_id, COALESCE(p.name, ''), a.action, a.ship_id, a.slot, a.detail, a.ip + FROM audit_log a LEFT JOIN players p ON p.id = a.player_id + WHERE (? = 0 OR a.player_id = ?) AND (? = '' OR a.action = ?) AND (? = 0 OR a.id < ?) + ORDER BY a.id DESC LIMIT ?`, + f.PlayerID, f.PlayerID, f.Action, f.Action, f.Before, f.Before, f.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []AuditRow{} + for rows.Next() { + var r AuditRow + if err := rows.Scan(&r.ID, &r.At, &r.PlayerID, &r.Name, &r.Action, &r.Ship, &r.Slot, &r.Detail, &r.IP); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// AccountRow summarises one account for the admin list. +type AccountRow struct { + ID int64 + Name string + Credits int64 + Ships int + Active int // ships in the belt + Slots int // code slots in use + Actions int // audit entries + LastLogin int64 + LastAction int64 +} + +// ListAccounts returns every account, oldest first. +func (q *Q) ListAccounts(ctx context.Context) ([]AccountRow, error) { + rows, err := q.query(ctx, `SELECT p.id, p.name, p.credits, + (SELECT COUNT(*) FROM ships s WHERE s.owner_id = p.id), + (SELECT COUNT(*) FROM ships s WHERE s.owner_id = p.id AND s.status = ?), + (SELECT COUNT(*) FROM code_slots c WHERE c.player_id = p.id), + (SELECT COUNT(*) FROM audit_log a WHERE a.player_id = p.id), + (SELECT MAX(a.at) FROM audit_log a WHERE a.player_id = p.id AND a.action = 'login'), + (SELECT MAX(a.at) FROM audit_log a WHERE a.player_id = p.id) + FROM players p ORDER BY p.id`, StatusActive) + if err != nil { + return nil, err + } + defer rows.Close() + out := []AccountRow{} + for rows.Next() { + var r AccountRow + var login, last sql.NullInt64 + if err := rows.Scan(&r.ID, &r.Name, &r.Credits, &r.Ships, &r.Active, &r.Slots, &r.Actions, &login, &last); err != nil { + return nil, err + } + r.LastLogin, r.LastAction = login.Int64, last.Int64 + out = append(out, r) + } + return out, rows.Err() +} + +// PlayerByID looks an account up by number. +func (q *Q) PlayerByID(ctx context.Context, id int64) (Player, error) { + var p Player + err := q.row(ctx, `SELECT id, name, credits FROM players WHERE id = ?`, id).Scan(&p.ID, &p.Name, &p.Credits) + if err == sql.ErrNoRows { + return p, ErrNotFound + } + return p, err +} + +// SlotSummary describes a saved code slot without its source. +type SlotSummary struct { + Slot int + Name string + Size int + UpdatedAt string +} + +// ListSlotSummaries returns a player's saved slots for the admin view. +func (q *Q) ListSlotSummaries(ctx context.Context, owner int64) ([]SlotSummary, error) { + rows, err := q.query(ctx, `SELECT slot, name, LENGTH(source), updated_at FROM code_slots WHERE player_id = ? ORDER BY slot`, owner) + if err != nil { + return nil, err + } + defer rows.Close() + out := []SlotSummary{} + for rows.Next() { + var s SlotSummary + if err := rows.Scan(&s.Slot, &s.Name, &s.Size, &s.UpdatedAt); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +// AuditActions lists the action names the log uses. +var AuditActions = []string{"register", "login", "code_save", "code_clear", "program_upload", "launch", "scrap", "uplink"} + +// ValidAuditAction reports whether name is one of AuditActions (or empty, meaning any). +func ValidAuditAction(name string) bool { + if name == "" { + return true + } + for _, a := range AuditActions { + if strings.EqualFold(a, name) { + return true + } + } + return false +} diff --git a/store/entities.go b/store/entities.go index 9b7ea2c..cb55dbb 100644 --- a/store/entities.go +++ b/store/entities.go @@ -19,6 +19,8 @@ const ( StatusInventory = "inventory" // built, can be programmed StatusLaunching = "launching" // enters the belt on the next run StatusActive = "active" // in the belt + StatusScrapping = "scrapping" // scrapped: leaves the belt on the next run, a fresh ship replaces it + StatusScrapped = "scrapped" // gone; hidden from the fleet list StatusDestroyed = "destroyed" ) @@ -91,7 +93,7 @@ func (q *Q) PlayerByKey(ctx context.Context, key string) (Player, error) { func (q *Q) PlayerShips(ctx context.Context, owner int64) ([]Ship, error) { rows, err := q.query(ctx, `SELECT id, owner_id, status, COALESCE(LENGTH(program), 0), downlink_day - FROM ships WHERE owner_id = ? ORDER BY id`, owner) + FROM ships WHERE owner_id = ? AND status <> ? ORDER BY id`, owner, StatusScrapped) if err != nil { return nil, err } @@ -148,6 +150,35 @@ func (q *Q) Launch(ctx context.Context, owner, ship int64) error { return q.requireOneRow(res, err) } +// Scrap queues a ship that is in the belt to be scrapped on the next daily +// run: it is removed from the world and its owner gets a new ship in inventory. +func (q *Q) Scrap(ctx context.Context, owner, ship int64) error { + if _, err := q.ownedStatus(ctx, owner, ship); err != nil { + return err + } + res, err := q.exec(ctx, `UPDATE ships SET status = ?, uplink = NULL WHERE id = ? AND owner_id = ? AND status = ?`, + StatusScrapping, ship, owner, StatusActive) + return q.requireOneRow(res, err) +} + +// PendingScraps returns the ships to remove on the next run, ordered by id. +func (q *Q) PendingScraps(ctx context.Context) ([]int64, error) { + rows, err := q.query(ctx, `SELECT id FROM ships WHERE status = ? ORDER BY id`, StatusScrapping) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + // SetUplink stores the message delivered on the next run, replacing any // message already queued. func (q *Q) SetUplink(ctx context.Context, owner, ship int64, data []byte) error { @@ -173,6 +204,36 @@ func (q *Q) Downlink(ctx context.Context, owner, ship int64) ([]byte, int64, err return data, day, err } +// DownlinkAt returns the downlink a ship produced in the run of the given day. +func (q *Q) DownlinkAt(ctx context.Context, owner, ship, day int64) ([]byte, error) { + var data []byte + err := q.row(ctx, `SELECT d.data FROM downlinks d JOIN ships s ON s.id = d.ship_id + WHERE d.ship_id = ? AND s.owner_id = ? AND d.day = ?`, ship, owner, day).Scan(&data) + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + return data, err +} + +// DownlinkDays lists the days a ship has a stored downlink for, oldest first. +func (q *Q) DownlinkDays(ctx context.Context, owner, ship int64) ([]int64, error) { + rows, err := q.query(ctx, `SELECT d.day FROM downlinks d JOIN ships s ON s.id = d.ship_id + WHERE d.ship_id = ? AND s.owner_id = ? ORDER BY d.day`, ship, owner) + if err != nil { + return nil, err + } + defer rows.Close() + days := []int64{} + for rows.Next() { + var d int64 + if err := rows.Scan(&d); err != nil { + return nil, err + } + days = append(days, d) + } + return days, rows.Err() +} + // PendingLaunches returns ships due to launch, ordered by id. func (q *Q) PendingLaunches(ctx context.Context) ([]sim.Launch, error) { rows, err := q.query(ctx, `SELECT id, owner_id, program FROM ships WHERE status = ? ORDER BY id`, StatusLaunching) @@ -255,6 +316,11 @@ func (q *Q) RecordRun(ctx context.Context, res sim.DayResult, ranAt string) erro d.Data, res.Day, d.ShipID); err != nil { return err } + if _, err := q.exec(ctx, `INSERT INTO downlinks (ship_id, day, data) VALUES (?, ?, ?) + ON CONFLICT (ship_id, day) DO UPDATE SET data = excluded.data`, + d.ShipID, res.Day, d.Data); err != nil { + return err + } } return nil } @@ -268,6 +334,32 @@ func (q *Q) AfterRun(ctx context.Context, st *sim.State) error { if _, err := q.exec(ctx, `UPDATE ships SET status = ? WHERE status = ?`, StatusActive, StatusLaunching); err != nil { return err } + // Scrapped ships are gone; each owner gets a new ship on the dock in exchange. + owners, err := q.query(ctx, `SELECT owner_id FROM ships WHERE status = ? ORDER BY id`, StatusScrapping) + if err != nil { + return err + } + var scrapped []int64 + for owners.Next() { + var o int64 + if err := owners.Scan(&o); err != nil { + owners.Close() + return err + } + scrapped = append(scrapped, o) + } + owners.Close() + if err := owners.Err(); err != nil { + return err + } + if _, err := q.exec(ctx, `UPDATE ships SET status = ? WHERE status = ?`, StatusScrapped, StatusScrapping); err != nil { + return err + } + for _, o := range scrapped { + if _, err := q.exec(ctx, `INSERT INTO ships (owner_id, status) VALUES (?, ?)`, o, StatusInventory); err != nil { + return err + } + } for _, sh := range st.Ships { if !sh.Alive { if _, err := q.exec(ctx, `UPDATE ships SET status = ? WHERE id = ?`, StatusDestroyed, sh.ID); err != nil { diff --git a/store/store.go b/store/store.go index 5228b21..b40d8be 100644 --- a/store/store.go +++ b/store/store.go @@ -123,10 +123,23 @@ var schema = []string{ id {ID}, owner_id BIGINT NOT NULL REFERENCES players(id), status TEXT NOT NULL, program {BLOB}, uplink {BLOB}, downlink {BLOB}, downlink_day BIGINT NOT NULL DEFAULT -1)`, + `CREATE TABLE IF NOT EXISTS downlinks ( + ship_id BIGINT NOT NULL, day BIGINT NOT NULL, data {BLOB} NOT NULL, + PRIMARY KEY (ship_id, day))`, + // Downlinks kept before the history table existed: only the latest of each ship. + `INSERT INTO downlinks (ship_id, day, data) + SELECT id, downlink_day, downlink FROM ships WHERE downlink_day >= 0 AND downlink IS NOT NULL + ON CONFLICT (ship_id, day) DO NOTHING`, `CREATE TABLE IF NOT EXISTS code_slots ( player_id BIGINT NOT NULL REFERENCES players(id), slot INTEGER NOT NULL, name TEXT NOT NULL, source TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (player_id, slot))`, + `CREATE TABLE IF NOT EXISTS audit_log ( + id {ID}, at BIGINT NOT NULL, player_id BIGINT NOT NULL, action TEXT NOT NULL, + ship_id BIGINT NOT NULL DEFAULT 0, slot INTEGER NOT NULL DEFAULT 0, + detail TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '')`, + `CREATE INDEX IF NOT EXISTS audit_player ON audit_log(player_id, id)`, + `CREATE INDEX IF NOT EXISTS audit_action ON audit_log(action, id)`, `CREATE INDEX IF NOT EXISTS ships_owner ON ships(owner_id)`, `CREATE INDEX IF NOT EXISTS ships_status ON ships(status)`, `CREATE TABLE IF NOT EXISTS snapshots ( diff --git a/web/static/admin/admin.css b/web/static/admin/admin.css new file mode 100644 index 0000000..5938716 --- /dev/null +++ b/web/static/admin/admin.css @@ -0,0 +1,80 @@ +/* The operator's console: the site's look, with a red accent so it is never mistaken for the player site. */ + +body.admin { --accent: #ff5577; background: linear-gradient(180deg, #07030f, #12061f 60%, #240a1c); min-height: 100vh; } +body.admin::after { content: ""; position: fixed; inset: 0; pointer-events: none; background: repeating-linear-gradient(to bottom, rgba(0,0,0,0) 0 2px, rgba(0,0,0,0.12) 3px, rgba(0,0,0,0) 4px); } + +.a-top { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 1.4rem; padding: 0.7rem 1.2rem; background: rgba(7, 3, 15, 0.96); border-bottom: 2px solid var(--accent); } +.a-brand { font-family: var(--display); letter-spacing: 0.3em; font-size: 0.95rem; } +.a-brand b { color: var(--accent); } +.a-note { color: var(--dim); font-size: 0.8rem; letter-spacing: 0.1em; } +.a-main { max-width: 1240px; margin: 0 auto; padding: 1rem 1rem 6rem; position: relative; z-index: 1; } +.a-tabs { display: flex; gap: 0.3rem; margin: 1rem 0; } +.a-tabs .tab.on { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 16px rgba(255, 85, 119, 0.55); color: #07030f; } + +.a-login { max-width: 420px; margin: 12vh auto 0; padding: 0 1rem; position: relative; z-index: 1; } +.a-login h1 { font-family: var(--display); font-size: 1.6rem; letter-spacing: 0.3em; margin: 0 0 1rem; text-align: center; } +.a-login h1 b { color: var(--accent); } + +.a-filters { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; padding: 0.8rem 1rem; } +.a-filters label { display: flex; align-items: center; gap: 0.5rem; flex: 1 1 15rem; min-width: 0; max-width: 24rem; } +.a-filters label.check { flex: none; } +.a-filters input[type="text"] { width: 100%; max-width: 16rem; } +/* a callsign can be 40 characters: the dropdown shrinks and truncates instead of widening the page */ +.a-filters select { flex: 1; min-width: 0; max-width: 100%; width: auto; text-overflow: ellipsis; } + +.scroll { overflow-x: auto; } +table.atable { width: 100%; border-collapse: collapse; font-size: 0.92rem; } +table.atable th { text-align: left; font-weight: 400; font-size: 0.75rem; letter-spacing: 0.18em; color: var(--dim); padding: 0.55rem 0.8rem; border-bottom: 1px solid var(--line); white-space: nowrap; } +table.atable th button { font: inherit; letter-spacing: inherit; color: inherit; background: none; border: 0; cursor: pointer; padding: 0; text-transform: inherit; } +table.atable th button:hover, table.atable th button[aria-sort] { color: var(--cyan); } +table.atable td { padding: 0.5rem 0.8rem; border-bottom: 1px dashed var(--faint); vertical-align: top; } +table.atable tr.arow { cursor: pointer; } +table.atable tr.arow:hover td { background: rgba(255, 85, 119, 0.07); } +table.atable tr.sel td { background: rgba(255, 85, 119, 0.13); } +table.atable td.num { text-align: right; font-variant-numeric: tabular-nums; } +table.atable td.nowrap { white-space: nowrap; } +table.atable tr.more td { background: rgba(0, 0, 0, 0.35); } +.detail-json { margin: 0; padding: 0.6rem 0.8rem; background: #05020c; border: 1px solid var(--faint); font-size: 0.85rem; overflow-x: auto; white-space: pre-wrap; overflow-wrap: anywhere; } + +.badge.lime { color: var(--lime); } +.badge.violet { color: var(--violet); } +.badge.dim { color: var(--dim); } + +.a-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1.2rem; align-items: start; } +.a-detail { margin-top: 1.2rem; } +.kv { display: grid; grid-template-columns: max-content 1fr; gap: 0.25rem 1.2rem; padding: 0.8rem 1rem; } +.kv dt { color: var(--dim); letter-spacing: 0.12em; font-size: 0.8rem; } +.kv dd { margin: 0; overflow-wrap: anywhere; } +.code-view { margin: 0; padding: 0.8rem 1rem; max-height: 22rem; overflow: auto; background: #05020c; border-top: 1px solid var(--faint); font-size: 0.85rem; white-space: pre; } +.a-empty { padding: 1rem; color: var(--dim); letter-spacing: 0.1em; } +.a-foot { padding: 0.7rem 1rem; display: flex; flex-wrap: wrap; align-items: center; gap: 0.8rem; color: var(--dim); } + +/* ---- the map ---------------------------------------------------------------- */ + +.map-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.9rem; padding: 0.7rem 1rem; border-bottom: 1px solid var(--faint); } +.map-tools .btn { min-width: 2.2rem; } +.map-tools label.check { font-size: 0.75rem; letter-spacing: 0.12em; } +.map-layout { display: grid; grid-template-columns: minmax(0, 1fr) 21rem; } +.map-wrap { position: relative; height: min(72vh, 760px); min-height: 380px; background: #05020c; overflow: hidden; } +.map-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: grab; touch-action: none; outline-offset: -3px; } +.map-canvas:active { cursor: grabbing; } +.map-tip { position: absolute; z-index: 2; max-width: 20rem; padding: 0.3rem 0.6rem; font-size: 0.8rem; pointer-events: none; background: rgba(7, 3, 15, 0.95); border: 1px solid var(--cyan); box-shadow: 0 0 12px rgba(40, 245, 255, 0.35); } +.map-tip[hidden] { display: none; } +.map-readout { position: absolute; right: 0.6rem; bottom: 0.4rem; font-size: 0.75rem; color: var(--dim); pointer-events: none; text-shadow: 0 0 4px #000; } +.map-side { border-left: 1px solid var(--faint); min-width: 0; max-height: min(72vh, 760px); overflow-y: auto; } +.map-info .kv { grid-template-columns: max-content minmax(0, 1fr); padding: 0.6rem 0.9rem; font-size: 0.88rem; } +.map-legend { display: flex; flex-wrap: wrap; gap: 0.4rem 1.2rem; font-size: 0.75rem; letter-spacing: 0.12em; } +.map-legend .dot { display: inline-block; width: 0.7rem; height: 0.7rem; border-radius: 50%; margin-right: 0.4rem; vertical-align: -0.05rem; background: var(--dim); } +.map-legend .dot[data-c="0"] { background: #8fb4d9; } +.map-legend .dot[data-c="1"] { background: #63e08a; } +.map-legend .dot[data-c="2"] { background: #4ee6ff; } +.map-legend .dot[data-c="3"] { background: #ffb62e; } +.map-legend .dot.ship { background: none; border: 2px solid #ff3ad8; box-shadow: 0 0 6px #ff3ad8; } +.map-legend .dot.box { background: #ffb62e; border-radius: 0; width: 0.55rem; height: 0.55rem; } +.map-legend .dot.orbit { background: none; border-radius: 0; border-top: 2px dashed rgba(120, 245, 255, 0.9); width: 0.8rem; height: 0; margin-top: 0.35rem; } + +@media (max-width: 900px) { + .map-layout { grid-template-columns: minmax(0, 1fr); } + .map-side { border-left: 0; border-top: 1px solid var(--faint); max-height: none; } + .map-wrap { height: 58vh; min-height: 320px; } +} diff --git a/web/static/admin/admin.js b/web/static/admin/admin.js new file mode 100644 index 0000000..978ee26 --- /dev/null +++ b/web/static/admin/admin.js @@ -0,0 +1,430 @@ +// Halcyon admin console: the accounts, and an audit trail of what they did. + +import { h, clear, toast, download } from '/dom.js'; +import { hexView, parseHex } from '/hexview.js'; +import { createMap } from '/admin/map.js'; + +const root = document.getElementById('app'); + +const ACTION_LOOK = { + register: ['lime', 'REGISTER'], + login: ['cyan', 'LOGIN'], + code_save: ['amber', 'CODE SAVE'], + code_clear: ['dim', 'CODE CLEAR'], + program_upload: ['violet', 'PROGRAM UPLOAD'], + launch: ['mag', 'LAUNCH'], + scrap: ['red', 'SCRAP'], + uplink: ['cyan', 'UPLINK'], +}; + +const state = { + actions: [], + accounts: [], + sort: { key: 'id', dir: 1 }, + selected: null, + tab: 'accounts', + audit: { rows: [], player: '', action: '', done: false, loading: false }, + auto: null, + map: null, +}; + +// ---- plumbing --------------------------------------------------------------- + +class SessionEnded extends Error {} + +async function call(path, { method = 'GET', body } = {}) { + const res = await fetch(`/admin/api${path}`, { + method, + credentials: 'same-origin', + headers: body === undefined ? {} : { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (res.status === 401 && path !== '/login') { + showLogin('YOUR SESSION HAS ENDED. LOG IN AGAIN.'); + throw new SessionEnded(); + } + if (!res.ok) { + let msg = `${res.status} ${res.statusText}`; + try { msg = (await res.json()).error ?? msg; } catch { /* not JSON */ } + const e = new Error(msg); + e.status = res.status; + throw e; + } + return res.status === 204 ? null : res.json(); +} + +const pad2 = (n) => String(n).padStart(2, '0'); +function stamp(sec) { + if (!sec) return h('span', { class: 'dim' }, 'NEVER'); + const d = new Date(sec * 1000); + const text = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; + return h('span', { title: `${d.toISOString()} (UTC)` }, text); +} +function ago(sec) { + if (!sec) return 'never'; + const s = Math.max(0, Math.floor(Date.now() / 1000 - sec)); + if (s < 90) return `${s}s ago`; + if (s < 5400) return `${Math.round(s / 60)}m ago`; + if (s < 129600) return `${Math.round(s / 3600)}h ago`; + return `${Math.round(s / 86400)}d ago`; +} +const shortHash = (x) => (typeof x === 'string' ? `${x.slice(0, 10)}…` : ''); + +/** One line describing an audit entry. */ +function summary(e) { + const d = e.detail && typeof e.detail === 'object' ? e.detail : {}; + switch (e.action) { + case 'register': return `created the account "${d.name ?? ''}" with ship #${e.ship}`; + case 'login': return `signed in${d.user_agent ? ` (${d.user_agent})` : ''}`; + case 'code_save': return `saved slot ${e.slot} "${d.name ?? ''}": ${d.bytes} bytes, sha256 ${shortHash(d.sha256)}`; + case 'code_clear': return `cleared slot ${e.slot}`; + case 'program_upload': return `uploaded a ${d.bytes}-byte program to ship #${e.ship}, sha256 ${shortHash(d.sha256)}`; + case 'launch': return e.slot + ? `launched ship #${e.ship} with slot ${e.slot} "${d.slot_name ?? ''}" (${d.program_bytes} bytes)` + : `launched ship #${e.ship} with the program already sealed in it`; + case 'scrap': return `scrapped ship #${e.ship}; a new ship replaces it on the next run`; + case 'uplink': { + const hex = typeof d.hex === 'string' ? d.hex : ''; + const preview = hex.slice(0, 32).match(/../g)?.join(' ').toUpperCase() ?? ''; + return `sent ship #${e.ship} ${d.bytes} bytes${preview ? `: ${preview}${hex.length > 32 ? ' …' : ''}` : ''}`; + } + default: return JSON.stringify(e.detail); + } +} + +const badge = (action) => { + const [cls, label] = ACTION_LOOK[action] ?? ['', action.toUpperCase()]; + return h('span', { class: `badge ${cls}` }, label); +}; + +// ---- login ------------------------------------------------------------------ + +function showLogin(message = '') { + stopAuto(); + const err = h('p', { class: 'form-error', role: 'alert' }, message); + const pw = h('input', { id: 'pw', type: 'password', autocomplete: 'current-password', required: true, spellcheck: 'false' }); + const btn = h('button', { class: 'btn primary wide', type: 'submit' }, 'ENTER'); + clear(root).append( + h('div', { class: 'a-login' }, + h('h1', null, 'HALCYON ', h('b', null, 'ADMIN')), + h('form', { + class: 'panel', + onsubmit: async (ev) => { + ev.preventDefault(); + btn.disabled = true; + err.textContent = ''; + try { + await call('/login', { method: 'POST', body: { password: pw.value } }); + pw.value = ''; + await boot(); + } catch (e) { + if (e instanceof SessionEnded) return; + err.textContent = e.status === 401 ? 'WRONG PASSWORD' : `ERROR: ${e.message}`.toUpperCase(); + btn.disabled = false; + pw.select(); + } + }, + }, + h('div', { class: 'panel-title' }, 'OPERATOR LOGIN'), + h('div', { class: 'panel-body stack' }, + h('label', { for: 'pw' }, 'ADMIN PASSWORD'), pw, err, btn), + ), + ), + ); + pw.focus(); +} + +// ---- console ---------------------------------------------------------------- + +async function boot() { + try { + const s = await call('/session'); + state.actions = s.actions; + } catch (e) { + if (e instanceof SessionEnded) return; + if (e.status === 404) { clear(root).append(h('div', { class: 'a-login' }, h('div', { class: 'panel' }, h('div', { class: 'panel-title' }, 'ADMIN IS OFF'), h('p', { class: 'panel-body' }, 'SET THE HALCYON_ADMIN_PASSWORD ENVIRONMENT VARIABLE ON THE SERVER TO TURN THE ADMIN CONSOLE ON.')))); return; } + showLogin(e.message); + return; + } + render(); + await loadAccounts(); +} + +function render() { + stopAuto(); + state.map?.destroy(); // a map from an earlier visit to the tab + state.map = null; + clear(root).append( + h('header', { class: 'a-top' }, + h('span', { class: 'a-brand' }, 'HALCYON // ', h('b', null, 'ADMIN')), + h('span', { class: 'a-note' }, 'OPERATOR CONSOLE. WHAT YOU SEE HERE IS PRIVATE.'), + h('span', { class: 'spacer' }), + h('button', { class: 'btn small', type: 'button', onclick: async () => { try { await call('/logout', { method: 'POST' }); } catch { /* already out */ } showLogin('LOGGED OUT.'); } }, 'LOGOUT'), + ), + h('main', { class: 'a-main', id: 'main' }, + h('div', { class: 'a-tabs', role: 'tablist' }, + [['accounts', 'ACCOUNTS'], ['audit', 'AUDIT LOG'], ['map', 'MAP']].map(([t, label]) => h('button', { class: `tab ${state.tab === t ? 'on' : ''}`, type: 'button', role: 'tab', 'aria-selected': String(state.tab === t), onclick: () => { state.tab = t; render(); if (t === 'audit') loadAudit(true); else if (t === 'accounts') loadAccounts(); } }, label))), + h('div', { id: 'view' }), + ), + ); + if (state.tab === 'accounts') renderAccounts(); else if (state.tab === 'audit') renderAudit(); else renderMap(); +} + +function renderMap() { + state.map = createMap({ + fetchMap: () => call('/map').catch((e) => { throw e instanceof SessionEnded ? Object.assign(e, { name: 'SessionEnded' }) : e; }), + fetchOrbit: (shipId) => call(`/map/orbit?ship=${shipId}`).catch((e) => { throw e instanceof SessionEnded ? Object.assign(e, { name: 'SessionEnded' }) : e; }), + onOpenAccount: (id) => { state.selected = id; state.tab = 'accounts'; render(); loadAccounts(); }, + }); + clear(view()).append(state.map.el); + state.map.load(); +} + +const view = () => document.getElementById('view'); + +// ---- accounts --------------------------------------------------------------- + +async function loadAccounts() { + try { + state.accounts = await call('/accounts'); + if (state.tab === 'accounts') renderAccounts(); + else fillAccountOptions(); + } catch (e) { if (!(e instanceof SessionEnded)) toast(`COULD NOT LOAD ACCOUNTS: ${e.message}`, 'err'); } +} + +const COLS = [ + ['id', '#', 'num'], ['name', 'CALLSIGN', ''], ['credits', 'CREDITS', 'num'], ['ships', 'SHIPS', 'num'], + ['in_belt', 'IN BELT', 'num'], ['slots_used', 'SLOTS', 'num'], ['actions', 'ACTIONS', 'num'], + ['last_login', 'LAST LOGIN', 'nowrap'], ['last_action', 'LAST ACTION', 'nowrap'], +]; + +let filterText = ''; + +function renderAccounts() { + const v = clear(view()); + const search = h('input', { type: 'text', id: 'q', placeholder: 'FILTER BY CALLSIGN', value: filterText, 'aria-label': 'Filter accounts by callsign', autocomplete: 'off', spellcheck: 'false' }); + const tableHost = h('div', { class: 'scroll' }); + const count = h('span', { class: 'dim' }); + const draw = () => { + const { key, dir } = state.sort; + const q = filterText.trim().toLowerCase(); + const rows = state.accounts + .filter((a) => !q || a.name.toLowerCase().includes(q) || String(a.id) === q) + .sort((a, b) => (typeof a[key] === 'string' ? a[key].localeCompare(b[key]) : a[key] - b[key]) * dir || a.id - b.id); + count.textContent = `${rows.length} OF ${state.accounts.length} ACCOUNTS`; + clear(tableHost).append( + rows.length === 0 + ? h('p', { class: 'a-empty' }, 'NO ACCOUNTS MATCH.') + : h('table', { class: 'atable' }, + h('thead', null, h('tr', null, COLS.map(([k, label]) => + h('th', { scope: 'col', 'aria-sort': state.sort.key === k ? (dir > 0 ? 'ascending' : 'descending') : null }, + h('button', { type: 'button', onclick: () => { state.sort = { key: k, dir: state.sort.key === k ? -dir : 1 }; draw(); } }, label + (state.sort.key === k ? (dir > 0 ? ' ▲' : ' ▼') : '')))))), + h('tbody', null, rows.map((a) => h('tr', { class: `arow ${state.selected === a.id ? 'sel' : ''}`, tabindex: '0', onclick: () => select(a.id), onkeydown: (e) => { if (e.key === 'Enter') select(a.id); } }, + h('td', { class: 'num' }, String(a.id)), + h('td', null, a.name), + h('td', { class: 'num' }, a.credits.toLocaleString('en-US')), + h('td', { class: 'num' }, String(a.ships)), + h('td', { class: 'num' }, String(a.in_belt)), + h('td', { class: 'num' }, `${a.slots_used}/13`), + h('td', { class: 'num' }, String(a.actions)), + h('td', { class: 'nowrap', title: a.last_login ? '' : 'has not signed in since logins were recorded' }, a.last_login ? `${ago(a.last_login)}` : h('span', { class: 'dim' }, '--')), + h('td', { class: 'nowrap' }, a.last_action ? ago(a.last_action) : h('span', { class: 'dim' }, '--')), + )))), + ); + }; + search.addEventListener('input', () => { filterText = search.value; draw(); }); + v.append( + h('section', { class: 'panel' }, + h('div', { class: 'panel-title' }, 'ACCOUNTS', h('span', { class: 'spacer' }), h('button', { class: 'btn small', type: 'button', onclick: loadAccounts }, 'REFRESH')), + h('div', { class: 'a-filters' }, h('label', null, search), count), + tableHost, + ), + h('div', { id: 'detail' }), + ); + draw(); + if (state.selected) select(state.selected, false); +} + +async function select(id, redraw = true) { + state.selected = id; + if (redraw) renderAccounts(); + const host = document.getElementById('detail'); + if (!host) return; + try { + const d = await call(`/accounts/${id}`); + if (state.selected !== id) return; + const code = h('pre', { class: 'code-view', hidden: true }); + const codeTitle = h('div', { class: 'panel-title', hidden: true }); + clear(host).append(h('div', { class: 'a-detail a-grid' }, + h('section', { class: 'panel' }, + h('div', { class: 'panel-title' }, `ACCOUNT #${d.id}`, h('span', { class: 'spacer' }), + h('button', { class: 'btn small', type: 'button', onclick: () => { state.audit.player = String(d.id); state.audit.action = ''; state.tab = 'audit'; render(); loadAudit(true); } }, 'FULL AUDIT')), + h('dl', { class: 'kv' }, + h('dt', null, 'CALLSIGN'), h('dd', null, d.name), + h('dt', null, 'CREDITS'), h('dd', null, d.credits.toLocaleString('en-US')), + ), + h('div', { class: 'panel-title' }, 'SHIPS'), + d.ships.length ? h('div', { class: 'scroll' }, h('table', { class: 'atable' }, + h('thead', null, h('tr', null, ['SHIP', 'STATUS', 'PROGRAM', 'LAST DOWNLINK'].map((t) => h('th', { scope: 'col' }, t)))), + h('tbody', null, d.ships.map((s) => h('tr', null, + h('td', null, `#${s.id}`), h('td', null, s.status.toUpperCase()), + h('td', null, s.program_bytes ? `${s.program_bytes} BYTES` : '--'), + h('td', null, s.downlink_day >= 0 ? `DAY ${s.downlink_day}` : '--')))))) : h('p', { class: 'a-empty' }, 'NO SHIPS.'), + h('div', { class: 'panel-title' }, 'CODE SLOTS'), + d.slots.length ? h('div', { class: 'scroll' }, h('table', { class: 'atable' }, + h('thead', null, h('tr', null, ['SLOT', 'NAME', 'SIZE', 'SAVED', ''].map((t) => h('th', { scope: 'col' }, t)))), + h('tbody', null, d.slots.map((s) => h('tr', null, + h('td', null, String(s.slot)), h('td', null, s.name), h('td', { class: 'num' }, `${s.size}`), + h('td', { class: 'nowrap' }, s.updated_at ? new Date(s.updated_at).toLocaleString() : '--'), + h('td', null, h('button', { class: 'btn small', type: 'button', onclick: async () => { + try { + const src = await call(`/accounts/${d.id}/slots/${s.slot}`); + codeTitle.hidden = code.hidden = false; + codeTitle.textContent = `SLOT ${s.slot}: ${src.name} (READ ONLY)`; + code.textContent = src.source; + code.scrollIntoView({ block: 'nearest' }); + } catch (e) { if (!(e instanceof SessionEnded)) toast(e.message, 'err'); } + } }, 'VIEW CODE'))))))) + : h('p', { class: 'a-empty' }, 'NO CODE SAVED.'), + codeTitle, code, + ), + h('section', { class: 'panel' }, + h('div', { class: 'panel-title' }, 'RECENT ACTIVITY'), + d.audit.length ? h('div', { class: 'scroll' }, auditTable(d.audit, { showAccount: false })) : h('p', { class: 'a-empty' }, 'NOTHING RECORDED YET.'), + ), + )); + } catch (e) { if (!(e instanceof SessionEnded)) toast(`COULD NOT LOAD THE ACCOUNT: ${e.message}`, 'err'); } +} + +// ---- audit ------------------------------------------------------------------ + +function auditTable(rows, { showAccount = true } = {}) { + const open = new Set(); + const tbody = h('tbody'); + const draw = () => { + clear(tbody); + for (const e of rows) { + const tr = h('tr', { class: 'arow', tabindex: '0', 'aria-expanded': String(open.has(e.id)), onclick: () => { open.has(e.id) ? open.delete(e.id) : open.add(e.id); draw(); }, onkeydown: (ev) => { if (ev.key === 'Enter') tr.click(); } }, + h('td', { class: 'nowrap' }, stamp(e.at)), + showAccount ? h('td', null, `${e.name || '?'} `, h('span', { class: 'dim' }, `#${e.player_id}`)) : null, + h('td', null, badge(e.action)), + h('td', null, summary(e)), + h('td', { class: 'nowrap dim' }, e.ip || ''), + ); + tbody.append(tr); + if (open.has(e.id)) { + const d = e.detail && typeof e.detail === 'object' ? e.detail : {}; + const cell = h('td', { colspan: showAccount ? '5' : '4' }, h('pre', { class: 'detail-json' }, JSON.stringify({ id: e.id, at: new Date(e.at * 1000).toISOString(), account: e.player_id, action: e.action, ship: e.ship || undefined, slot: e.slot || undefined, ip: e.ip, detail: e.detail }, null, 2))); + if (e.action === 'uplink' && typeof d.hex === 'string') { + try { cell.append(hexView(parseHex(d.hex), { maxFont: 13 }).el); } catch { /* not hex: the JSON above shows it */ } + } + tbody.append(h('tr', { class: 'more' }, cell)); + } + } + }; + draw(); + return h('table', { class: 'atable' }, + h('thead', null, h('tr', null, ['TIME', showAccount ? 'ACCOUNT' : null, 'ACTION', 'WHAT', 'FROM'].filter(Boolean).map((t) => h('th', { scope: 'col' }, t)))), + tbody); +} + +const PAGE = 100; + +async function loadAudit(reset) { + const a = state.audit; + if (a.loading) { + // A request is in flight. If this one is a fresh load (a filter changed), do it as + // soon as that finishes, with the filter as it is by then; extra "load more" clicks are dropped. + if (reset) a.reload = true; + return; + } + a.reload = false; + if (reset) { a.rows = []; a.done = false; } + a.loading = true; + try { + const q = new URLSearchParams({ limit: String(PAGE) }); + if (a.player) q.set('player', a.player); + if (a.action) q.set('action', a.action); + if (!reset && a.rows.length) q.set('before', String(a.rows[a.rows.length - 1].id)); + const rows = await call(`/audit?${q}`); + a.rows = reset ? rows : a.rows.concat(rows); + a.done = rows.length < PAGE; + } catch (e) { + if (!(e instanceof SessionEnded)) toast(`COULD NOT LOAD THE AUDIT LOG: ${e.message}`, 'err'); + } finally { + a.loading = false; + } + if (a.reload) return loadAudit(true); // a filter changed while we were loading + if (state.tab === 'audit') renderAuditList(); +} + +function stopAuto() { if (state.auto) { clearInterval(state.auto); state.auto = null; } } + +/** The account dropdown, kept in step with the loaded accounts without replacing it. */ +function fillAccountOptions() { + const sel = document.getElementById('f-player'); + if (!sel) return; + const keep = state.audit.player; + sel.replaceChildren( + h('option', { value: '' }, 'ALL ACCOUNTS'), + ...state.accounts.map((x) => h('option', { value: x.id, selected: String(x.id) === keep }, `${x.name} #${x.id}`)), + ); + sel.value = keep; +} + +// The filter bar is built once when the tab opens and never replaced, so a control +// keeps its focus and a change made while results are loading is never lost. Only the +// list underneath is redrawn. +function renderAudit() { + const a = state.audit; + const v = clear(view()); + const accountSel = h('select', { id: 'f-player', 'aria-label': 'Account' }); + const actionSel = h('select', { id: 'f-action', 'aria-label': 'Action' }, + h('option', { value: '' }, 'ALL ACTIONS'), + state.actions.map((x) => h('option', { value: x, selected: x === a.action }, (ACTION_LOOK[x]?.[1] ?? x.toUpperCase())))); + accountSel.addEventListener('change', () => { a.player = accountSel.value; loadAudit(true); }); + actionSel.addEventListener('change', () => { a.action = actionSel.value; loadAudit(true); }); + const auto = h('input', { type: 'checkbox', id: 'auto', checked: !!state.auto }); + auto.addEventListener('change', () => { + stopAuto(); + if (auto.checked) state.auto = setInterval(() => { if (state.tab === 'audit' && !a.loading) loadAudit(true); }, 10000); + }); + + v.append(h('section', { class: 'panel' }, + h('div', { class: 'panel-title' }, 'AUDIT LOG', h('span', { class: 'spacer' }), + h('button', { class: 'btn small', type: 'button', onclick: () => loadAudit(true) }, 'REFRESH')), + h('div', { class: 'a-filters' }, + h('label', null, 'ACCOUNT', accountSel), h('label', null, 'ACTION', actionSel), + h('label', { class: 'check', for: 'auto' }, auto, 'AUTO-REFRESH (10 S)'), + h('span', { class: 'spacer' }), + h('button', { class: 'btn small', id: 'csv', type: 'button', disabled: true, onclick: () => download('halcyon-audit.csv', toCSV(state.audit.rows), 'text/csv') }, 'DOWNLOAD CSV')), + h('div', { id: 'audit-list' }), + )); + fillAccountOptions(); + if (!state.accounts.length) loadAccounts(); + renderAuditList(); +} + +function renderAuditList() { + const a = state.audit; + const host = document.getElementById('audit-list'); + if (!host) return; + const csv = document.getElementById('csv'); + if (csv) csv.disabled = a.rows.length === 0; + clear(host).append( + a.rows.length ? h('div', { class: 'scroll' }, auditTable(a.rows)) : h('p', { class: 'a-empty' }, a.loading ? 'LOADING...' : 'NOTHING RECORDED FOR THIS FILTER.'), + h('div', { class: 'a-foot' }, + h('span', null, `${a.rows.length} ENTRIES SHOWN`), + a.done ? h('span', null, 'THAT IS EVERYTHING.') : h('button', { class: 'btn small', type: 'button', disabled: a.loading || a.rows.length === 0, onclick: () => loadAudit(false) }, 'LOAD MORE')), + ); +} + +const csvCell = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`; +function toCSV(rows) { + const head = ['id', 'time_utc', 'account_id', 'account', 'action', 'ship', 'slot', 'ip', 'detail']; + return `${[head.join(','), ...rows.map((e) => [e.id, new Date(e.at * 1000).toISOString(), e.player_id, e.name, e.action, e.ship, e.slot, e.ip, JSON.stringify(e.detail)].map(csvCell).join(','))].join('\n')}\n`; +} + +// ---- start ------------------------------------------------------------------ + +boot().catch(() => showLogin()); diff --git a/web/static/admin/index.html b/web/static/admin/index.html new file mode 100644 index 0000000..2cc85bb --- /dev/null +++ b/web/static/admin/index.html @@ -0,0 +1,19 @@ + + + + + + + + Halcyon Admin + + + + + + +

CONNECTING TO THE ADMIN CONSOLE...

+
+ + + diff --git a/web/static/admin/map.js b/web/static/admin/map.js new file mode 100644 index 0000000..4c43ae0 --- /dev/null +++ b/web/static/admin/map.js @@ -0,0 +1,620 @@ +// The admin map: a top-down (ecliptic plane) view of the belt. Asteroids, the +// station, drifting containers and ships are drawn on a canvas that pans and +// zooms; each ship carries an arrow for its velocity. +// +// Coordinates are kilometres from the Star, X to the right and Y up. The map +// is flat, so the Z part of a position or velocity appears in the details. + +import { h, clear } from '/dom.js'; + +const ORE_COLORS = ['#8fb4d9', '#63e08a', '#4ee6ff', '#ffb62e']; // iron, nickel, ice, platinum +const SHIP_COLOR = '#ff3ad8'; +const K_MIN = 2e-5; // px per km +const K_MAX = 3000; +const ARROW_PX_PER_KMS = 16; // arrow length per km/s of speed, on screen at any zoom + +// What survives closing the tab and coming back: where you were looking and what you chose. +const saved = { view: null, sel: null, opts: { rings: true, grid: true, labels: true, ranges: true, containers: true, lost: false } }; +try { Object.assign(saved.opts, JSON.parse(localStorage.getItem('halcyon.adminmap') ?? '{}')); } catch { /* defaults */ } + +const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); +const fmt = (n, d = 0) => Number(n).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d }); +const dominant = (ore) => ore.reduce((best, v, i) => (v > ore[best] ? i : best), 0); + +function niceStep(min) { + const p = 10 ** Math.floor(Math.log10(min)); + for (const m of [1, 2, 5, 10]) if (p * m >= min) return p * m; + return p * 10; +} +function fmtKm(v) { + const a = Math.abs(v); + if (a >= 1e6) return `${+(v / 1e6).toPrecision(3)} M`; + if (a >= 1e3) return `${+(v / 1e3).toPrecision(3)} k`; + return `${+v.toPrecision(3)}`; +} + +/** + * createMap({ fetchMap, fetchOrbit, onOpenAccount }) returns { el, load, destroy }. + * fetchMap() resolves the /admin/api/map document. fetchOrbit(shipId) resolves + * the predicted coasting path for that ship (/admin/api/map/orbit). + */ +export function createMap({ fetchMap, fetchOrbit, onOpenAccount }) { + let data = null; + let W = 0; + let H = 0; + let dpr = 1; + const view = saved.view ?? { cx: 0, cy: 0, k: 1e-4 }; + let sel = saved.sel; // { type: 'ship'|'asteroid'|'container'|'station', id } + let hover = null; + let drawQueued = false; + const opts = saved.opts; + let orbit = null; // { ship, points, closed, hit_star } for the selected ship, once loaded + let orbitSeq = 0; // guards against a stale response landing after a newer selection + + // ---- DOM ------------------------------------------------------------------ + const canvas = h('canvas', { + class: 'map-canvas', tabindex: '0', role: 'application', + 'aria-label': 'Map of the belt. Arrow keys pan, plus and minus zoom, zero fits the belt.', + }); + const tip = h('div', { class: 'map-tip', hidden: true, role: 'tooltip' }); + const readout = h('div', { class: 'map-readout' }, ''); + const wrap = h('div', { class: 'map-wrap' }, canvas, tip, readout); + const dayLabel = h('span', { class: 'dim' }, 'LOADING...'); + const info = h('div', { class: 'map-info' }); + const shipList = h('div', { class: 'scroll' }); + + const btn = (label, title, onclick) => h('button', { class: 'btn small', type: 'button', title, 'aria-label': title, onclick }, label); + const toggle = (key, label) => { + const box = h('input', { type: 'checkbox', checked: !!opts[key] }); + box.addEventListener('change', () => { opts[key] = box.checked; persist(); schedule(); }); + return h('label', { class: 'check' }, box, label); + }; + const legend = h('div', { class: 'map-legend' }, + ['IRON', 'NICKEL', 'ICE', 'PLATINUM'].map((n, i) => h('span', null, h('i', { class: 'dot', 'data-c': String(i) }), n)), + h('span', null, h('i', { class: 'dot ship' }), 'SHIP + VELOCITY ARROW'), + h('span', null, h('i', { class: 'dot box' }), 'CONTAINER'), + h('span', null, h('i', { class: 'dot orbit' }), 'SELECTED SHIP: PREDICTED PATH'), + ); + + const el = h('div', { class: 'stack-lg' }, + h('section', { class: 'panel' }, + h('div', { class: 'panel-title' }, 'BELT MAP', h('span', { class: 'spacer' }), dayLabel, + h('button', { class: 'btn small', type: 'button', onclick: () => load() }, 'REFRESH')), + h('div', { class: 'map-tools' }, + btn('+', 'Zoom in', () => zoomBy(1.6)), btn('−', 'Zoom out', () => zoomBy(1 / 1.6)), + btn('FIT BELT', 'Fit the whole belt', fit), btn('STATION', 'Centre on the station', () => { if (data) { view.cx = data.station.x; view.cy = data.station.y; view.k = Math.max(view.k, 4e-4); changed(); } }), + h('span', { class: 'spacer' }), + toggle('grid', 'GRID'), toggle('rings', 'ORBITS'), toggle('labels', 'LABELS'), toggle('ranges', 'RANGES'), toggle('containers', 'CONTAINERS'), toggle('lost', 'LOST SHIPS'), + ), + h('div', { class: 'map-layout' }, wrap, + h('aside', { class: 'map-side' }, + h('div', { class: 'panel-title' }, 'SELECTED'), info, + h('div', { class: 'panel-title' }, 'SHIPS'), shipList)), + h('div', { class: 'a-foot' }, legend), + ), + ); + + function persist() { + saved.view = view; + saved.sel = sel; + try { localStorage.setItem('halcyon.adminmap', JSON.stringify(opts)); } catch { /* ignore */ } + } + + // ---- geometry --------------------------------------------------------------- + const sx = (x) => W / 2 + (x - view.cx) * view.k; + const sy = (y) => H / 2 - (y - view.cy) * view.k; + const wx = (px) => view.cx + (px - W / 2) / view.k; + const wy = (py) => view.cy - (py - H / 2) / view.k; + + function zoomAt(px, py, factor) { + const x = wx(px); + const y = wy(py); + view.k = clamp(view.k * factor, K_MIN, K_MAX); + view.cx = x - (px - W / 2) / view.k; + view.cy = y + (py - H / 2) / view.k; + changed(); + } + const zoomBy = (f) => zoomAt(W / 2, H / 2, f); + + function fit() { + const r = (data?.belt.outer ?? 3e6) * 1.1; + view.cx = 0; + view.cy = 0; + view.k = clamp((Math.min(W, H) / 2) / r, K_MIN, K_MAX); + changed(); + } + + function flyTo(x, y, minK = 2e-3) { + view.cx = x; + view.cy = y; + view.k = clamp(Math.max(view.k, minK), K_MIN, K_MAX); + changed(); + } + + function changed() { persist(); schedule(); } + + // ---- drawing ---------------------------------------------------------------- + function schedule() { + if (drawQueued) return; + drawQueued = true; + requestAnimationFrame(() => { drawQueued = false; draw(); }); + } + + function arrow(ctx, x, y, dx, dy, len) { + const ex = x + dx * len; + const ey = y + dy * len; + ctx.beginPath(); + ctx.moveTo(x + dx * 7, y + dy * 7); + ctx.lineTo(ex, ey); + ctx.stroke(); + const head = 7; + ctx.beginPath(); + ctx.moveTo(ex, ey); + ctx.lineTo(ex - dx * head + dy * head * 0.5, ey - dy * head - dx * head * 0.5); + ctx.lineTo(ex - dx * head - dy * head * 0.5, ey - dy * head + dx * head * 0.5); + ctx.closePath(); + ctx.fill(); + } + + function circle(ctx, x, y, r) { ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); } + + function ring(ctx, r, style, dash) { + const px = r * view.k; + if (px < 3 || px > 4e5) return; // too small to see, or so large it is a straight line off screen + ctx.setLineDash(dash ?? []); + ctx.strokeStyle = style; + circle(ctx, sx(0), sy(0), px); + ctx.stroke(); + ctx.setLineDash([]); + } + + function draw() { + if (!W || !H) return; + const ctx = canvas.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.fillStyle = '#05020c'; + ctx.fillRect(0, 0, W, H); + ctx.font = '11px ui-monospace, "DejaVu Sans Mono", monospace'; + ctx.textBaseline = 'alphabetic'; + canvas.dataset.scale = String(view.k); + canvas.dataset.cx = String(view.cx); + canvas.dataset.cy = String(view.cy); + if (!data) return; + + // grid, with its coordinates along the edges + if (opts.grid) { + const step = niceStep(80 / view.k); + ctx.lineWidth = 1; + ctx.strokeStyle = 'rgba(120, 100, 200, 0.16)'; + ctx.fillStyle = 'rgba(160, 145, 220, 0.75)'; + ctx.beginPath(); + for (let x = Math.ceil(wx(0) / step) * step; x <= wx(W); x += step) { const px = Math.round(sx(x)) + 0.5; ctx.moveTo(px, 0); ctx.lineTo(px, H); } + for (let y = Math.ceil(wy(H) / step) * step; y <= wy(0); y += step) { const py = Math.round(sy(y)) + 0.5; ctx.moveTo(0, py); ctx.lineTo(W, py); } + ctx.stroke(); + for (let x = Math.ceil(wx(0) / step) * step; x <= wx(W); x += step) ctx.fillText(fmtKm(x), sx(x) + 3, H - 5); + for (let y = Math.ceil(wy(H) / step) * step; y <= wy(0); y += step) ctx.fillText(fmtKm(y), 4, sy(y) - 3); + } + + ctx.lineWidth = 1; + if (opts.rings) { + ring(ctx, data.belt.inner, 'rgba(255, 182, 46, 0.35)', [6, 6]); + ring(ctx, data.belt.outer, 'rgba(255, 182, 46, 0.35)', [6, 6]); + ring(ctx, data.station.orbit_radius, 'rgba(40, 245, 255, 0.28)'); + } + + // the Star and the zone around it that destroys ships + const cx0 = sx(0); + const cy0 = sy(0); + const zone = data.star.radius * view.k; + if (zone > 1.5) { + ctx.fillStyle = 'rgba(255, 85, 119, 0.14)'; + ctx.strokeStyle = 'rgba(255, 85, 119, 0.6)'; + circle(ctx, cx0, cy0, zone); + ctx.fill(); + ctx.stroke(); + } + const glow = ctx.createRadialGradient(cx0, cy0, 0, cx0, cy0, 12); + glow.addColorStop(0, '#fff27a'); + glow.addColorStop(0.4, '#ffb62e'); + glow.addColorStop(1, 'rgba(255, 58, 216, 0)'); + ctx.fillStyle = glow; + circle(ctx, cx0, cy0, 12); + ctx.fill(); + + // asteroids + const margin = 12; + let drawn = 0; + for (const a of data.asteroids) { + const x = sx(a.x); + const y = sy(a.y); + if (x < -margin || y < -margin || x > W + margin || y > H + margin) continue; + drawn++; + const r = clamp(1.6 + Math.log10(a.total + 1) * 0.55, 2, 5); + ctx.fillStyle = ORE_COLORS[dominant(a.ore)]; + ctx.globalAlpha = 0.9; + circle(ctx, x, y, r); + ctx.fill(); + ctx.globalAlpha = 1; + if (opts.ranges && data.ranges.mining * view.k >= 3) { + ctx.strokeStyle = 'rgba(99, 224, 138, 0.35)'; + ctx.setLineDash([3, 3]); + circle(ctx, x, y, data.ranges.mining * view.k); + ctx.stroke(); + ctx.setLineDash([]); + } + if (opts.labels && view.k > 6e-4) { ctx.fillStyle = 'rgba(200, 190, 240, 0.8)'; ctx.fillText(`#${a.id}`, x + r + 3, y + 3); } + } + canvas.dataset.asteroidsDrawn = String(drawn); + + // station + const stx = sx(data.station.x); + const sty = sy(data.station.y); + ctx.strokeStyle = '#28f5ff'; + ctx.fillStyle = 'rgba(40, 245, 255, 0.25)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.rect(stx - 5, sty - 5, 10, 10); + ctx.fill(); + ctx.stroke(); + if (opts.ranges && data.ranges.docking * view.k >= 4) { + ctx.setLineDash([4, 4]); + circle(ctx, stx, sty, data.ranges.docking * view.k); + ctx.stroke(); + ctx.setLineDash([]); + } + if (opts.labels) { ctx.fillStyle = '#28f5ff'; ctx.fillText('STATION', stx + 9, sty - 7); } + ctx.lineWidth = 1; + + // drifting containers + if (opts.containers) { + ctx.fillStyle = '#ffb62e'; + for (const c of data.containers) { + const x = sx(c.x); + const y = sy(c.y); + if (x < -margin || y < -margin || x > W + margin || y > H + margin) continue; + ctx.fillRect(x - 2, y - 2, 4, 4); + if (c.ore > 0) { ctx.strokeStyle = '#ffb62e'; ctx.strokeRect(x - 4, y - 4, 8, 8); } + } + } + + // the selected ship's predicted path, coasting with the engine off from here on (drawn + // under the ships, so a ship marker and its velocity arrow are never obscured by it) + if (orbit && sel?.type === 'ship' && orbit.ship === sel.id && orbit.points.length > 1) { + ctx.strokeStyle = orbit.hit_star ? 'rgba(255, 133, 92, 0.85)' : 'rgba(120, 245, 255, 0.8)'; + ctx.lineWidth = 1.5; + if (!orbit.closed) ctx.setLineDash([5, 4]); // open path: it does not return to where it started + ctx.beginPath(); + orbit.points.forEach((pt, i) => { + const x = sx(pt[0]); + const y = sy(pt[1]); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.stroke(); + ctx.setLineDash([]); + ctx.lineWidth = 1; + } + canvas.dataset.orbitPoints = orbit && sel?.type === 'ship' && orbit.ship === sel.id ? String(orbit.points.length) : '0'; + + // ships, each with an arrow for its velocity + for (const s of data.ships) { + if (!s.alive && !opts.lost) continue; + const x = sx(s.x); + const y = sy(s.y); + if (x < -120 || y < -120 || x > W + 120 || y > H + 120) continue; + if (!s.alive) { + ctx.strokeStyle = 'rgba(160, 150, 190, 0.8)'; + ctx.beginPath(); + ctx.moveTo(x - 4, y - 4); ctx.lineTo(x + 4, y + 4); ctx.moveTo(x + 4, y - 4); ctx.lineTo(x - 4, y + 4); + ctx.stroke(); + } else { + const v = Math.hypot(s.vx, s.vy); + ctx.shadowColor = SHIP_COLOR; + ctx.shadowBlur = 8; + ctx.strokeStyle = SHIP_COLOR; + ctx.fillStyle = SHIP_COLOR; + ctx.lineWidth = 2; + if (v > 1e-6) arrow(ctx, x, y, s.vx / v, -s.vy / v, clamp(v * ARROW_PX_PER_KMS, 14, 120)); + ctx.lineWidth = 1.5; + ctx.fillStyle = '#05020c'; + circle(ctx, x, y, 5); + ctx.fill(); + ctx.stroke(); + ctx.shadowBlur = 0; + ctx.lineWidth = 1; + } + if (opts.labels) { + // on the side away from the arrow, so the two never overlap + const away = s.alive && s.vx > 0; + ctx.textAlign = away ? 'right' : 'left'; + ctx.fillStyle = s.alive ? '#ffd6f7' : 'rgba(160, 150, 190, 0.9)'; + ctx.fillText(`${s.name || '?'} #${s.id}`, away ? x - 9 : x + 9, y - 9); + ctx.textAlign = 'left'; + } + } + + // the selection + const p = position(sel); + if (p) { + ctx.strokeStyle = '#28f5ff'; + ctx.lineWidth = 2; + circle(ctx, sx(p.x), sy(p.y), 11); + ctx.stroke(); + ctx.lineWidth = 1; + } + + // scale bar + const bar = niceStep(90 / view.k); + const px = bar * view.k; + ctx.strokeStyle = '#e9e4ff'; + ctx.fillStyle = '#e9e4ff'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(14, H - 26); ctx.lineTo(14 + px, H - 26); ctx.moveTo(14, H - 30); ctx.lineTo(14, H - 22); ctx.moveTo(14 + px, H - 30); ctx.lineTo(14 + px, H - 22); + ctx.stroke(); + ctx.lineWidth = 1; + ctx.fillText(`${fmtKm(bar)} km`, 18, H - 32); + } + + // ---- objects ------------------------------------------------------------------ + function find(sel_) { + if (!data || !sel_) return null; + switch (sel_.type) { + case 'ship': return data.ships.find((s) => s.id === sel_.id) ?? null; + case 'asteroid': return data.asteroids.find((a) => a.id === sel_.id) ?? null; + case 'container': return data.containers.find((c) => c.id === sel_.id) ?? null; + case 'station': return data.station; + default: return null; + } + } + const position = (sel_) => find(sel_); + + /** The object nearest the point (in pixels), if one is close enough to mean it. */ + function hit(px, py) { + if (!data) return null; + let best = null; + let bestD = Infinity; + const consider = (type, obj, reach, weight = 0) => { + const d = Math.hypot(sx(obj.x) - px, sy(obj.y) - py) - weight; + if (d <= reach && d < bestD) { best = { type, id: obj.id, obj }; bestD = d; } + }; + consider('station', { ...data.station, id: 0 }, 12, 4); + for (const s of data.ships) if (s.alive || opts.lost) consider('ship', s, 13, 6); + if (opts.containers) for (const c of data.containers) consider('container', c, 8, 2); + for (const a of data.asteroids) consider('asteroid', a, 8); + return best; + } + + // ---- details ------------------------------------------------------------------ + const row = (k, v) => [h('dt', null, k), h('dd', null, v)]; + const xyz = (o) => `${fmt(o.x)}, ${fmt(o.y)}, ${fmt(o.z)}`; + const vel = (o) => `${fmt(o.vx, 3)}, ${fmt(o.vy, 3)}, ${fmt(o.vz, 3)}`; + + function orbitStatus(shipId) { + if (!orbit || orbit.ship !== shipId) return 'DRAWING...'; + if (orbit.hit_star) return 'ENGINE OFF FROM HERE: FALLS INTO THE STAR'; + if (orbit.closed) return 'ENGINE OFF FROM HERE: CLOSES AFTER ONE TURN (drawn)'; + return 'ENGINE OFF FROM HERE: DOES NOT CLOSE (drawn)'; + } + + function renderInfo() { + clear(info); + const o = find(sel); + if (!o) { info.append(h('p', { class: 'a-empty' }, 'CLICK A SHIP, ASTEROID OR CONTAINER.')); return; } + const items = []; + if (sel.type === 'ship') { + const speed = Math.hypot(o.vx, o.vy, o.vz); + items.push( + ...row('CALLSIGN', h('span', null, o.name || '?', ' ', h('button', { class: 'linklike', type: 'button', onclick: () => onOpenAccount(o.owner) }, `(ACCOUNT #${o.owner})`))), + ...row('SHIP', `#${o.id}`), + ...row('STATE', o.alive ? 'IN THE BELT' : 'LOST'), + ...row('POSITION KM', xyz(o)), + ...row('FROM THE STAR', `${fmt(Math.hypot(o.x, o.y, o.z))} km`), + ...row('VELOCITY KM/S', vel(o)), + ...row('SPEED', `${fmt(speed, 3)} km/s`), + ...row('HEADING', `${fmt(((Math.atan2(o.vy, o.vx) * 180) / Math.PI + 360) % 360, 1)}° from +X`), + ...row('FUEL', `${fmt(o.fuel)} kg`), + ...row('THROTTLE', `${fmt(o.throttle / 10, 1)}%${o.mining ? ', MINING' : ''}`), + ...row('IN TOW', `${o.containers_in_tow} containers, ${fmt(o.ore_in_tow)} kg of ore`), + ...row('EARNED', fmt(o.earned)), + ...row('PREDICTED ORBIT', orbitStatus(o.id)), + ); + } else if (sel.type === 'asteroid') { + const names = data.ores; + items.push( + ...row('ASTEROID', `#${o.id}`), + ...row('POSITION KM', xyz(o)), + ...row('ORBIT RADIUS', `${fmt(o.orbit_radius)} km`), + ...row('PERIOD', `${fmt(o.period / 86400, 1)} days`), + ...row('INCLINATION', `${fmt((o.inclination * 180) / Math.PI, 1)}°`), + ...row('ORE', `${fmt(o.total)} kg`), + ...names.map((n, i) => [h('dt', null, ` ${n.toUpperCase()}`), h('dd', null, `${fmt(o.ore[i])} kg`)]).flat(), + ); + } else if (sel.type === 'container') { + items.push( + ...row('CONTAINER', `#${o.id}`), + ...row('POSITION KM', xyz(o)), + ...row('ORE', `${fmt(o.ore)} kg`), + ...row('VANISHES IN', `${fmt((o.expires_at - data.time) / 86400, 1)} days`), + ); + } else { + items.push( + ...row('STATION', 'DROPOFF POINT'), + ...row('POSITION KM', xyz(o)), + ...row('VELOCITY KM/S', vel(o)), + ...row('ORBIT RADIUS', `${fmt(o.orbit_radius)} km`), + ); + } + info.append(h('dl', { class: 'kv' }, ...items)); + } + + function renderShips() { + clear(shipList); + if (!data || data.ships.length === 0) { shipList.append(h('p', { class: 'a-empty' }, 'NO SHIPS HAVE LAUNCHED.')); return; } + shipList.append(h('table', { class: 'atable' }, + h('thead', null, h('tr', null, ['CALLSIGN', 'SHIP', 'KM/S', ''].map((t) => h('th', { scope: 'col' }, t)))), + h('tbody', null, data.ships.map((s) => h('tr', { class: `arow ${sel?.type === 'ship' && sel.id === s.id ? 'sel' : ''}`, tabindex: '0', + onclick: () => choose({ type: 'ship', id: s.id }, true), onkeydown: (e) => { if (e.key === 'Enter') choose({ type: 'ship', id: s.id }, true); } }, + h('td', null, s.name || '?'), h('td', null, `#${s.id}`), h('td', { class: 'num' }, fmt(Math.hypot(s.vx, s.vy, s.vz), 2)), + h('td', null, s.alive ? '' : h('span', { class: 'badge red' }, 'LOST'))))))); + } + + function choose(next, center = false) { + sel = next; + persist(); + updateOrbit(); + renderInfo(); + renderShips(); + const o = find(next); + if (center && o) flyTo(o.x, o.y); + else schedule(); + } + + /** (Re)fetches the predicted path for the selected ship, if any; drops it otherwise. */ + function updateOrbit() { + const mine = ++orbitSeq; + orbit = null; + if (!sel || sel.type !== 'ship' || !fetchOrbit) { schedule(); return; } + const shipId = sel.id; + fetchOrbit(shipId).then( + (o) => { if (mine === orbitSeq) { orbit = o; schedule(); renderInfo(); } }, + (e) => { if (mine === orbitSeq && !(e && e.name === 'SessionEnded')) { orbit = null; schedule(); renderInfo(); } }, + ); + } + + // ---- input ---------------------------------------------------------------------- + const pointers = new Map(); + let moved = false; + let pinch = null; + + const local = (e) => { const r = canvas.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; }; + + canvas.addEventListener('pointerdown', (e) => { + try { canvas.setPointerCapture(e.pointerId); } catch { /* the pointer has already gone (a finger lifted mid-gesture) */ } + canvas.focus({ preventScroll: true }); + pointers.set(e.pointerId, local(e)); + moved = false; + if (pointers.size === 2) { + const [a, b] = [...pointers.values()]; + pinch = { d: Math.hypot(a[0] - b[0], a[1] - b[1]), mx: (a[0] + b[0]) / 2, my: (a[1] + b[1]) / 2 }; + } + }); + + canvas.addEventListener('pointermove', (e) => { + const [px, py] = local(e); + readout.textContent = data ? `X ${fmt(wx(px))} Y ${fmt(wy(py))} km` : ''; + if (!pointers.has(e.pointerId)) { hoverAt(px, py); return; } + const [ox, oy] = pointers.get(e.pointerId); + pointers.set(e.pointerId, [px, py]); + if (pointers.size === 1) { + if (Math.hypot(px - ox, py - oy) > 0 && (moved || Math.hypot(px - ox, py - oy) > 1)) { + view.cx -= (px - ox) / view.k; + view.cy += (py - oy) / view.k; + moved = true; + hideTip(); + changed(); + } + } else if (pointers.size === 2 && pinch) { + const [a, b] = [...pointers.values()]; + const d = Math.hypot(a[0] - b[0], a[1] - b[1]); + const mx = (a[0] + b[0]) / 2; + const my = (a[1] + b[1]) / 2; + view.cx -= (mx - pinch.mx) / view.k; + view.cy += (my - pinch.my) / view.k; + if (pinch.d > 0) zoomAt(mx, my, d / pinch.d); + pinch = { d, mx, my }; + moved = true; + } + }); + + const release = (e) => { + if (!pointers.has(e.pointerId)) return; + const [px, py] = pointers.get(e.pointerId); + pointers.delete(e.pointerId); + if (pointers.size < 2) pinch = null; + if (e.type === 'pointerup' && !moved && pointers.size === 0) { + const h_ = hit(px, py); + choose(h_ ? { type: h_.type, id: h_.id } : null); + } + }; + canvas.addEventListener('pointerup', release); + canvas.addEventListener('pointercancel', release); + canvas.addEventListener('pointerleave', () => { hideTip(); readout.textContent = ''; }); + + canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + const [px, py] = local(e); + zoomAt(px, py, Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0016))); + }, { passive: false }); + + canvas.addEventListener('dblclick', (e) => { const [px, py] = local(e); zoomAt(px, py, 2); }); + + canvas.addEventListener('keydown', (e) => { + const pan = 60 / view.k; + const acts = { + '+': () => zoomBy(1.4), '=': () => zoomBy(1.4), '-': () => zoomBy(1 / 1.4), _: () => zoomBy(1 / 1.4), + ArrowLeft: () => { view.cx -= pan; changed(); }, ArrowRight: () => { view.cx += pan; changed(); }, + ArrowUp: () => { view.cy += pan; changed(); }, ArrowDown: () => { view.cy -= pan; changed(); }, + 0: fit, Home: fit, Escape: () => choose(null), + }; + if (acts[e.key]) { e.preventDefault(); acts[e.key](); } + }); + + function hoverAt(px, py) { + const found = hit(px, py); + hover = found; + canvas.style.cursor = found ? 'pointer' : 'grab'; + if (!found) { hideTip(); return; } + const o = found.obj; + let text = ''; + if (found.type === 'ship') text = `${o.name || '?'} #${o.id}: ${fmt(Math.hypot(o.vx, o.vy, o.vz), 2)} km/s`; + else if (found.type === 'asteroid') text = `Asteroid #${o.id}: ${fmt(o.total)} kg of ore (${data.ores[dominant(o.ore)]})`; + else if (found.type === 'container') text = `Container #${o.id}: ${fmt(o.ore)} kg of ore`; + else text = 'Station'; + tip.textContent = text; + tip.hidden = false; + tip.style.left = `${clamp(px + 14, 0, W - 180)}px`; + tip.style.top = `${clamp(py + 14, 0, H - 30)}px`; + } + function hideTip() { tip.hidden = true; hover = null; } + + // ---- sizing and loading ---------------------------------------------------------- + function resize() { + const r = wrap.getBoundingClientRect(); + if (!r.width || !r.height) return; + dpr = Math.min(window.devicePixelRatio || 1, 2); + const first = W === 0; + W = Math.round(r.width); + H = Math.round(r.height); + canvas.width = Math.round(W * dpr); + canvas.height = Math.round(H * dpr); + if (first && !saved.view && data) fit(); + schedule(); + } + const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null; + ro?.observe(wrap); + + async function load() { + try { + data = await fetchMap(); + } catch (e) { + if (e && e.name === 'SessionEnded') return; + dayLabel.textContent = `COULD NOT LOAD THE MAP: ${e.message}`.toUpperCase(); + return; + } + dayLabel.textContent = `WORLD AT THE START OF DAY ${data.day}`; + canvas.dataset.day = String(data.day); + if (sel && !find(sel)) sel = null; + if (!saved.view && W) { fit(); } + updateOrbit(); + renderInfo(); + renderShips(); + schedule(); + } + + return { + el, + load, + destroy() { ro?.disconnect(); persist(); }, + }; +} diff --git a/web/static/api.js b/web/static/api.js index ab1e6f1..7ebcddf 100644 --- a/web/static/api.js +++ b/web/static/api.js @@ -77,17 +77,21 @@ export const api = { market: () => call('GET', '/market', { auth: false }), register: (name) => call('POST', '/register', { json: { name }, auth: false }), me: (key) => call('GET', '/me', { key }), + // Signing in with an access key: like me(), and the server records the login. + login: (key) => call('POST', '/login', { key }), ships: () => call('GET', '/ships'), assemble: (source) => call('POST', '/assemble', { body: source, type: 'text/plain', auth: false }), // Launch a docked ship, flying the program in code slot `slot`. launch: (id, slot) => call('POST', `/ships/${id}/launch`, { json: { slot } }), + scrap: (id) => call('POST', `/ships/${id}/scrap`), slots: () => call('GET', '/slots'), slot: (n) => call('GET', `/slots/${n}`), saveSlot: (n, name, source) => call('PUT', `/slots/${n}`, { json: { name, source } }), deleteSlot: (n) => call('DELETE', `/slots/${n}`), setUplink: (id, bytes) => call('PUT', `/ships/${id}/uplink`, { body: bytes, type: 'application/octet-stream' }), - async downlink(id) { - const res = await call('GET', `/ships/${id}/downlink`, { raw: true }); + downlinkDays: async (id) => (await call('GET', `/ships/${id}/downlinks`)).days, + async downlink(id, day) { + const res = await call('GET', `/ships/${id}/downlink${day == null ? '' : `?day=${day}`}`, { raw: true }); return { bytes: new Uint8Array(await res.arrayBuffer()), day: Number(res.headers.get('X-Downlink-Day')), diff --git a/web/static/status.js b/web/static/status.js index 1ec1a81..f9e4023 100644 --- a/web/static/status.js +++ b/web/static/status.js @@ -4,6 +4,7 @@ export const STATUS = { inventory: { label: 'DOCKED', cls: 'amber', hint: 'On the dock. Can be programmed and launched.' }, launching: { label: 'LAUNCH PENDING', cls: 'mag', hint: 'Enters the belt on the next daily run.' }, active: { label: 'IN BELT', cls: 'cyan', hint: 'Flying its program.' }, + scrapping: { label: 'SCRAP PENDING', cls: 'amber', hint: 'Leaves the belt on the next daily run; a new ship replaces it.' }, destroyed: { label: 'LOST', cls: 'red', hint: 'Destroyed.' }, }; diff --git a/web/static/view-auth.js b/web/static/view-auth.js index 442e81a..fbcc52b 100644 --- a/web/static/view-auth.js +++ b/web/static/view-auth.js @@ -44,7 +44,7 @@ export function authView({ onLogin }) { btn.disabled = true; err.textContent = ''; try { - await api.me(k); + await api.login(k); keyStore.set(k); onLogin(); } catch (ex) { diff --git a/web/static/view-downlink.js b/web/static/view-downlink.js index eea2f10..d1e3821 100644 --- a/web/static/view-downlink.js +++ b/web/static/view-downlink.js @@ -11,15 +11,46 @@ export function downlinkView(ctx) { const dl = h('button', { class: 'btn', type: 'button', disabled: true }, 'DOWNLOAD .BIN'); const refresh = h('button', { class: 'btn', type: 'button', disabled: !ship }, 'REFRESH'); + // Earlier downlinks: pick a day, or step with the arrows. `days` is oldest first. + const older = h('button', { class: 'btn', type: 'button', disabled: true, 'aria-label': 'Previous downlink' }, '<'); + const newer = h('button', { class: 'btn', type: 'button', disabled: true, 'aria-label': 'Next downlink' }, '>'); + const pick = h('select', { class: 'daypick', 'aria-label': 'Downlink day', disabled: true }); + let days = []; + let shown = null; // the day on screen; null = the latest + + function syncPicker() { + clear(pick).append(...days.slice().reverse().map((d, i) => + h('option', { value: String(d) }, `DAY ${d}${i === 0 ? ' (LATEST)' : ''}`))); + const at = shown == null ? days.length - 1 : days.indexOf(shown); + if (at >= 0) pick.value = String(days[at]); + pick.disabled = days.length < 2; + older.disabled = at <= 0; + newer.disabled = at < 0 || at >= days.length - 1; + } + function go(step) { + const at = shown == null ? days.length - 1 : days.indexOf(shown); + const to = days[at + step]; + if (to !== undefined) fetchIt(to); + } + older.addEventListener('click', () => go(-1)); + newer.addEventListener('click', () => go(1)); + pick.addEventListener('change', () => fetchIt(Number(pick.value))); + let current = null; - async function fetchIt() { + async function fetchIt(day) { if (!ship) return; refresh.disabled = true; try { - const { bytes, day } = await api.downlink(ship.id); - current = { bytes, day }; + days = await api.downlinkDays(ship.id); + // Refreshing (no day given) jumps to the latest; a stale choice falls back to it too. + shown = day != null && days.includes(day) ? day : null; + const got = await api.downlink(ship.id, shown); + const bytes = got.bytes; + shown = got.day === days[days.length - 1] ? null : got.day; + syncPicker(); + current = { bytes, day: got.day }; const allZero = bytes.every((b) => b === 0); - meta.textContent = `SHIP #${ship.id} // FROM THE RUN OF DAY ${day} // ${bytes.length} BYTES`; + meta.textContent = `SHIP #${ship.id} // FROM THE RUN OF DAY ${got.day} // ${bytes.length} BYTES`; dl.disabled = false; clear(body).append( ...(allZero ? [h('p', { class: 'warn' }, 'BUFFER IS ALL ZEROS: THE SHIP HAS NOT WRITTEN ANYTHING TO ITS DOWNLINK BUFFER.')] : []), @@ -28,6 +59,9 @@ export function downlinkView(ctx) { ); } catch (e) { current = null; + days = []; + shown = null; + syncPicker(); dl.disabled = true; meta.textContent = ''; clear(body).append( @@ -53,7 +87,7 @@ export function downlinkView(ctx) { return h('section', { class: 'panel' }, h('div', { class: 'panel-title' }, 'DOWNLINK RECEIVER', h('span', { class: 'spacer' }), meta), - h('div', { class: 'toolbar pad' }, refresh, h('span', { class: 'spacer' }), dl), + h('div', { class: 'toolbar pad' }, refresh, older, pick, newer, h('span', { class: 'spacer' }), dl), body, ); } diff --git a/web/static/view-fleet.js b/web/static/view-fleet.js index 5ed1c36..b86de95 100644 --- a/web/static/view-fleet.js +++ b/web/static/view-fleet.js @@ -71,6 +71,26 @@ export function fleetView(ctx) { } } + async function scrap(ship) { + const dlg = customDialog({ + title: `SCRAP SHIP #${ship.id}`, + content: [ + h('p', null, 'THE SHIP IS BROKEN UP ON THE NEXT DAILY RUN AND LEAVES THE BELT. A NEW SHIP WITH NO PROGRAM APPEARS ON YOUR DOCK.'), + h('p', { class: 'warn' }, 'ITS FUEL AND PROGRAM ARE LOST, AND CONTAINERS IT TOWS ARE LEFT ADRIFT. THIS CANNOT BE UNDONE.'), + ], + confirmText: 'SCRAP', + danger: true, + }); + if (!(await dlg.result)) return; + try { + await api.scrap(ship.id); + toast(`SHIP #${ship.id} WILL BE SCRAPPED ON THE NEXT DAILY RUN. A NEW SHIP JOINS YOUR DOCK THEN.`); + await ctx.refresh(); + } catch (e) { + toast(`SCRAP FAILED: ${e.message}`, 'err'); + } + } + const btn = (label, onclick, opts = {}) => h('button', { class: `btn small ${opts.cls ?? ''}`, type: 'button', onclick, disabled: opts.disabled, title: opts.title }, label); @@ -81,6 +101,7 @@ export function fleetView(ctx) { out.push(btn('LAUNCH', () => launch(s), { cls: 'primary', title: 'Choose a code slot and launch on the next daily run' })); } if (s.status === 'launching' || s.status === 'active') out.push(btn('UPLINK', () => ctx.selectShip(s.id, 'uplink'))); + if (s.status === 'active') out.push(btn('SCRAP', () => scrap(s), { cls: 'danger', title: 'Break the ship up: a new ship replaces it on your dock' })); if (s.downlink_day >= 0) out.push(btn('DOWNLINK', () => ctx.selectShip(s.id, 'downlink'))); return out; } @@ -117,7 +138,7 @@ export function fleetView(ctx) { step('3', 'LAUNCH', 'the ship and choose which slot it flies. It enters the belt on the next daily run and the program is fixed for good.'), step('4', 'TALK', 'to it once a day: 1 KB up, 1 KB down, over the wormhole link.'), ), - h('p', { class: 'hint pad' }, 'The belt runs once a day. Ore delivered to the station earns credits.'), + h('p', { class: 'hint pad' }, 'The belt runs once a day. Ore is towed to the station in containers. A container left there is taken, and paid for.'), h('p', { class: 'pad' }, h('a', { class: 'btn small', href: '/manual.pdf', target: '_blank', rel: 'noopener' }, 'OPEN THE HC-33 MANUAL (PDF)')), ), ); diff --git a/web/web.go b/web/web.go index 9fedcd2..f3b1489 100644 --- a/web/web.go +++ b/web/web.go @@ -82,6 +82,10 @@ func secure(h http.Handler) http.Handler { hd.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'") hd.Set("X-Content-Type-Options", "nosniff") hd.Set("Referrer-Policy", "no-referrer") + if strings.HasPrefix(r.URL.Path, "/admin") { + hd.Set("X-Robots-Tag", "noindex, nofollow") + hd.Set("Cache-Control", "no-store") + } h.ServeHTTP(w, r) }) }