This commit is contained in:
root
2026-10-01 22:16:48 +02:00
parent b6d1909d7f
commit 9dfdf79fa7
39 changed files with 4560 additions and 181 deletions
+9
View File
@@ -0,0 +1,9 @@
# secrets and local state
.env
*.db
*.db-shm
*.db-wal
# build products
docs/generated/manual.pdf
node_modules/
+27
View File
@@ -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`. 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 ## Layout
| path | what | | path | what |
+361
View File
@@ -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 "<expiry unix seconds>.<hmac>".
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=<id>&action=<name>&before=<entry id>&limit=<n>.
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))
}
+83
View File
@@ -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")
}
}
+231
View File
@@ -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)
}
+473
View File
@@ -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, "<img src=x onerror=alert(1)>") // 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 != "<img src=x onerror=alert(1)>" || 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[:])
}
+269
View File
@@ -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
}
+93 -3
View File
@@ -4,6 +4,7 @@ package api
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"io" "io"
@@ -22,6 +23,9 @@ import (
type Server struct { type Server struct {
st *store.Store st *store.Store
cfg config.Config 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. // New returns the HTTP handler.
@@ -37,26 +41,44 @@ type Server struct {
// slot's code is assembled and sealed in first) // 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) // 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) // 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 <api_key>". // Authenticated routes take "Authorization: Bearer <api_key>".
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} s := &Server{st: st, cfg: cfg}
for _, o := range opts {
o(s)
}
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("POST /register", s.register) mux.HandleFunc("POST /register", s.register)
mux.HandleFunc("GET /market", s.market) mux.HandleFunc("GET /market", s.market)
mux.HandleFunc("GET /info", s.info) mux.HandleFunc("GET /info", s.info)
mux.HandleFunc("POST /assemble", s.assemble) 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 /me", s.auth(s.me))
mux.Handle("GET /ships", s.auth(s.ships)) mux.Handle("GET /ships", s.auth(s.ships))
mux.Handle("PUT /ships/{id}/program", s.auth(s.program)) 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}/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", s.auth(s.listSlots))
mux.Handle("GET /slots/{n}", s.auth(s.getSlot)) mux.Handle("GET /slots/{n}", s.auth(s.getSlot))
mux.Handle("PUT /slots/{n}", s.auth(s.putSlot)) mux.Handle("PUT /slots/{n}", s.auth(s.putSlot))
mux.Handle("DELETE /slots/{n}", s.auth(s.deleteSlot)) mux.Handle("DELETE /slots/{n}", s.auth(s.deleteSlot))
mux.Handle("PUT /ships/{id}/uplink", s.auth(s.uplink)) 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}/downlink", s.auth(s.downlink))
mux.Handle("GET /ships/{id}/downlinks", s.auth(s.downlinks))
return mux return mux
} }
@@ -117,9 +139,25 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
fail(w, err) fail(w, err)
return 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}) 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 { func (s *Server) currentDay(ctx context.Context) int64 {
v, ok, _ := s.st.GetMeta(ctx, runner.MetaDay) v, ok, _ := s.st.GetMeta(ctx, runner.MetaDay)
if !ok { if !ok {
@@ -240,6 +278,7 @@ func (s *Server) program(w http.ResponseWriter, r *http.Request, p store.Player)
fail(w, err) fail(w, err)
return return
} }
s.audit(r, p.ID, "program_upload", id, 0, map[string]any{"bytes": len(b), "sha256": sha256Hex(b)})
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
@@ -271,6 +310,20 @@ func (s *Server) launch(w http.ResponseWriter, r *http.Request, p store.Player)
fail(w, err) fail(w, err)
return 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) w.WriteHeader(http.StatusAccepted)
} }
@@ -291,15 +344,52 @@ func (s *Server) uplink(w http.ResponseWriter, r *http.Request, p store.Player)
fail(w, err) fail(w, err)
return 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) 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) { func (s *Server) downlink(w http.ResponseWriter, r *http.Request, p store.Player) {
id, ok := shipID(w, r) id, ok := shipID(w, r)
if !ok { if !ok {
return 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 { if err != nil {
fail(w, err) fail(w, err)
return return
+45
View File
@@ -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" { if b := do("GET", ship+"/downlink", nil, 200); len(b) != cfg.CommBytes || string(b[:4]) != "PING" {
t.Fatalf("downlink = %q", b[:8]) t.Fatalf("downlink = %q", b[:8])
} }
do("GET", ship+"/downlinks", nil, 200)
var ships []struct{ Status string } var ships []struct{ Status string }
json.Unmarshal(do("GET", "/ships", nil, 200), &ships) json.Unmarshal(do("GET", "/ships", nil, 200), &ships)
if len(ships) != 1 || ships[0].Status != store.StatusActive { if len(ships) != 1 || ships[0].Status != store.StatusActive {
@@ -131,6 +132,20 @@ func TestEndToEnd(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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) mem := sim.NewState(cfg)
in := sim.DayInput{ in := sim.DayInput{
Launches: []sim.Launch{{ShipID: reg.ShipID, Owner: 1, Program: prog}}, 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") 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), &reg2)
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. // Changing the config on an existing world is refused.
cfg.Seed = 99 cfg.Seed = 99
if _, err := runner.RunNextDay(ctx, st, cfg); err == nil { if _, err := runner.RunNextDay(ctx, st, cfg); err == nil {
+55
View File
@@ -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)
}
}
+34 -4
View File
@@ -20,13 +20,13 @@ import (
) )
// newSite starts the API with the web front end mounted, as cmd/server does. // 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) { func newSite(t *testing.T, manualPDF string, opts ...api.Option) (*httptest.Server, config.Config) {
srv, cfg, _ := newSiteStore(t, manualPDF) srv, cfg, _ := newSiteStore(t, manualPDF, opts...)
return srv, cfg return srv, cfg
} }
// newSiteStore is newSite, also returning the database so tests can run a simulated day. // 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() t.Helper()
cfg := config.Default() cfg := config.Default()
dsn := "sqlite:" + filepath.Join(t.TempDir(), "t.db") 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 { if err := st.Migrate(context.Background()); err != nil {
t.Fatal(err) t.Fatal(err)
} }
mux := api.New(st, cfg) mux := api.New(st, cfg, opts...)
mux.Handle("/", web.Handler(manualPDF)) mux.Handle("/", web.Handler(manualPDF))
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
t.Cleanup(srv.Close) t.Cleanup(srv.Close)
@@ -182,3 +182,33 @@ func TestManualPDFServedFromFileAndPickedUpLive(t *testing.T) {
t.Fatalf("range: %d %q", rr.StatusCode, part) 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)
}
}
+3
View File
@@ -129,6 +129,7 @@ func (s *Server) putSlot(w http.ResponseWriter, r *http.Request, p store.Player)
fail(w, err) fail(w, err)
return 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}) 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) fail(w, err)
return return
} }
s.audit(r, p.ID, "code_clear", 0, n, nil)
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
@@ -173,5 +175,6 @@ func (s *Server) launchFromSlot(w http.ResponseWriter, r *http.Request, p store.
fail(w, err) fail(w, err)
return 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)}) writeJSON(w, http.StatusAccepted, map[string]any{"slot": n, "program_bytes": len(prog)})
} }
+10 -1
View File
@@ -35,7 +35,16 @@ func main() {
if err := st.Migrate(context.Background()); err != nil { if err := st.Migrate(context.Background()); err != nil {
log.Fatal(err) 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 mux.Handle("/", web.Handler(*manual)) // the site; API routes are more specific and win
log.Printf("listening on %s", *addr) log.Printf("listening on %s", *addr)
log.Fatal(http.ListenAndServe(*addr, mux)) log.Fatal(http.ListenAndServe(*addr, mux))
+3
View File
@@ -24,6 +24,9 @@ services:
HALCYON_RAM_BYTES: ${HALCYON_RAM_BYTES:-8192} HALCYON_RAM_BYTES: ${HALCYON_RAM_BYTES:-8192}
HALCYON_COMM_BYTES: ${HALCYON_COMM_BYTES:-1024} HALCYON_COMM_BYTES: ${HALCYON_COMM_BYTES:-1024}
HALCYON_ASTEROIDS: ${HALCYON_ASTEROIDS:-500} 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: volumes:
- data:/data - data:/data
- manual:/manual:ro # the PDF manual, rendered separately (see below) - manual:/manual:ro # the PDF manual, rendered separately (see below)
+4 -3
View File
@@ -24,11 +24,12 @@ var portValues = map[string]int{
"RELVX": sim.PortScanRelVX, "RELVY": sim.PortScanRelVY, "RELVZ": sim.PortScanRelVZ, "RELVX": sim.PortScanRelVX, "RELVY": sim.PortScanRelVY, "RELVZ": sim.PortScanRelVZ,
"DIST": sim.PortScanDist, "DIST": sim.PortScanDist,
"ORE0": sim.PortScanOre, "ORE1": sim.PortScanOre + 1, "ORE2": sim.PortScanOre + 2, "ORE3": sim.PortScanOre + 3, "ORE0": sim.PortScanOre, "ORE1": sim.PortScanOre + 1, "ORE2": sim.PortScanOre + 2, "ORE3": sim.PortScanOre + 3,
"MINE": sim.PortMine, "CARGO": sim.PortCargo, "CARGOCAP": sim.PortCargoCap, "MINE": sim.PortMine, "CONTS": sim.PortConts, "CONTKG": sim.PortContKg, "CONTROOM": sim.PortContRoom, "CONTMASS": sim.PortContMass,
"CARGO0": sim.PortCargoOre, "CARGO1": sim.PortCargoOre + 1, "CARGO2": sim.PortCargoOre + 2, "CARGO3": sim.PortCargoOre + 3, "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, "STNX": sim.PortStnRelX, "STNY": sim.PortStnRelY, "STNZ": sim.PortStnRelZ,
"STNVX": sim.PortStnRelVX, "STNVY": sim.PortStnRelVY, "STNVZ": sim.PortStnRelVZ, "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, "UPNEW": sim.PortUplinkNew, "UPLEN": sim.PortUplinkLen,
"MATHX": sim.PortMathX, "MATHY": sim.PortMathY, "MATHZ": sim.PortMathZ, "MATHX": sim.PortMathX, "MATHY": sim.PortMathY, "MATHZ": sim.PortMathZ,
"ATAN2": sim.PortMathAtan2, "HYPOT": sim.PortMathHypot, "NORM3": sim.PortMathNorm3, "ATAN2": sim.PortMathAtan2, "HYPOT": sim.PortMathHypot, "NORM3": sim.PortMathNorm3,
+144 -54
View File
@@ -625,7 +625,7 @@ chosen by the throttle.
0x21 AZIMUTH output Thrust direction azimuth, milliradians. 0x21 AZIMUTH output Thrust direction azimuth, milliradians.
0x22 PITCH output Thrust direction elevation, milliradians. 0x22 PITCH output Thrust direction elevation, milliradians.
0x23 FUEL input Fuel remaining, kg. 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 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 Full thrust ................ 6000 newtons
Exhaust velocity ........... 30,000 m/s Exhaust velocity ........... 30,000 m/s
Fuel used at full throttle . 12 kg per turn (thrust x turn / exhaust vel.) 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 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. 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 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 0x30 SELECT output Asteroid number to track. Zero, or a number that
does not exist, clears the selection. 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 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 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 (RELX, RELY, RELZ). To match its velocity, apply a thrust along
(RELVX, RELVY, RELVZ). (RELVX, RELVY, RELVZ).
NEAREST is decided among all asteroids by true three-dimensional distance; NEAREST is decided among the asteroids within scanner range by true
if two are exactly equally near, the lower number wins. 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 > **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 > 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 > to the ship is therefore decided by the difference in DIRECTION of travel, not
> by any difference in speed. > 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. 0x50 MINE output Non-zero: mine the tracked asteroid. Zero: stop.
0x51 CARGO input Total mass in the hold, kg. 0x51 CONTS input Number of containers in tow.
0x52 CARGOCAP input Capacity of the hold, kg (Prospector: 6000). 0x52 CONTKG input Ore in the containers in tow, kg.
0x58 CARGO0 input Iron in the hold, kg 0x53 CONTROOM input Room left in the containers in tow, kg.
0x59 CARGO1 input Nickel in the hold, kg 0x54 CONTMASS input Mass of the containers in tow, ore included, kg.
0x5A CARGO2 input Ice in the hold, kg 0x55 ATTACH output Non-zero: take the containers near the ship in tow.
0x5B CARGO3 input Platinum in the hold, kg 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.) (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 MINING. The laser stays on, once MINE has been set, until MINE is set to zero.
each turn in which ALL of the following are true, it moves ore from the In each turn in which ALL of the following are true, it moves ore from the
asteroid into the hold: asteroid into the containers in tow:
- an asteroid is tracked and still has ore; - an asteroid is tracked and still has ore;
- the asteroid is within 5 km of the ship; - the asteroid is within 5 km of the ship;
- the ship's velocity relative to the asteroid is at most 100 m/s; - 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 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. 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. does not replenish.
> **Note:** The scanner reports whole kilometres (rounded down). The range to > **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 0x63 STNVX input Velocity of the Station relative to the ship, X, m/s
0x64 STNVY input ... Y, m/s 0x64 STNVY input ... Y, m/s
0x65 STNVZ input ... Z, 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 0x67 EARNED input Credits earned by this ship so far
``` ```
As with the scanner, relative values are STATION MINUS SHIP. 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 SELLING IS AUTOMATIC. At the start of every turn the Station buys every
ALL of the following are true, and otherwise does nothing (there is no error container that is ADRIFT within 20 km of it and has ore in it, and pays for the
signal): 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; The credits go to the player whose ship last let go of the container, and to
- its velocity relative to the Station is at most 100 m/s; EARNED of that ship. Containers have no owner, so ore left adrift belongs to
- the hold is not empty. 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 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 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 ## 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. 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. LAUNCHING Cleared to launch. It will enter the belt on the next daily run.
Its program is sealed and can no longer be changed. Its program is sealed and can no longer be changed.
ACTIVE In the belt. ACTIVE In the belt.
SCRAPPING Ordered to be scrapped. It is removed on the next daily run.
DESTROYED Lost. It cannot be recovered. 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: 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. 2. Uplinks are delivered to their ships.
3. For each turn of the day, for each ship in order of ship number: 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; 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 PUT /ships/{n}/uplink Body: up to 1024 raw bytes. Replaces any
message queued for the next run. Allowed while LAUNCHING or ACTIVE. 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 GET /ships/{n}/downlink The latest downlink, exactly 1024 raw
bytes. The header X-Downlink-Day tells you which day's run bytes. The header X-Downlink-Day tells you which day's run
produced it. A ship's first downlink appears after its first day. 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: Typical use:
@@ -978,13 +1043,23 @@ request, 413 when your data is too large, 400 for malformed data.
``` ```
Dry mass ............................. 8000 kg Dry mass ............................. 8000 kg
Fuel tank ............................ 4000 kg Fuel tank ............................ 4000 kg
Hold ................................. 6000 kg Cargo hold ........................... none: ore travels in containers
Total mass, full tank, empty hold .... 12,000 kg Container, empty ..................... 500 kg (holds 2000 kg of ore)
Total mass, empty tank, full hold .... 14,000 kg 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 Engine ............................... 6000 N, exhaust velocity 30 km/s
Mining laser ......................... 10 kg per turn 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 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 pushes in whatever direction the two angles command. There are no
thrusters, no spin, no fuel used to rotate. 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 ) 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 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 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 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 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. hours of ship time, and yet the day is 24. Plan accordingly.
@@ -1318,14 +1394,19 @@ SCANNER
0x42 ORE2 in ice in target kg 0x42 ORE2 in ice in target kg
0x43 ORE3 in platinum in target kg 0x43 ORE3 in platinum in target kg
MINING AND HOLD MINING AND CONTAINERS
0x50 MINE out non-zero = laser on, latched 0x50 MINE out non-zero = laser on, latched
0x51 CARGO in hold contents kg 0x51 CONTS in containers in tow
0x52 CARGOCAP in hold capacity kg 0x52 CONTKG in ore in the containers in tow kg
0x58 CARGO0 in iron in hold kg 0x53 CONTROOM in room left in the containers in tow kg
0x59 CARGO1 in nickel in hold kg 0x54 CONTMASS in mass of the containers in tow kg
0x5A CARGO2 in ice in hold kg 0x55 ATTACH out non-zero = tow the containers nearby
0x5B CARGO3 in platinum in hold kg 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 STATION
0x60 STNX in station minus ship, X km 0x60 STNX in station minus ship, X km
@@ -1334,7 +1415,7 @@ STATION
0x63 STNVX in station velocity minus ship, X m/s 0x63 STNVX in station velocity minus ship, X m/s
0x64 STNVY in ... Y m/s 0x64 STNVY in ... Y m/s
0x65 STNVZ in ... Z 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 0x67 EARNED in credits earned by this ship
WORMHOLE LINK WORMHOLE LINK
@@ -1392,19 +1473,24 @@ Paste this at the top of any program. Unused equates cost nothing.
.equ P_ORE2 0x42 .equ P_ORE2 0x42
.equ P_ORE3 0x43 .equ P_ORE3 0x43
.equ P_MINE 0x50 .equ P_MINE 0x50
.equ P_CARGO 0x51 .equ P_CONTS 0x51
.equ P_CARGOCAP 0x52 .equ P_CONTKG 0x52
.equ P_CARGO0 0x58 .equ P_CONTROOM 0x53
.equ P_CARGO1 0x59 .equ P_CONTMASS 0x54
.equ P_CARGO2 0x5A .equ P_ATTACH 0x55
.equ P_CARGO3 0x5B .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_STNX 0x60
.equ P_STNY 0x61 .equ P_STNY 0x61
.equ P_STNZ 0x62 .equ P_STNZ 0x62
.equ P_STNVX 0x63 .equ P_STNVX 0x63
.equ P_STNVY 0x64 .equ P_STNVY 0x64
.equ P_STNVZ 0x65 .equ P_STNVZ 0x65
.equ P_SELL 0x66 .equ P_ORDER 0x66
.equ P_EARNED 0x67 .equ P_EARNED 0x67
.equ P_UPNEW 0x70 .equ P_UPNEW 0x70
.equ P_UPLEN 0x71 .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 | | 10-12 pos km 13-15 vel m/s |
| 20 throttle 0-1000 21 azimuth 22 pitch 23 fuel 24 mass | | 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 | | 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 | | 50 mine 51 conts 52 ore 53 room 54 mass 55 attach 56 detach |
| 60-62 stn pos 63-65 stn vel 66 sell 67 earned | | 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 | | 70 uplink flag / ack 71 uplink length |
| 80 x 81 y 82 z 83 atan2 84 hypot 85 norm3 | | 80 x 81 y 82 z 83 atan2 84 hypot 85 norm3 |
+------------------------------------------------------------------------------+ +------------------------------------------------------------------------------+
| MINE within 5 km, rel speed <= 100 m/s 10 kg/turn | | 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. | | STAR a = v^2/r, v = 3 km/s. Inside 200,000 km = destroyed. |
+------------------------------------------------------------------------------+ +------------------------------------------------------------------------------+
``` ```
+12 -7
View File
@@ -33,19 +33,24 @@
.equ P_ORE2 0x42 .equ P_ORE2 0x42
.equ P_ORE3 0x43 .equ P_ORE3 0x43
.equ P_MINE 0x50 .equ P_MINE 0x50
.equ P_CARGO 0x51 .equ P_CONTS 0x51
.equ P_CARGOCAP 0x52 .equ P_CONTKG 0x52
.equ P_CARGO0 0x58 .equ P_CONTROOM 0x53
.equ P_CARGO1 0x59 .equ P_CONTMASS 0x54
.equ P_CARGO2 0x5A .equ P_ATTACH 0x55
.equ P_CARGO3 0x5B .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_STNX 0x60
.equ P_STNY 0x61 .equ P_STNY 0x61
.equ P_STNZ 0x62 .equ P_STNZ 0x62
.equ P_STNVX 0x63 .equ P_STNVX 0x63
.equ P_STNVY 0x64 .equ P_STNVY 0x64
.equ P_STNVZ 0x65 .equ P_STNVZ 0x65
.equ P_SELL 0x66 .equ P_ORDER 0x66
.equ P_EARNED 0x67 .equ P_EARNED 0x67
.equ P_UPNEW 0x70 .equ P_UPNEW 0x70
.equ P_UPLEN 0x71 .equ P_UPLEN 0x71
+3
View File
@@ -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 { if in.Launches, err = q.PendingLaunches(ctx); err != nil {
return err return err
} }
if in.Scraps, err = q.PendingScraps(ctx); err != nil {
return err
}
if in.Uplinks, err = q.PendingUplinks(ctx); err != nil { if in.Uplinks, err = q.PendingUplinks(ctx); err != nil {
return err return err
} }
+30 -22
View File
@@ -94,12 +94,13 @@ func (b *shipBus) In(port uint16) int32 {
case port == PortFuel: case port == PortFuel:
return sat32(sh.Fuel.Floor()) return sat32(sh.Fuel.Floor())
case port == PortMass: case port == PortMass:
return sat32(sh.Mass().Floor()) return sat32(tc.s.shipMass(sh).Floor())
case port == PortScanNearest: case port == PortScanNearest:
scanRange := fixed.FromInt(ScanRangeKm)
best, bestD := int64(0), fixed.Max best, bestD := int64(0), fixed.Max
for i := range tc.s.Asteroids { for i := range tc.s.Asteroids {
p, _ := tc.asteroid(i) 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 best, bestD = int64(i+1), d
} }
} }
@@ -110,6 +111,9 @@ func (b *shipBus) In(port uint16) int32 {
return 0 return 0
} }
rel, relV := p.Sub(sh.Pos), v.Sub(sh.Vel) rel, relV := p.Sub(sh.Pos), v.Sub(sh.Vel)
if rel.Len() > fixed.FromInt(ScanRangeKm) {
return 0
}
switch { switch {
case port <= PortScanRelZ: case port <= PortScanRelZ:
return axis(rel, port, PortScanRelX, kmInt) return axis(rel, port, PortScanRelX, kmInt)
@@ -119,16 +123,25 @@ func (b *shipBus) In(port uint16) int32 {
return kmInt(rel.Len()) return kmInt(rel.Len())
case port >= PortScanOre && port < PortScanOre+8: case port >= PortScanOre && port < PortScanOre+8:
if a := tc.s.asteroid(sh.Target); a != nil && port-PortScanOre < uint16(world.NumOre) { 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 sat32(a.Ore[port-PortScanOre])
} }
return 0 return 0
case port == PortCargo: case port == PortConts:
return sat32(sh.CargoTotal()) return int32(tc.s.towOf(sh.ID).Count)
case port == PortCargoCap: case port == PortContKg:
return sat32(sh.Hull.CargoCap) return sat32(tc.s.towOf(sh.ID).OreTotal)
case port >= PortCargoOre && port < PortCargoOre+8: case port == PortContRoom:
if port-PortCargoOre < uint16(world.NumOre) { return sat32(tc.s.towOf(sh.ID).Room)
return sat32(sh.Cargo[port-PortCargoOre]) 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 return 0
case port >= PortStnRelX && port <= PortStnRelVZ: case port >= PortStnRelX && port <= PortStnRelVZ:
@@ -170,21 +183,16 @@ func (b *shipBus) Out(port uint16, v int32) {
sh.Target = int64(v) sh.Target = int64(v)
case PortMine: case PortMine:
sh.Mining = v != 0 sh.Mining = v != 0
case PortSell: case PortAttach:
if v == 0 || !tc.s.canReach(sh, tc.stPos, tc.stVel, DockRangeKm) { if v != 0 {
return tc.s.attach(sh, tc)
} }
value := tc.s.Market.Value(sh.Cargo) case PortDetach:
if value == 0 { if v != 0 {
return tc.s.detach(sh, tc)
} }
tc.s.Credits[sh.Owner] += value case PortOrder:
sh.Earned += value tc.s.order(sh, int(v), tc)
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 PortUplinkNew: case PortUplinkNew:
sh.UplinkNew = false sh.UplinkNew = false
case PortMathX: case PortMathX:
+289
View File
@@ -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{}
}
}
+758
View File
@@ -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")
}
}
}
+8 -3
View File
@@ -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), 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.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) 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()) 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)) owners := make([]int64, 0, len(s.Credits))
for o := range s.Credits { for o := range s.Credits {
owners = append(owners, o) owners = append(owners, o)
+10 -5
View File
@@ -37,11 +37,16 @@ const (
PortScanDist = 0x38 // in: distance to target, km PortScanDist = 0x38 // in: distance to target, km
PortScanOre = 0x40 // in: PortScanOre+ore = kg of ore remaining (8 ports) 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 PortMine = 0x50 // out: 1 to mine the selected asteroid, 0 to stop
PortCargo = 0x51 // in: total cargo kg PortConts = 0x51 // in: number of containers in tow
PortCargoCap = 0x52 // in: cargo capacity kg PortContKg = 0x52 // in: kg of ore in the containers in tow
PortCargoOre = 0x58 // in: PortCargoOre+ore = kg of ore carried (8 ports) 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. // Dropoff station.
PortStnRelX = 0x60 // in: station position relative to ship, km PortStnRelX = 0x60 // in: station position relative to ship, km
@@ -50,7 +55,7 @@ const (
PortStnRelVX = 0x63 // in: station velocity relative to ship, m/s PortStnRelVX = 0x63 // in: station velocity relative to ship, m/s
PortStnRelVY = 0x64 PortStnRelVY = 0x64
PortStnRelVZ = 0x65 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) PortCredits = 0x67 // in: credits earned by this ship (saturating)
// Comms buffers live in RAM; these ports coordinate them. // Comms buffers live in RAM; these ports coordinate them.
+66 -37
View File
@@ -7,6 +7,7 @@ import (
"halcyon/config" "halcyon/config"
"halcyon/fixed" "halcyon/fixed"
"halcyon/vm" "halcyon/vm"
"halcyon/world"
) )
func testCfg() config.Config { 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) { func TestCommsBuffers(t *testing.T) {
cfg := testCfg() cfg := testCfg()
s := NewState(cfg) s := NewState(cfg)
@@ -209,3 +173,68 @@ func TestCommsBuffers(t *testing.T) {
t.Fatalf("downlink = %q", res.Downlinks) 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")
}
}
+11 -13
View File
@@ -16,7 +16,6 @@ import (
type Hull struct { type Hull struct {
DryMass int64 // kg DryMass int64 // kg
FuelCap int64 // kg FuelCap int64 // kg
CargoCap int64 // kg
Thrust int64 // newtons at full throttle Thrust int64 // newtons at full throttle
ExhaustVel int64 // m/s ExhaustVel int64 // m/s
MineRate int64 // kg mined per tick MineRate int64 // kg mined per tick
@@ -26,7 +25,6 @@ type Hull struct {
var CommandShip = Hull{ var CommandShip = Hull{
DryMass: 8000, DryMass: 8000,
FuelCap: 4000, FuelCap: 4000,
CargoCap: 6000,
Thrust: 6000, Thrust: 6000,
ExhaustVel: 30000, ExhaustVel: 30000,
MineRate: 10, MineRate: 10,
@@ -35,9 +33,10 @@ var CommandShip = Hull{
// Docking / mining limits. // Docking / mining limits.
const ( const (
MineRangeKm = 5 // max distance to an asteroid for mining 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 MaxRelSpeedM = 100 // max relative speed (m/s) for mining or docking
StarRadiusKm = 200_000 StarRadiusKm = 200_000
ScanRangeKm = 500_000 // the scanner sees nothing farther away than this
) )
type Ship struct { type Ship struct {
@@ -47,6 +46,8 @@ type Ship struct {
Pos fixed.Vec Pos fixed.Vec
Vel fixed.Vec Vel fixed.Vec
Fuel fixed.F 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 Cargo [world.NumOre]int64
CPU *vm.CPU CPU *vm.CPU
Alive bool Alive bool
@@ -65,16 +66,9 @@ type Ship struct {
MathZ int32 MathZ int32
} }
func (s *Ship) CargoTotal() int64 { // baseMass is the hull and its fuel; towed containers add to it (State.shipMass).
var t int64 func (s *Ship) baseMass() fixed.F {
for _, v := range s.Cargo { return fixed.FromInt(s.Hull.DryMass) + s.Fuel
t += v
}
return t
}
func (s *Ship) Mass() fixed.F {
return fixed.FromInt(s.Hull.DryMass+s.CargoTotal()) + s.Fuel
} }
// State is everything that persists between daily runs. // State is everything that persists between daily runs.
@@ -83,6 +77,9 @@ type State struct {
Asteroids []world.Asteroid // sorted by ID Asteroids []world.Asteroid // sorted by ID
Station world.Station Station world.Station
Ships []*Ship // sorted by ID Ships []*Ship // sorted by ID
// Containers are sorted by ID; NextContainer is the last ID handed out.
Containers []*Container
NextContainer int64
Credits map[int64]int64 Credits map[int64]int64
Market market.Market Market market.Market
} }
@@ -123,6 +120,7 @@ type Uplink struct {
type DayInput struct { type DayInput struct {
Launches []Launch // processed in slice order (must be deterministic) Launches []Launch // processed in slice order (must be deterministic)
Uplinks []Uplink 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. // Downlink is the content of a ship's transmit buffer at the end of the day.
+31 -6
View File
@@ -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", ""}) 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 }) 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. // Uplinks land in the RX buffer at the start of RAM.
for _, u := range in.Uplinks { for _, u := range in.Uplinks {
@@ -57,17 +73,25 @@ func (s *State) RunDay(cfg config.Config, in DayInput) (DayResult, error) {
now := dayStart + int64(tick)*dt now := dayStart + int64(tick)*dt
stPos, stVel = s.Station.Orbit.State(now) stPos, stVel = s.Station.Orbit.State(now)
tc := newTickCtx(s, cfg, tick, now, stPos, stVel, &res, &sold) 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 { for _, sh := range s.Ships {
if !sh.Alive { if !sh.Alive {
continue continue
} }
sh.CPU.Run(&shipBus{tc: tc, sh: sh}, cfg.CyclesPerTick) sh.CPU.Run(&shipBus{tc: tc, sh: sh}, cfg.CyclesPerTick)
s.physics(cfg, sh, dt, tick, &res) s.physics(cfg, sh, dt, tick, &res)
if sh.Alive && sh.Mining { if sh.Alive {
s.syncTow(sh)
if sh.Mining {
s.mine(sh, tc.now+dt) s.mine(sh, tc.now+dt)
} }
} else {
s.releaseTow(sh, tc.now)
} }
} }
s.driftContainers(cfg.OrbitSpeed, dt, tick, &res)
}
for _, sh := range s.Ships { for _, sh := range s.Ships {
if sh.Alive { if sh.Alive {
@@ -109,7 +133,7 @@ func (s *State) physics(cfg config.Config, sh *Ship, dt int64, tick int, res *Da
burn = sh.Fuel burn = sh.Fuel
} }
// dv (km/s) = thrust*dt / mass / 1000. // 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.Vel = sh.Vel.Add(thrustDir(sh).Scale(dv))
sh.Fuel -= burn 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) relV.Len() <= fixed.FromRatio(MaxRelSpeedM, 1000)
} }
// mine moves ore from the target asteroid into the ship's hold, split // mine moves ore from the target asteroid into the containers the ship is towing,
// proportionally to the asteroid's composition. at is the absolute time (s) // 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. // at which range is evaluated.
func (s *State) mine(sh *Ship, at int64) { func (s *State) mine(sh *Ship, at int64) {
a := s.asteroid(sh.Target) a := s.asteroid(sh.Target)
@@ -141,7 +166,7 @@ func (s *State) mine(sh *Ship, at int64) {
return return
} }
total := a.TotalOre() total := a.TotalOre()
room := sh.Hull.CargoCap - sh.CargoTotal() room := s.towOf(sh.ID).Room
amt := min(sh.Hull.MineRate, room, total) amt := min(sh.Hull.MineRate, room, total)
if amt <= 0 { if amt <= 0 {
return return
@@ -163,8 +188,8 @@ func (s *State) mine(sh *Ship, at int64) {
} }
for o, kg := range taken { for o, kg := range taken {
a.Ore[o] -= kg 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. // thrustDir converts the ship's azimuth/pitch (milliradians) to a unit vector.
+157
View File
@@ -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
}
+93 -1
View File
@@ -19,6 +19,8 @@ const (
StatusInventory = "inventory" // built, can be programmed StatusInventory = "inventory" // built, can be programmed
StatusLaunching = "launching" // enters the belt on the next run StatusLaunching = "launching" // enters the belt on the next run
StatusActive = "active" // in the belt 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" 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) { 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 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 { if err != nil {
return nil, err return nil, err
} }
@@ -148,6 +150,35 @@ func (q *Q) Launch(ctx context.Context, owner, ship int64) error {
return q.requireOneRow(res, err) 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 // SetUplink stores the message delivered on the next run, replacing any
// message already queued. // message already queued.
func (q *Q) SetUplink(ctx context.Context, owner, ship int64, data []byte) error { 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 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. // PendingLaunches returns ships due to launch, ordered by id.
func (q *Q) PendingLaunches(ctx context.Context) ([]sim.Launch, error) { 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) 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 { d.Data, res.Day, d.ShipID); err != nil {
return err 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 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 { if _, err := q.exec(ctx, `UPDATE ships SET status = ? WHERE status = ?`, StatusActive, StatusLaunching); err != nil {
return err 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 { for _, sh := range st.Ships {
if !sh.Alive { if !sh.Alive {
if _, err := q.exec(ctx, `UPDATE ships SET status = ? WHERE id = ?`, StatusDestroyed, sh.ID); err != nil { if _, err := q.exec(ctx, `UPDATE ships SET status = ? WHERE id = ?`, StatusDestroyed, sh.ID); err != nil {
+13
View File
@@ -123,10 +123,23 @@ var schema = []string{
id {ID}, owner_id BIGINT NOT NULL REFERENCES players(id), id {ID}, owner_id BIGINT NOT NULL REFERENCES players(id),
status TEXT NOT NULL, program {BLOB}, uplink {BLOB}, downlink {BLOB}, status TEXT NOT NULL, program {BLOB}, uplink {BLOB}, downlink {BLOB},
downlink_day BIGINT NOT NULL DEFAULT -1)`, 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 ( `CREATE TABLE IF NOT EXISTS code_slots (
player_id BIGINT NOT NULL REFERENCES players(id), slot INTEGER NOT NULL, 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, name TEXT NOT NULL, source TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY (player_id, slot))`, 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_owner ON ships(owner_id)`,
`CREATE INDEX IF NOT EXISTS ships_status ON ships(status)`, `CREATE INDEX IF NOT EXISTS ships_status ON ships(status)`,
`CREATE TABLE IF NOT EXISTS snapshots ( `CREATE TABLE IF NOT EXISTS snapshots (
+80
View File
@@ -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; }
}
+430
View File
@@ -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());
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<meta name="color-scheme" content="dark">
<title>Halcyon Admin</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/style.css">
<link rel="stylesheet" href="/admin/admin.css">
<script type="module" src="/admin/admin.js"></script>
</head>
<body class="admin">
<div id="app"><p class="boot">CONNECTING TO THE ADMIN CONSOLE...</p></div>
<div id="toasts" role="status" aria-live="polite"></div>
<noscript><p class="boot">THIS CONSOLE REQUIRES JAVASCRIPT.</p></noscript>
</body>
</html>
+620
View File
@@ -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(); },
};
}
+6 -2
View File
@@ -77,17 +77,21 @@ export const api = {
market: () => call('GET', '/market', { auth: false }), market: () => call('GET', '/market', { auth: false }),
register: (name) => call('POST', '/register', { json: { name }, auth: false }), register: (name) => call('POST', '/register', { json: { name }, auth: false }),
me: (key) => call('GET', '/me', { key }), 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'), ships: () => call('GET', '/ships'),
assemble: (source) => call('POST', '/assemble', { body: source, type: 'text/plain', auth: false }), assemble: (source) => call('POST', '/assemble', { body: source, type: 'text/plain', auth: false }),
// Launch a docked ship, flying the program in code slot `slot`. // Launch a docked ship, flying the program in code slot `slot`.
launch: (id, slot) => call('POST', `/ships/${id}/launch`, { json: { slot } }), launch: (id, slot) => call('POST', `/ships/${id}/launch`, { json: { slot } }),
scrap: (id) => call('POST', `/ships/${id}/scrap`),
slots: () => call('GET', '/slots'), slots: () => call('GET', '/slots'),
slot: (n) => call('GET', `/slots/${n}`), slot: (n) => call('GET', `/slots/${n}`),
saveSlot: (n, name, source) => call('PUT', `/slots/${n}`, { json: { name, source } }), saveSlot: (n, name, source) => call('PUT', `/slots/${n}`, { json: { name, source } }),
deleteSlot: (n) => call('DELETE', `/slots/${n}`), deleteSlot: (n) => call('DELETE', `/slots/${n}`),
setUplink: (id, bytes) => call('PUT', `/ships/${id}/uplink`, { body: bytes, type: 'application/octet-stream' }), setUplink: (id, bytes) => call('PUT', `/ships/${id}/uplink`, { body: bytes, type: 'application/octet-stream' }),
async downlink(id) { downlinkDays: async (id) => (await call('GET', `/ships/${id}/downlinks`)).days,
const res = await call('GET', `/ships/${id}/downlink`, { raw: true }); async downlink(id, day) {
const res = await call('GET', `/ships/${id}/downlink${day == null ? '' : `?day=${day}`}`, { raw: true });
return { return {
bytes: new Uint8Array(await res.arrayBuffer()), bytes: new Uint8Array(await res.arrayBuffer()),
day: Number(res.headers.get('X-Downlink-Day')), day: Number(res.headers.get('X-Downlink-Day')),
+1
View File
@@ -4,6 +4,7 @@ export const STATUS = {
inventory: { label: 'DOCKED', cls: 'amber', hint: 'On the dock. Can be programmed and launched.' }, 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.' }, 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.' }, 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.' }, destroyed: { label: 'LOST', cls: 'red', hint: 'Destroyed.' },
}; };
+1 -1
View File
@@ -44,7 +44,7 @@ export function authView({ onLogin }) {
btn.disabled = true; btn.disabled = true;
err.textContent = ''; err.textContent = '';
try { try {
await api.me(k); await api.login(k);
keyStore.set(k); keyStore.set(k);
onLogin(); onLogin();
} catch (ex) { } catch (ex) {
+39 -5
View File
@@ -11,15 +11,46 @@ export function downlinkView(ctx) {
const dl = h('button', { class: 'btn', type: 'button', disabled: true }, 'DOWNLOAD .BIN'); const dl = h('button', { class: 'btn', type: 'button', disabled: true }, 'DOWNLOAD .BIN');
const refresh = h('button', { class: 'btn', type: 'button', disabled: !ship }, 'REFRESH'); 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; let current = null;
async function fetchIt() { async function fetchIt(day) {
if (!ship) return; if (!ship) return;
refresh.disabled = true; refresh.disabled = true;
try { try {
const { bytes, day } = await api.downlink(ship.id); days = await api.downlinkDays(ship.id);
current = { bytes, day }; // 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); 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; dl.disabled = false;
clear(body).append( clear(body).append(
...(allZero ? [h('p', { class: 'warn' }, 'BUFFER IS ALL ZEROS: THE SHIP HAS NOT WRITTEN ANYTHING TO ITS DOWNLINK BUFFER.')] : []), ...(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) { } catch (e) {
current = null; current = null;
days = [];
shown = null;
syncPicker();
dl.disabled = true; dl.disabled = true;
meta.textContent = ''; meta.textContent = '';
clear(body).append( clear(body).append(
@@ -53,7 +87,7 @@ export function downlinkView(ctx) {
return h('section', { class: 'panel' }, return h('section', { class: 'panel' },
h('div', { class: 'panel-title' }, 'DOWNLINK RECEIVER', h('span', { class: 'spacer' }), meta), 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, body,
); );
} }
+22 -1
View File
@@ -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 = {}) => const btn = (label, onclick, opts = {}) =>
h('button', { class: `btn small ${opts.cls ?? ''}`, type: 'button', onclick, disabled: opts.disabled, title: opts.title }, label); 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' })); 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 === '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'))); if (s.downlink_day >= 0) out.push(btn('DOWNLINK', () => ctx.selectShip(s.id, 'downlink')));
return out; 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('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.'), 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)')), h('p', { class: 'pad' }, h('a', { class: 'btn small', href: '/manual.pdf', target: '_blank', rel: 'noopener' }, 'OPEN THE HC-33 MANUAL (PDF)')),
), ),
); );
+4
View File
@@ -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("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("X-Content-Type-Options", "nosniff")
hd.Set("Referrer-Policy", "no-referrer") 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) h.ServeHTTP(w, r)
}) })
} }