vibes
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# secrets and local state
|
||||
.env
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# build products
|
||||
docs/generated/manual.pdf
|
||||
node_modules/
|
||||
@@ -69,6 +69,33 @@ scripts/manual-pdf.sh # or: render to docs/generated/ fo
|
||||
|
||||
Until it has been rendered once, `/manual.pdf` is a 404 that says so. The Markdown source is always at `/manual.md`.
|
||||
|
||||
## Admin console
|
||||
|
||||
`/admin` is a separate operator console: a list of accounts (ships, code slots, credits, last login and
|
||||
activity) and an audit log of what players did. It has its own login: one password, taken from the
|
||||
environment. With no password set the console is off.
|
||||
|
||||
```sh
|
||||
echo 'HALCYON_ADMIN_PASSWORD=a-long-passphrase-of-12-or-more-characters' > .env # git-ignored
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
- **Sessions:** a signed, HttpOnly, SameSite=Strict cookie scoped to `/admin`, valid for 8 hours. Changing
|
||||
the password ends every session. Wrong guesses are rate-limited per client address (5 failures, then a
|
||||
15-minute lockout). Use it over HTTPS (Caddy does that) so the cookie is marked Secure.
|
||||
- **What is audited:** `register`, `login`, `code_save`, `code_clear`, `program_upload`, `launch` and
|
||||
`uplink`, each with the account, time, client address and the specifics: the slot, name, size and
|
||||
SHA-256 of saved code; the slot or program launched; the full data of every uplink (at most 1 KB). Actions
|
||||
that fail are not recorded. A `login` is signing in with an access key through the site's login form (or
|
||||
`POST /login`); other requests are authenticated by the key alone. The admin can read the code an
|
||||
account has saved, but not change anything.
|
||||
- **Map:** the MAP tab is a top-down 2D chart of the belt: asteroids (coloured by dominant ore), the station,
|
||||
drifting containers and every ship, each with its velocity vector drawn as an arrow (longer = faster).
|
||||
Drag to pan, wheel or pinch to zoom, click an object for details, or pick a ship from the list to fly to it.
|
||||
Positions are those at the start of the current day and are served by `GET /admin/api/map`. Clicking a ship
|
||||
also draws the path it would coast along with its engine off from there, from `GET /admin/api/map/orbit`.
|
||||
- **Retention:** the log is never pruned. It is the `audit_log` table, so it can be queried or trimmed with SQL.
|
||||
|
||||
## Layout
|
||||
|
||||
| path | what |
|
||||
|
||||
+361
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -4,6 +4,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -22,6 +23,9 @@ import (
|
||||
type Server struct {
|
||||
st *store.Store
|
||||
cfg config.Config
|
||||
admin *adminAuth // nil when the admin interface is off
|
||||
|
||||
mapCache mapCache // the admin map's last response
|
||||
}
|
||||
|
||||
// New returns the HTTP handler.
|
||||
@@ -37,26 +41,44 @@ type Server struct {
|
||||
// slot's code is assembled and sealed in first)
|
||||
// GET /slots, GET/PUT/DELETE /slots/{n} the sixteen code slots (1-3 are read-only examples)
|
||||
// PUT /ships/{id}/uplink raw bytes (<= comm size) (replaces any queued uplink)
|
||||
// GET /ships/{id}/downlink raw bytes, X-Downlink-Day header
|
||||
// POST /ships/{id}/scrap scrap a ship in the belt (on the next run it vanishes and a new ship
|
||||
// appears in your inventory)
|
||||
// GET /ships/{id}/downlink raw bytes, X-Downlink-Day header; ?day=N picks an earlier one
|
||||
// GET /ships/{id}/downlinks {"days":[...]} the days with a stored downlink
|
||||
//
|
||||
// Authenticated routes take "Authorization: Bearer <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}
|
||||
for _, o := range opts {
|
||||
o(s)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /register", s.register)
|
||||
mux.HandleFunc("GET /market", s.market)
|
||||
mux.HandleFunc("GET /info", s.info)
|
||||
mux.HandleFunc("POST /assemble", s.assemble)
|
||||
mux.Handle("POST /login", s.auth(s.playerLogin))
|
||||
mux.Handle("GET /me", s.auth(s.me))
|
||||
mux.Handle("GET /ships", s.auth(s.ships))
|
||||
mux.Handle("PUT /ships/{id}/program", s.auth(s.program))
|
||||
mux.Handle("POST /ships/{id}/launch", s.auth(s.launch))
|
||||
mux.Handle("POST /ships/{id}/scrap", s.auth(s.scrap))
|
||||
mux.HandleFunc("POST /admin/api/login", s.adminLogin)
|
||||
mux.HandleFunc("POST /admin/api/logout", s.adminLogout)
|
||||
mux.Handle("GET /admin/api/session", s.adminOnly(s.adminSession))
|
||||
mux.Handle("GET /admin/api/accounts", s.adminOnly(s.adminAccounts))
|
||||
mux.Handle("GET /admin/api/accounts/{id}", s.adminOnly(s.adminAccount))
|
||||
mux.Handle("GET /admin/api/accounts/{id}/slots/{n}", s.adminOnly(s.adminSlotSource))
|
||||
mux.Handle("GET /admin/api/audit", s.adminOnly(s.adminAudit))
|
||||
mux.Handle("GET /admin/api/map", s.adminOnly(s.adminMap))
|
||||
mux.Handle("GET /admin/api/map/orbit", s.adminOnly(s.adminMapOrbit)) // ?ship=N: its predicted coasting path
|
||||
mux.Handle("GET /slots", s.auth(s.listSlots))
|
||||
mux.Handle("GET /slots/{n}", s.auth(s.getSlot))
|
||||
mux.Handle("PUT /slots/{n}", s.auth(s.putSlot))
|
||||
mux.Handle("DELETE /slots/{n}", s.auth(s.deleteSlot))
|
||||
mux.Handle("PUT /ships/{id}/uplink", s.auth(s.uplink))
|
||||
mux.Handle("GET /ships/{id}/downlink", s.auth(s.downlink))
|
||||
mux.Handle("GET /ships/{id}/downlinks", s.auth(s.downlinks))
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -117,9 +139,25 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, p.ID, "register", ship, 0, map[string]any{"name": p.Name})
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"player_id": p.ID, "api_key": key, "ship_id": ship})
|
||||
}
|
||||
|
||||
// playerLogin is what the sign-in form calls once it has been given an access key:
|
||||
// it checks the key, returns the account, and puts the sign-in in the audit log.
|
||||
// (Every other request is authenticated by the key alone, so it is not a "login".)
|
||||
func (s *Server) playerLogin(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
s.audit(r, p.ID, "login", 0, 0, map[string]any{"user_agent": trunc(r.UserAgent(), 200)})
|
||||
s.me(w, r, p)
|
||||
}
|
||||
|
||||
func trunc(s string, n int) string {
|
||||
if r := []rune(s); len(r) > n {
|
||||
return string(r[:n])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) currentDay(ctx context.Context) int64 {
|
||||
v, ok, _ := s.st.GetMeta(ctx, runner.MetaDay)
|
||||
if !ok {
|
||||
@@ -240,6 +278,7 @@ func (s *Server) program(w http.ResponseWriter, r *http.Request, p store.Player)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, p.ID, "program_upload", id, 0, map[string]any{"bytes": len(b), "sha256": sha256Hex(b)})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -271,6 +310,20 @@ func (s *Server) launch(w http.ResponseWriter, r *http.Request, p store.Player)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, p.ID, "launch", id, 0, map[string]any{"program": "already sealed in the ship"})
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
|
||||
func (s *Server) scrap(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
id, ok := shipID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.st.Scrap(r.Context(), p.ID, id); err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, p.ID, "scrap", id, 0, nil)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
|
||||
@@ -291,15 +344,52 @@ func (s *Server) uplink(w http.ResponseWriter, r *http.Request, p store.Player)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
// The whole message is kept: it is at most one kilobyte.
|
||||
s.audit(r, p.ID, "uplink", id, 0, map[string]any{"bytes": len(b), "sha256": sha256Hex(b), "hex": hex.EncodeToString(b)})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) downlinks(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
id, ok := shipID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, _, err := s.st.Downlink(r.Context(), p.ID, id); err != nil {
|
||||
fail(w, err) // not this player's ship
|
||||
return
|
||||
}
|
||||
days, err := s.st.DownlinkDays(r.Context(), p.ID, id)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"days": days})
|
||||
}
|
||||
|
||||
func (s *Server) downlink(w http.ResponseWriter, r *http.Request, p store.Player) {
|
||||
id, ok := shipID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, day, err := s.st.Downlink(r.Context(), p.ID, id)
|
||||
var (
|
||||
data []byte
|
||||
day int64
|
||||
err error
|
||||
)
|
||||
if q := r.URL.Query().Get("day"); q != "" {
|
||||
day, err = strconv.ParseInt(q, 10, 64)
|
||||
if err != nil || day < 0 {
|
||||
httpError(w, http.StatusBadRequest, "day must be a non-negative integer")
|
||||
return
|
||||
}
|
||||
data, err = s.st.DownlinkAt(r.Context(), p.ID, id, day)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpError(w, http.StatusNotFound, "no downlink for that day")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
data, day, err = s.st.Downlink(r.Context(), p.ID, id)
|
||||
}
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
|
||||
@@ -118,6 +118,7 @@ func TestEndToEnd(t *testing.T) {
|
||||
if b := do("GET", ship+"/downlink", nil, 200); len(b) != cfg.CommBytes || string(b[:4]) != "PING" {
|
||||
t.Fatalf("downlink = %q", b[:8])
|
||||
}
|
||||
do("GET", ship+"/downlinks", nil, 200)
|
||||
var ships []struct{ Status string }
|
||||
json.Unmarshal(do("GET", "/ships", nil, 200), &ships)
|
||||
if len(ships) != 1 || ships[0].Status != store.StatusActive {
|
||||
@@ -131,6 +132,20 @@ func TestEndToEnd(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Both days' downlinks are kept: day 0 carries PING.
|
||||
var hist struct{ Days []int64 }
|
||||
json.Unmarshal(do("GET", ship+"/downlinks", nil, 200), &hist)
|
||||
if len(hist.Days) != 2 || hist.Days[0] != 0 || hist.Days[1] != 1 {
|
||||
t.Fatalf("downlink days = %v", hist.Days)
|
||||
}
|
||||
if b := do("GET", ship+"/downlink?day=0", nil, 200); string(b[:4]) != "PING" {
|
||||
t.Fatalf("day 0 downlink = %q", b[:8])
|
||||
}
|
||||
do("GET", ship+"/downlink?day=1", nil, 200)
|
||||
do("GET", ship+"/downlink?day=7", nil, 404)
|
||||
do("GET", ship+"/downlink?day=x", nil, 400)
|
||||
do("GET", ship+"/downlink?day=-1", nil, 400)
|
||||
|
||||
mem := sim.NewState(cfg)
|
||||
in := sim.DayInput{
|
||||
Launches: []sim.Launch{{ShipID: reg.ShipID, Owner: 1, Program: prog}},
|
||||
@@ -147,6 +162,36 @@ func TestEndToEnd(t *testing.T) {
|
||||
t.Fatal("state after DB round-trip differs from in-memory run")
|
||||
}
|
||||
|
||||
// Scrapping: only a ship in the belt, effective on the next run, which also hands out a new ship.
|
||||
var reg2 struct {
|
||||
APIKey string `json:"api_key"`
|
||||
ShipID int64 `json:"ship_id"`
|
||||
}
|
||||
json.Unmarshal(do("POST", "/register", []byte(`{"name":"bob"}`), 201), ®2)
|
||||
do("POST", "/ships/"+itoa(reg2.ShipID)+"/scrap", nil, 404) // ann cannot scrap bob's ship
|
||||
do("POST", "/ships/9999/scrap", nil, 404)
|
||||
key = reg2.APIKey
|
||||
do("POST", ship+"/scrap", nil, 404) // bob cannot scrap ann's ship
|
||||
do("POST", "/ships/"+itoa(reg2.ShipID)+"/scrap", nil, 409) // docked, not in the belt
|
||||
key = reg.APIKey
|
||||
do("POST", ship+"/scrap", nil, 202)
|
||||
do("POST", ship+"/scrap", nil, 409) // already queued
|
||||
var fleet []struct {
|
||||
ID int64
|
||||
Status string
|
||||
}
|
||||
json.Unmarshal(do("GET", "/ships", nil, 200), &fleet)
|
||||
if len(fleet) != 1 || fleet[0].Status != store.StatusScrapping {
|
||||
t.Fatalf("fleet while scrapping = %+v", fleet)
|
||||
}
|
||||
if _, err := runner.RunNextDay(ctx, st, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
json.Unmarshal(do("GET", "/ships", nil, 200), &fleet)
|
||||
if len(fleet) != 1 || fleet[0].Status != store.StatusInventory || fleet[0].ID == reg.ShipID {
|
||||
t.Fatalf("fleet after scrapping = %+v, want one new docked ship", fleet)
|
||||
}
|
||||
|
||||
// Changing the config on an existing world is refused.
|
||||
cfg.Seed = 99
|
||||
if _, err := runner.RunNextDay(ctx, st, cfg); err == nil {
|
||||
|
||||
@@ -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
@@ -20,13 +20,13 @@ import (
|
||||
)
|
||||
|
||||
// newSite starts the API with the web front end mounted, as cmd/server does.
|
||||
func newSite(t *testing.T, manualPDF string) (*httptest.Server, config.Config) {
|
||||
srv, cfg, _ := newSiteStore(t, manualPDF)
|
||||
func newSite(t *testing.T, manualPDF string, opts ...api.Option) (*httptest.Server, config.Config) {
|
||||
srv, cfg, _ := newSiteStore(t, manualPDF, opts...)
|
||||
return srv, cfg
|
||||
}
|
||||
|
||||
// newSiteStore is newSite, also returning the database so tests can run a simulated day.
|
||||
func newSiteStore(t *testing.T, manualPDF string) (*httptest.Server, config.Config, *store.Store) {
|
||||
func newSiteStore(t *testing.T, manualPDF string, opts ...api.Option) (*httptest.Server, config.Config, *store.Store) {
|
||||
t.Helper()
|
||||
cfg := config.Default()
|
||||
dsn := "sqlite:" + filepath.Join(t.TempDir(), "t.db")
|
||||
@@ -49,7 +49,7 @@ func newSiteStore(t *testing.T, manualPDF string) (*httptest.Server, config.Conf
|
||||
if err := st.Migrate(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := api.New(st, cfg)
|
||||
mux := api.New(st, cfg, opts...)
|
||||
mux.Handle("/", web.Handler(manualPDF))
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
@@ -182,3 +182,33 @@ func TestManualPDFServedFromFileAndPickedUpLive(t *testing.T) {
|
||||
t.Fatalf("range: %d %q", rr.StatusCode, part)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPageIsServedWithSafeHeaders(t *testing.T) {
|
||||
srv, _ := newSite(t, "")
|
||||
resp, body := get(t, srv.URL+"/admin/")
|
||||
if resp.StatusCode != 200 || !strings.Contains(body, "Halcyon Admin") {
|
||||
t.Fatalf("/admin/: %d", resp.StatusCode)
|
||||
}
|
||||
if !strings.Contains(resp.Header.Get("Content-Security-Policy"), "default-src 'self'") {
|
||||
t.Error("the admin page needs the site's Content-Security-Policy")
|
||||
}
|
||||
if !strings.Contains(resp.Header.Get("X-Robots-Tag"), "noindex") || resp.Header.Get("Cache-Control") != "no-store" {
|
||||
t.Errorf("admin page headers: robots=%q cache=%q", resp.Header.Get("X-Robots-Tag"), resp.Header.Get("Cache-Control"))
|
||||
}
|
||||
for _, path := range []string{"/admin/admin.js", "/admin/admin.css"} {
|
||||
if r, _ := get(t, srv.URL+path); r.StatusCode != 200 {
|
||||
t.Errorf("%s: %d", path, r.StatusCode)
|
||||
}
|
||||
}
|
||||
// /admin (no slash) reaches the same page.
|
||||
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
r, err := client.Get(srv.URL + "/admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Body.Close()
|
||||
// The Location is relative ("admin/"), which a browser resolves to /admin/.
|
||||
if loc := r.Header.Get("Location"); r.StatusCode != 301 || (loc != "admin/" && loc != "/admin/") {
|
||||
t.Errorf("/admin: %d -> %q, want a redirect to /admin/", r.StatusCode, loc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ func (s *Server) putSlot(w http.ResponseWriter, r *http.Request, p store.Player)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, p.ID, "code_save", 0, n, map[string]any{"name": req.Name, "bytes": len(req.Source), "sha256": sha256Hex([]byte(req.Source))})
|
||||
writeJSON(w, http.StatusOK, slotView{Slot: n, Name: req.Name})
|
||||
}
|
||||
|
||||
@@ -146,6 +147,7 @@ func (s *Server) deleteSlot(w http.ResponseWriter, r *http.Request, p store.Play
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, p.ID, "code_clear", 0, n, nil)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -173,5 +175,6 @@ func (s *Server) launchFromSlot(w http.ResponseWriter, r *http.Request, p store.
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
s.audit(r, p.ID, "launch", ship, n, map[string]any{"slot_name": sl.Name, "program_bytes": len(prog), "program_sha256": sha256Hex(prog)})
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"slot": n, "program_bytes": len(prog)})
|
||||
}
|
||||
|
||||
+10
-1
@@ -35,7 +35,16 @@ func main() {
|
||||
if err := st.Migrate(context.Background()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
mux := api.New(st, *cfg)
|
||||
adminPassword := os.Getenv("HALCYON_ADMIN_PASSWORD")
|
||||
switch {
|
||||
case adminPassword == "":
|
||||
log.Print("admin interface is OFF: set HALCYON_ADMIN_PASSWORD to turn it on at /admin")
|
||||
case len(adminPassword) < 12:
|
||||
log.Print("admin interface is on at /admin, but its password is short: use at least 12 characters")
|
||||
default:
|
||||
log.Print("admin interface is on at /admin")
|
||||
}
|
||||
mux := api.New(st, *cfg, api.WithAdminPassword(adminPassword))
|
||||
mux.Handle("/", web.Handler(*manual)) // the site; API routes are more specific and win
|
||||
log.Printf("listening on %s", *addr)
|
||||
log.Fatal(http.ListenAndServe(*addr, mux))
|
||||
|
||||
@@ -24,6 +24,9 @@ services:
|
||||
HALCYON_RAM_BYTES: ${HALCYON_RAM_BYTES:-8192}
|
||||
HALCYON_COMM_BYTES: ${HALCYON_COMM_BYTES:-1024}
|
||||
HALCYON_ASTEROIDS: ${HALCYON_ASTEROIDS:-500}
|
||||
# The operator console at /admin. Set the password in the environment or in a .env
|
||||
# file next to this one (which is git-ignored). Empty leaves the console switched off.
|
||||
HALCYON_ADMIN_PASSWORD: ${HALCYON_ADMIN_PASSWORD:-}
|
||||
volumes:
|
||||
- data:/data
|
||||
- manual:/manual:ro # the PDF manual, rendered separately (see below)
|
||||
|
||||
+4
-3
@@ -24,11 +24,12 @@ var portValues = map[string]int{
|
||||
"RELVX": sim.PortScanRelVX, "RELVY": sim.PortScanRelVY, "RELVZ": sim.PortScanRelVZ,
|
||||
"DIST": sim.PortScanDist,
|
||||
"ORE0": sim.PortScanOre, "ORE1": sim.PortScanOre + 1, "ORE2": sim.PortScanOre + 2, "ORE3": sim.PortScanOre + 3,
|
||||
"MINE": sim.PortMine, "CARGO": sim.PortCargo, "CARGOCAP": sim.PortCargoCap,
|
||||
"CARGO0": sim.PortCargoOre, "CARGO1": sim.PortCargoOre + 1, "CARGO2": sim.PortCargoOre + 2, "CARGO3": sim.PortCargoOre + 3,
|
||||
"MINE": sim.PortMine, "CONTS": sim.PortConts, "CONTKG": sim.PortContKg, "CONTROOM": sim.PortContRoom, "CONTMASS": sim.PortContMass,
|
||||
"ATTACH": sim.PortAttach, "DETACH": sim.PortDetach, "NEARBY": sim.PortNearby,
|
||||
"CONTORE0": sim.PortContOre, "CONTORE1": sim.PortContOre + 1, "CONTORE2": sim.PortContOre + 2, "CONTORE3": sim.PortContOre + 3,
|
||||
"STNX": sim.PortStnRelX, "STNY": sim.PortStnRelY, "STNZ": sim.PortStnRelZ,
|
||||
"STNVX": sim.PortStnRelVX, "STNVY": sim.PortStnRelVY, "STNVZ": sim.PortStnRelVZ,
|
||||
"SELL": sim.PortSell, "EARNED": sim.PortCredits,
|
||||
"ORDER": sim.PortOrder, "EARNED": sim.PortCredits,
|
||||
"UPNEW": sim.PortUplinkNew, "UPLEN": sim.PortUplinkLen,
|
||||
"MATHX": sim.PortMathX, "MATHY": sim.PortMathY, "MATHZ": sim.PortMathZ,
|
||||
"ATAN2": sim.PortMathAtan2, "HYPOT": sim.PortMathHypot, "NORM3": sim.PortMathNorm3,
|
||||
|
||||
+144
-54
@@ -625,7 +625,7 @@ chosen by the throttle.
|
||||
0x21 AZIMUTH output Thrust direction azimuth, milliradians.
|
||||
0x22 PITCH output Thrust direction elevation, milliradians.
|
||||
0x23 FUEL input Fuel remaining, kg.
|
||||
0x24 MASS input Total ship mass (hull + fuel + cargo), kg.
|
||||
0x24 MASS input Total ship mass (hull + fuel + containers in tow), kg.
|
||||
```
|
||||
|
||||
THE ENGINE FIRES ALONG THE DIRECTION GIVEN, for the whole of the turn, at
|
||||
@@ -644,7 +644,8 @@ Thrust and fuel. For a Prospector at standard turn length:
|
||||
Full thrust ................ 6000 newtons
|
||||
Exhaust velocity ........... 30,000 m/s
|
||||
Fuel used at full throttle . 12 kg per turn (thrust x turn / exhaust vel.)
|
||||
Acceleration ............... thrust / mass, 0.33 to 0.75 m/s^2
|
||||
Acceleration ............... thrust / mass: 0.75 m/s^2 with an empty tank
|
||||
and nothing in tow, less with containers
|
||||
```
|
||||
|
||||
Fuel use is proportional to throttle. When fuel runs out the engine falls
|
||||
@@ -659,16 +660,17 @@ shortened burn.
|
||||
|
||||
The scanner tracks one asteroid at a time, which you choose by number.
|
||||
Asteroids are numbered from 1. The standard belt has 500. The scanner has
|
||||
unlimited range: every asteroid in the belt is visible from anywhere in it.
|
||||
a range of 500,000 km: an asteroid farther away than that cannot be seen.
|
||||
|
||||
```
|
||||
0x30 SELECT output Asteroid number to track. Zero, or a number that
|
||||
does not exist, clears the selection.
|
||||
0x31 NEAREST input Number of the asteroid nearest to the ship.
|
||||
0x31 NEAREST input Number of the asteroid nearest to the ship, or 0
|
||||
if none is within 500,000 km.
|
||||
```
|
||||
|
||||
The following ports describe the TRACKED asteroid and all read zero if none
|
||||
is tracked.
|
||||
is tracked, or if it is more than 500,000 km away.
|
||||
|
||||
```
|
||||
0x32 RELX input Position of the target relative to the ship, X, km
|
||||
@@ -690,43 +692,80 @@ the +X side of you. To fly towards the target, point the engine along
|
||||
(RELX, RELY, RELZ). To match its velocity, apply a thrust along
|
||||
(RELVX, RELVY, RELVZ).
|
||||
|
||||
NEAREST is decided among all asteroids by true three-dimensional distance;
|
||||
if two are exactly equally near, the lower number wins.
|
||||
NEAREST is decided among the asteroids within scanner range by true
|
||||
three-dimensional distance, and reads zero if there are none; if two are
|
||||
exactly equally near, the lower number wins.
|
||||
|
||||
> **Note:** Every asteroid in the belt moves at the same speed, 3 km/s (section
|
||||
> 7.2). For a ship travelling at that speed, the velocity of a target relative
|
||||
> to the ship is therefore decided by the difference in DIRECTION of travel, not
|
||||
> by any difference in speed.
|
||||
|
||||
## 5.7 Mining Laser and Cargo Hold
|
||||
## 5.7 Mining Laser and Containers
|
||||
|
||||
A ship has no hold. Ore travels in CONTAINERS, which the ship TOWS. A
|
||||
container weighs 500 kg empty and holds 2000 kg of ore. Containers belong to
|
||||
no one: any ship can tow any container. A container is always in one of two
|
||||
conditions:
|
||||
|
||||
- IN TOW behind a ship: it moves exactly as the ship moves.
|
||||
- ADRIFT: it moves on its own, pulled by the Star like anything else (section
|
||||
7.2). A container let go with the speed of a ship in orbit stays in orbit
|
||||
beside it. One that loses its speed falls inward, and is destroyed with its
|
||||
ore if it comes within 200,000 km of the Star. A container that stays adrift
|
||||
for TEN DAYS disappears, with whatever is in it, so containers that are lost
|
||||
cannot pile up. While a container is in tow its days do not count.
|
||||
|
||||
```
|
||||
0x50 MINE output Non-zero: mine the tracked asteroid. Zero: stop.
|
||||
0x51 CARGO input Total mass in the hold, kg.
|
||||
0x52 CARGOCAP input Capacity of the hold, kg (Prospector: 6000).
|
||||
0x58 CARGO0 input Iron in the hold, kg
|
||||
0x59 CARGO1 input Nickel in the hold, kg
|
||||
0x5A CARGO2 input Ice in the hold, kg
|
||||
0x5B CARGO3 input Platinum in the hold, kg
|
||||
0x51 CONTS input Number of containers in tow.
|
||||
0x52 CONTKG input Ore in the containers in tow, kg.
|
||||
0x53 CONTROOM input Room left in the containers in tow, kg.
|
||||
0x54 CONTMASS input Mass of the containers in tow, ore included, kg.
|
||||
0x55 ATTACH output Non-zero: take the containers near the ship in tow.
|
||||
0x56 DETACH output Non-zero: let go of every container in tow.
|
||||
0x57 NEARBY input How many drifting containers ATTACH would take.
|
||||
0x58 CONTORE0 input Iron in the containers in tow, kg
|
||||
0x59 CONTORE1 input Nickel in the containers in tow, kg
|
||||
0x5A CONTORE2 input Ice in the containers in tow, kg
|
||||
0x5B CONTORE3 input Platinum in the containers in tow, kg
|
||||
(0x5C-0x5F exist for future ores and read zero.)
|
||||
```
|
||||
|
||||
The laser stays on, once MINE has been set, until MINE is set to zero. In
|
||||
each turn in which ALL of the following are true, it moves ore from the
|
||||
asteroid into the hold:
|
||||
MINING. The laser stays on, once MINE has been set, until MINE is set to zero.
|
||||
In each turn in which ALL of the following are true, it moves ore from the
|
||||
asteroid into the containers in tow:
|
||||
|
||||
- an asteroid is tracked and still has ore;
|
||||
- the asteroid is within 5 km of the ship;
|
||||
- the ship's velocity relative to the asteroid is at most 100 m/s;
|
||||
- the hold is not full.
|
||||
- at least one container in tow has room.
|
||||
|
||||
The Prospector mines 10 kg per turn. The ore taken is a mixture in the same
|
||||
proportions as the asteroid's remaining ore. You cannot choose what to mine.
|
||||
The conditions are tested at the END of the turn, after the ship has moved.
|
||||
It goes into the containers in the order they were made, each filled before the
|
||||
next. The conditions are tested at the END of the turn, after the ship has
|
||||
moved. With nothing in tow, or every container full, the laser does nothing.
|
||||
|
||||
To fill an empty hold takes 600 turns, 10 hours of ship time.
|
||||
One container takes 200 turns, a little over three hours of ship time, to fill.
|
||||
|
||||
Ore has to be brought to the Station before it is worth anything. An asteroid
|
||||
ATTACH. Writing a non-zero value to ATTACH takes in tow every container that
|
||||
is adrift and near the ship: within 10 km, and moving at most 100 m/s relative
|
||||
to it. It makes no difference who ordered a container or whose ship last
|
||||
towed it. A container that another ship is towing is left alone. Attached
|
||||
containers move at once to the ship. Reading NEARBY beforehand says how many
|
||||
ATTACH would take.
|
||||
|
||||
DETACH. Writing a non-zero value to DETACH lets go of every container in tow.
|
||||
Each carries on drifting with the velocity the ship had at that moment, and
|
||||
its ten days start from then.
|
||||
|
||||
MASS. Everything in tow counts towards the ship's mass, empty containers as
|
||||
well as the ore in them, and the engine's thrust does not change. Acceleration
|
||||
therefore falls as the tow grows: see section 7.1. MASS (section 5.5) reports
|
||||
the total.
|
||||
|
||||
Ore is worth nothing until it reaches the Station (section 5.8). An asteroid
|
||||
does not replenish.
|
||||
|
||||
> **Note:** The scanner reports whole kilometres (rounded down). The range to
|
||||
@@ -746,21 +785,35 @@ ship is launched from it.
|
||||
0x63 STNVX input Velocity of the Station relative to the ship, X, m/s
|
||||
0x64 STNVY input ... Y, m/s
|
||||
0x65 STNVZ input ... Z, m/s
|
||||
0x66 SELL output Non-zero: sell the entire hold.
|
||||
0x66 ORDER output 1 to 10: the Station delivers that many empty containers.
|
||||
0x67 EARNED input Credits earned by this ship so far
|
||||
```
|
||||
|
||||
As with the scanner, relative values are STATION MINUS SHIP.
|
||||
|
||||
Writing a non-zero value to SELL sells everything in the hold at once, if
|
||||
ALL of the following are true, and otherwise does nothing (there is no error
|
||||
signal):
|
||||
SELLING IS AUTOMATIC. At the start of every turn the Station buys every
|
||||
container that is ADRIFT within 20 km of it and has ore in it, and pays for the
|
||||
ore at the day's prices. THE STATION TAKES THE CONTAINER WITH THE ORE: a sold
|
||||
container disappears. Empty containers are not bought. Containers in tow are
|
||||
never touched: to sell, detach near the Station.
|
||||
|
||||
- the ship is within 20 km of the Station;
|
||||
- its velocity relative to the Station is at most 100 m/s;
|
||||
- the hold is not empty.
|
||||
The credits go to the player whose ship last let go of the container, and to
|
||||
EARNED of that ship. Containers have no owner, so ore left adrift belongs to
|
||||
whoever brings it to the Station first.
|
||||
|
||||
The hold is emptied, the credits go to your account, and EARNED goes up.
|
||||
ORDERING CONTAINERS. When the ship is within 20 km of the Station, writing a
|
||||
number from 1 to 10 to ORDER has the Station deliver that many EMPTY containers
|
||||
to the ship: they appear at the ship's position, adrift with the ship's own
|
||||
velocity, ready for ATTACH. Larger numbers act as 10; zero or less does
|
||||
nothing. The Station will not deliver more once 50 containers that you ordered
|
||||
still exist. A sale takes containers away, and so does the ten-day rule
|
||||
(section 5.7), so containers you lose never leave you stuck.
|
||||
Away from the Station ORDER does nothing, and there is no error signal.
|
||||
Containers are free.
|
||||
|
||||
A round trip is therefore: order containers, attach them, fly to an asteroid,
|
||||
mine until they are full, fly back, detach near the Station (which takes the
|
||||
containers and pays for the ore), and order fresh ones for the next trip.
|
||||
|
||||
PRICES. Each ore has a standard price per kilogram. The price FALLS as ore
|
||||
floods the market and recovers as it is used up. Prices are set once per day
|
||||
@@ -849,13 +902,14 @@ Sample program 3 (Chapter 9) does exactly this.
|
||||
|
||||
## 6.1 Life Cycle of a Ship
|
||||
|
||||
A ship is always in one of four conditions.
|
||||
A ship is always in one of five conditions.
|
||||
|
||||
```
|
||||
INVENTORY Built and waiting on the dock. It can be launched.
|
||||
LAUNCHING Cleared to launch. It will enter the belt on the next daily run.
|
||||
Its program is sealed and can no longer be changed.
|
||||
ACTIVE In the belt.
|
||||
SCRAPPING Ordered to be scrapped. It is removed on the next daily run.
|
||||
DESTROYED Lost. It cannot be recovered.
|
||||
```
|
||||
|
||||
@@ -890,7 +944,9 @@ produce the same day, bit for bit. There is no luck in it.
|
||||
|
||||
Order of events within the run:
|
||||
|
||||
1. Ships waiting to launch are placed at the Station.
|
||||
1. Ships waiting to launch are placed at the Station. Ships being scrapped
|
||||
are removed; any containers they tow are left adrift, and each owner is
|
||||
given a new ship, in INVENTORY, with no program.
|
||||
2. Uplinks are delivered to their ships.
|
||||
3. For each turn of the day, for each ship in order of ship number:
|
||||
a. the computer runs until it yields or exhausts its budget;
|
||||
@@ -947,9 +1003,18 @@ DELETE /slots/{n} Empty slot 4 to 16.
|
||||
PUT /ships/{n}/uplink Body: up to 1024 raw bytes. Replaces any
|
||||
message queued for the next run. Allowed while LAUNCHING or ACTIVE.
|
||||
|
||||
POST /ships/{n}/scrap Scrap a ship that is ACTIVE (409 otherwise).
|
||||
On the next run the ship is removed from the belt and you receive a
|
||||
new ship in INVENTORY. Its program and fuel are lost.
|
||||
|
||||
GET /ships/{n}/downlink The latest downlink, exactly 1024 raw
|
||||
bytes. The header X-Downlink-Day tells you which day's run
|
||||
produced it. A ship's first downlink appears after its first day.
|
||||
With ?day=D you get the downlink of that earlier day instead (404
|
||||
if there is none).
|
||||
|
||||
GET /ships/{n}/downlinks {"days":[0,1,2,...]}: the days that have a
|
||||
stored downlink, oldest first. Every day's downlink is kept.
|
||||
```
|
||||
|
||||
Typical use:
|
||||
@@ -978,13 +1043,23 @@ request, 413 when your data is too large, 400 for malformed data.
|
||||
```
|
||||
Dry mass ............................. 8000 kg
|
||||
Fuel tank ............................ 4000 kg
|
||||
Hold ................................. 6000 kg
|
||||
Total mass, full tank, empty hold .... 12,000 kg
|
||||
Total mass, empty tank, full hold .... 14,000 kg
|
||||
Cargo hold ........................... none: ore travels in containers
|
||||
Container, empty ..................... 500 kg (holds 2000 kg of ore)
|
||||
Container, full ...................... 2500 kg
|
||||
Total mass, full tank, nothing in tow 12,000 kg
|
||||
Total mass, empty tank, nothing in tow 8,000 kg
|
||||
Engine ............................... 6000 N, exhaust velocity 30 km/s
|
||||
Mining laser ......................... 10 kg per turn
|
||||
```
|
||||
|
||||
Acceleration is thrust divided by the total mass, containers in tow included:
|
||||
|
||||
```
|
||||
full tank, nothing in tow ............ 6000 / 12,000 = 0.50 m/s^2
|
||||
full tank, 3 full containers ......... 6000 / 19,500 = 0.31 m/s^2
|
||||
empty tank, nothing in tow ........... 6000 / 8,000 = 0.75 m/s^2
|
||||
```
|
||||
|
||||
The ship is a point. It has no orientation of its own; the engine simply
|
||||
pushes in whatever direction the two angles command. There are no
|
||||
thrusters, no spin, no fuel used to rotate.
|
||||
@@ -1043,7 +1118,7 @@ The total change of velocity available from one tank is given by
|
||||
dv = exhaust velocity x ln( mass before / mass after )
|
||||
```
|
||||
|
||||
For a Prospector with an empty hold and full tank:
|
||||
For a Prospector with nothing in tow and a full tank:
|
||||
|
||||
```
|
||||
30 km/s x ln( 12000 / 8000 ) = about 12.2 km/s
|
||||
@@ -1051,7 +1126,8 @@ For a Prospector with an empty hold and full tank:
|
||||
|
||||
That is roughly four times the orbit speed. Nothing is ever free: every
|
||||
metre per second gained going out must be paid again to slow down, and every
|
||||
kilogram of ore carried home makes the ship harder to reverse. The
|
||||
kilogram of ore towed home, and every empty container towed out for it,
|
||||
makes the ship harder to reverse. The
|
||||
engine at full power uses the whole tank in 333 turns, a little over five
|
||||
hours of ship time, and yet the day is 24. Plan accordingly.
|
||||
|
||||
@@ -1318,14 +1394,19 @@ SCANNER
|
||||
0x42 ORE2 in ice in target kg
|
||||
0x43 ORE3 in platinum in target kg
|
||||
|
||||
MINING AND HOLD
|
||||
MINING AND CONTAINERS
|
||||
0x50 MINE out non-zero = laser on, latched
|
||||
0x51 CARGO in hold contents kg
|
||||
0x52 CARGOCAP in hold capacity kg
|
||||
0x58 CARGO0 in iron in hold kg
|
||||
0x59 CARGO1 in nickel in hold kg
|
||||
0x5A CARGO2 in ice in hold kg
|
||||
0x5B CARGO3 in platinum in hold kg
|
||||
0x51 CONTS in containers in tow
|
||||
0x52 CONTKG in ore in the containers in tow kg
|
||||
0x53 CONTROOM in room left in the containers in tow kg
|
||||
0x54 CONTMASS in mass of the containers in tow kg
|
||||
0x55 ATTACH out non-zero = tow the containers nearby
|
||||
0x56 DETACH out non-zero = let go of everything in tow
|
||||
0x57 NEARBY in drifting containers ATTACH would take
|
||||
0x58 CONTORE0 in iron in tow kg
|
||||
0x59 CONTORE1 in nickel in tow kg
|
||||
0x5A CONTORE2 in ice in tow kg
|
||||
0x5B CONTORE3 in platinum in tow kg
|
||||
|
||||
STATION
|
||||
0x60 STNX in station minus ship, X km
|
||||
@@ -1334,7 +1415,7 @@ STATION
|
||||
0x63 STNVX in station velocity minus ship, X m/s
|
||||
0x64 STNVY in ... Y m/s
|
||||
0x65 STNVZ in ... Z m/s
|
||||
0x66 SELL out non-zero = sell hold (if docked)
|
||||
0x66 ORDER out 1 to 10 = station delivers empty containers
|
||||
0x67 EARNED in credits earned by this ship
|
||||
|
||||
WORMHOLE LINK
|
||||
@@ -1392,19 +1473,24 @@ Paste this at the top of any program. Unused equates cost nothing.
|
||||
.equ P_ORE2 0x42
|
||||
.equ P_ORE3 0x43
|
||||
.equ P_MINE 0x50
|
||||
.equ P_CARGO 0x51
|
||||
.equ P_CARGOCAP 0x52
|
||||
.equ P_CARGO0 0x58
|
||||
.equ P_CARGO1 0x59
|
||||
.equ P_CARGO2 0x5A
|
||||
.equ P_CARGO3 0x5B
|
||||
.equ P_CONTS 0x51
|
||||
.equ P_CONTKG 0x52
|
||||
.equ P_CONTROOM 0x53
|
||||
.equ P_CONTMASS 0x54
|
||||
.equ P_ATTACH 0x55
|
||||
.equ P_DETACH 0x56
|
||||
.equ P_NEARBY 0x57
|
||||
.equ P_CONTORE0 0x58
|
||||
.equ P_CONTORE1 0x59
|
||||
.equ P_CONTORE2 0x5A
|
||||
.equ P_CONTORE3 0x5B
|
||||
.equ P_STNX 0x60
|
||||
.equ P_STNY 0x61
|
||||
.equ P_STNZ 0x62
|
||||
.equ P_STNVX 0x63
|
||||
.equ P_STNVY 0x64
|
||||
.equ P_STNVZ 0x65
|
||||
.equ P_SELL 0x66
|
||||
.equ P_ORDER 0x66
|
||||
.equ P_EARNED 0x67
|
||||
.equ P_UPNEW 0x70
|
||||
.equ P_UPLEN 0x71
|
||||
@@ -1463,13 +1549,17 @@ program (HALT), commanding a throttle greater than 1000 (clamped).
|
||||
| 10-12 pos km 13-15 vel m/s |
|
||||
| 20 throttle 0-1000 21 azimuth 22 pitch 23 fuel 24 mass |
|
||||
| 30 select 31 nearest 32-34 rel pos 35-37 rel vel 38 dist 40-43 ore |
|
||||
| 50 mine 51 cargo 52 cap 58-5B cargo by ore |
|
||||
| 60-62 stn pos 63-65 stn vel 66 sell 67 earned |
|
||||
| 50 mine 51 conts 52 ore 53 room 54 mass 55 attach 56 detach |
|
||||
| 57 nearby 58-5B ore by type in tow |
|
||||
| 60-62 stn pos 63-65 stn vel 66 order 67 earned |
|
||||
| 70 uplink flag / ack 71 uplink length |
|
||||
| 80 x 81 y 82 z 83 atan2 84 hypot 85 norm3 |
|
||||
+------------------------------------------------------------------------------+
|
||||
| MINE within 5 km, rel speed <= 100 m/s 10 kg/turn |
|
||||
| SELL within 20 km, rel speed <= 100 m/s whole hold |
|
||||
| ATTACH containers within 10 km, rel speed <= 100 m/s |
|
||||
| SALE drifting containers with ore within 20 km: taken and paid |
|
||||
| ORDER 1-10 empty containers, near the Station (max 50 ordered) |
|
||||
| ADRIFT a container adrift for 10 days vanishes, ore and all |
|
||||
| STAR a = v^2/r, v = 3 km/s. Inside 200,000 km = destroyed. |
|
||||
+------------------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
@@ -33,19 +33,24 @@
|
||||
.equ P_ORE2 0x42
|
||||
.equ P_ORE3 0x43
|
||||
.equ P_MINE 0x50
|
||||
.equ P_CARGO 0x51
|
||||
.equ P_CARGOCAP 0x52
|
||||
.equ P_CARGO0 0x58
|
||||
.equ P_CARGO1 0x59
|
||||
.equ P_CARGO2 0x5A
|
||||
.equ P_CARGO3 0x5B
|
||||
.equ P_CONTS 0x51
|
||||
.equ P_CONTKG 0x52
|
||||
.equ P_CONTROOM 0x53
|
||||
.equ P_CONTMASS 0x54
|
||||
.equ P_ATTACH 0x55
|
||||
.equ P_DETACH 0x56
|
||||
.equ P_NEARBY 0x57
|
||||
.equ P_CONTORE0 0x58
|
||||
.equ P_CONTORE1 0x59
|
||||
.equ P_CONTORE2 0x5A
|
||||
.equ P_CONTORE3 0x5B
|
||||
.equ P_STNX 0x60
|
||||
.equ P_STNY 0x61
|
||||
.equ P_STNZ 0x62
|
||||
.equ P_STNVX 0x63
|
||||
.equ P_STNVY 0x64
|
||||
.equ P_STNVZ 0x65
|
||||
.equ P_SELL 0x66
|
||||
.equ P_ORDER 0x66
|
||||
.equ P_EARNED 0x67
|
||||
.equ P_UPNEW 0x70
|
||||
.equ P_UPLEN 0x71
|
||||
|
||||
@@ -55,6 +55,9 @@ func RunNextDay(ctx context.Context, s *store.Store, cfg config.Config) (sim.Day
|
||||
if in.Launches, err = q.PendingLaunches(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Scraps, err = q.PendingScraps(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Uplinks, err = q.PendingUplinks(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+30
-22
@@ -94,12 +94,13 @@ func (b *shipBus) In(port uint16) int32 {
|
||||
case port == PortFuel:
|
||||
return sat32(sh.Fuel.Floor())
|
||||
case port == PortMass:
|
||||
return sat32(sh.Mass().Floor())
|
||||
return sat32(tc.s.shipMass(sh).Floor())
|
||||
case port == PortScanNearest:
|
||||
scanRange := fixed.FromInt(ScanRangeKm)
|
||||
best, bestD := int64(0), fixed.Max
|
||||
for i := range tc.s.Asteroids {
|
||||
p, _ := tc.asteroid(i)
|
||||
if d := p.Sub(sh.Pos).Len(); d < bestD {
|
||||
if d := p.Sub(sh.Pos).Len(); d <= scanRange && d < bestD {
|
||||
best, bestD = int64(i+1), d
|
||||
}
|
||||
}
|
||||
@@ -110,6 +111,9 @@ func (b *shipBus) In(port uint16) int32 {
|
||||
return 0
|
||||
}
|
||||
rel, relV := p.Sub(sh.Pos), v.Sub(sh.Vel)
|
||||
if rel.Len() > fixed.FromInt(ScanRangeKm) {
|
||||
return 0
|
||||
}
|
||||
switch {
|
||||
case port <= PortScanRelZ:
|
||||
return axis(rel, port, PortScanRelX, kmInt)
|
||||
@@ -119,16 +123,25 @@ func (b *shipBus) In(port uint16) int32 {
|
||||
return kmInt(rel.Len())
|
||||
case port >= PortScanOre && port < PortScanOre+8:
|
||||
if a := tc.s.asteroid(sh.Target); a != nil && port-PortScanOre < uint16(world.NumOre) {
|
||||
if p, _, _ := b.target(); p.Sub(sh.Pos).Len() > fixed.FromInt(ScanRangeKm) {
|
||||
return 0
|
||||
}
|
||||
return sat32(a.Ore[port-PortScanOre])
|
||||
}
|
||||
return 0
|
||||
case port == PortCargo:
|
||||
return sat32(sh.CargoTotal())
|
||||
case port == PortCargoCap:
|
||||
return sat32(sh.Hull.CargoCap)
|
||||
case port >= PortCargoOre && port < PortCargoOre+8:
|
||||
if port-PortCargoOre < uint16(world.NumOre) {
|
||||
return sat32(sh.Cargo[port-PortCargoOre])
|
||||
case port == PortConts:
|
||||
return int32(tc.s.towOf(sh.ID).Count)
|
||||
case port == PortContKg:
|
||||
return sat32(tc.s.towOf(sh.ID).OreTotal)
|
||||
case port == PortContRoom:
|
||||
return sat32(tc.s.towOf(sh.ID).Room)
|
||||
case port == PortContMass:
|
||||
return sat32(tc.s.towOf(sh.ID).Mass)
|
||||
case port == PortNearby:
|
||||
return int32(tc.s.nearbyFree(sh))
|
||||
case port >= PortContOre && port < PortContOre+8:
|
||||
if port-PortContOre < uint16(world.NumOre) {
|
||||
return sat32(tc.s.towOf(sh.ID).Ore[port-PortContOre])
|
||||
}
|
||||
return 0
|
||||
case port >= PortStnRelX && port <= PortStnRelVZ:
|
||||
@@ -170,21 +183,16 @@ func (b *shipBus) Out(port uint16, v int32) {
|
||||
sh.Target = int64(v)
|
||||
case PortMine:
|
||||
sh.Mining = v != 0
|
||||
case PortSell:
|
||||
if v == 0 || !tc.s.canReach(sh, tc.stPos, tc.stVel, DockRangeKm) {
|
||||
return
|
||||
case PortAttach:
|
||||
if v != 0 {
|
||||
tc.s.attach(sh, tc)
|
||||
}
|
||||
value := tc.s.Market.Value(sh.Cargo)
|
||||
if value == 0 {
|
||||
return
|
||||
case PortDetach:
|
||||
if v != 0 {
|
||||
tc.s.detach(sh, tc)
|
||||
}
|
||||
tc.s.Credits[sh.Owner] += value
|
||||
sh.Earned += value
|
||||
for o, kg := range sh.Cargo {
|
||||
tc.sold[o] += kg
|
||||
sh.Cargo[o] = 0
|
||||
}
|
||||
tc.res.Events = append(tc.res.Events, Event{tc.tick, sh.ID, "sold", itoa(value) + " credits"})
|
||||
case PortOrder:
|
||||
tc.s.order(sh, int(v), tc)
|
||||
case PortUplinkNew:
|
||||
sh.UplinkNew = false
|
||||
case PortMathX:
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -34,11 +34,16 @@ func (s *State) Hash() [32]byte {
|
||||
w(sh.ID, sh.Owner, alive, int64(sh.Pos.X), int64(sh.Pos.Y), int64(sh.Pos.Z),
|
||||
int64(sh.Vel.X), int64(sh.Vel.Y), int64(sh.Vel.Z),
|
||||
int64(sh.Fuel), sh.Earned, int64(sh.Throttle), int64(sh.Azimuth), int64(sh.Pitch), sh.Target)
|
||||
for _, c := range sh.Cargo {
|
||||
w(c)
|
||||
}
|
||||
h.Write(sh.CPU.MarshalState())
|
||||
}
|
||||
w(int64(len(s.Containers)), s.NextContainer)
|
||||
for _, c := range s.Containers {
|
||||
w(c.ID, c.OrderedBy, int64(c.Pos.X), int64(c.Pos.Y), int64(c.Pos.Z),
|
||||
int64(c.Vel.X), int64(c.Vel.Y), int64(c.Vel.Z), c.Towed, c.Dropped, c.ExpiresAt)
|
||||
for _, o := range c.Ore {
|
||||
w(o)
|
||||
}
|
||||
}
|
||||
owners := make([]int64, 0, len(s.Credits))
|
||||
for o := range s.Credits {
|
||||
owners = append(owners, o)
|
||||
|
||||
+10
-5
@@ -37,11 +37,16 @@ const (
|
||||
PortScanDist = 0x38 // in: distance to target, km
|
||||
PortScanOre = 0x40 // in: PortScanOre+ore = kg of ore remaining (8 ports)
|
||||
|
||||
// Mining laser and cargo hold.
|
||||
// Mining laser and the containers in tow.
|
||||
PortMine = 0x50 // out: 1 to mine the selected asteroid, 0 to stop
|
||||
PortCargo = 0x51 // in: total cargo kg
|
||||
PortCargoCap = 0x52 // in: cargo capacity kg
|
||||
PortCargoOre = 0x58 // in: PortCargoOre+ore = kg of ore carried (8 ports)
|
||||
PortConts = 0x51 // in: number of containers in tow
|
||||
PortContKg = 0x52 // in: kg of ore in the containers in tow
|
||||
PortContRoom = 0x53 // in: kg of ore the containers in tow can still take
|
||||
PortContMass = 0x54 // in: mass of the containers in tow, ore included, kg
|
||||
PortAttach = 0x55 // out: non-zero: take every one of your containers near the ship in tow
|
||||
PortDetach = 0x56 // out: non-zero: let go of every container in tow
|
||||
PortNearby = 0x57 // in: how many of your drifting containers are near enough to attach
|
||||
PortContOre = 0x58 // in: PortContOre+ore = kg of that ore in the containers in tow (8 ports)
|
||||
|
||||
// Dropoff station.
|
||||
PortStnRelX = 0x60 // in: station position relative to ship, km
|
||||
@@ -50,7 +55,7 @@ const (
|
||||
PortStnRelVX = 0x63 // in: station velocity relative to ship, m/s
|
||||
PortStnRelVY = 0x64
|
||||
PortStnRelVZ = 0x65
|
||||
PortSell = 0x66 // out: 1 to sell all cargo (needs to be docked)
|
||||
PortOrder = 0x66 // out: 1..10: the station delivers that many empty containers to the ship (near the station)
|
||||
PortCredits = 0x67 // in: credits earned by this ship (saturating)
|
||||
|
||||
// Comms buffers live in RAM; these ports coordinate them.
|
||||
|
||||
+66
-37
@@ -7,6 +7,7 @@ import (
|
||||
"halcyon/config"
|
||||
"halcyon/fixed"
|
||||
"halcyon/vm"
|
||||
"halcyon/world"
|
||||
)
|
||||
|
||||
func testCfg() config.Config {
|
||||
@@ -147,43 +148,6 @@ func TestInclinedOrbitLeavesPlane(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMineAndSell(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
s := NewState(cfg)
|
||||
// Sit on asteroid 1, matching its motion for the whole first tick, and mine.
|
||||
pos, vel := s.Asteroids[0].Orbit.State(0)
|
||||
sh := addShip(t, s, cfg, 1, mustAsm(t, `
|
||||
ldi r0, 1
|
||||
out 0x30, r0 ; select asteroid 1
|
||||
out 0x50, r0 ; mine
|
||||
yield
|
||||
jmp -2
|
||||
`), pos, vel)
|
||||
if _, err := s.RunDay(cfg, DayInput{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Gravity acts on the ship differently than the rails, so it soon drifts
|
||||
// out of range; it should still have mined something at the start.
|
||||
if sh.CargoTotal() == 0 {
|
||||
t.Fatal("nothing mined")
|
||||
}
|
||||
|
||||
// Now dock at the station and sell.
|
||||
s2 := NewState(cfg)
|
||||
sp, sv := s2.Station.Orbit.State(0)
|
||||
sh2 := addShip(t, s2, cfg, 1, mustAsm(t, "ldi r0, 1\n out 0x66, r0\n halt"), sp, sv)
|
||||
sh2.Cargo[0] = 1000 // iron
|
||||
if _, err := s2.RunDay(cfg, DayInput{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s2.Credits[1] != 2000 || sh2.CargoTotal() != 0 {
|
||||
t.Fatalf("credits=%d cargo=%d", s2.Credits[1], sh2.CargoTotal())
|
||||
}
|
||||
if s2.Market.Prices[0] >= 2 && s2.Market.Supply[0] != 1000 {
|
||||
t.Fatalf("market not updated: %+v", s2.Market)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommsBuffers(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
s := NewState(cfg)
|
||||
@@ -209,3 +173,68 @@ func TestCommsBuffers(t *testing.T) {
|
||||
t.Fatalf("downlink = %q", res.Downlinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScannerRange(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
s := NewState(cfg)
|
||||
stPos, stVel := s.Station.Orbit.State(0)
|
||||
sh := addShip(t, s, cfg, 1, mustAsm(t, "halt\n"), stPos, stVel)
|
||||
tc := newTickCtx(s, cfg, 0, 0, stPos, stVel, &DayResult{}, new([world.NumOre]int64))
|
||||
b := &shipBus{tc: tc, sh: sh}
|
||||
|
||||
// Find the true nearest asteroid and its distance from the ship.
|
||||
var near int
|
||||
var nd fixed.F = fixed.Max
|
||||
for i := range s.Asteroids {
|
||||
p, _ := tc.asteroid(i)
|
||||
if d := p.Sub(sh.Pos).Len(); d < nd {
|
||||
near, nd = i+1, d
|
||||
}
|
||||
}
|
||||
limit := fixed.FromInt(ScanRangeKm)
|
||||
if nd <= limit {
|
||||
if got := b.In(PortScanNearest); got != int32(near) {
|
||||
t.Fatalf("nearest = %d, want %d", got, near)
|
||||
}
|
||||
} else if got := b.In(PortScanNearest); got != 0 {
|
||||
t.Fatalf("nearest = %d beyond range, want 0", got)
|
||||
}
|
||||
|
||||
// Move the ship out of range of everything: the scanner goes blind.
|
||||
sh.Pos = fixed.Vec{X: fixed.FromInt(50_000_000)}
|
||||
if got := b.In(PortScanNearest); got != 0 {
|
||||
t.Fatalf("nearest out of range = %d, want 0", got)
|
||||
}
|
||||
b.Out(PortScanSelect, int32(near))
|
||||
for _, p := range []uint16{PortScanRelX, PortScanRelVX, PortScanDist, PortScanOre} {
|
||||
if got := b.In(p); got != 0 {
|
||||
t.Fatalf("port %#x = %d out of range, want 0", p, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrapRemovesShipAndReleasesTow(t *testing.T) {
|
||||
cfg := testCfg()
|
||||
s := NewState(cfg)
|
||||
stPos, stVel := s.Station.Orbit.State(0)
|
||||
sh := addShip(t, s, cfg, 1, mustAsm(t, "halt\n"), stPos, stVel)
|
||||
s.Containers = append(s.Containers, &Container{ID: 1, Towed: sh.ID, Pos: stPos, Vel: stVel})
|
||||
res, err := s.RunDay(cfg, DayInput{Scraps: []int64{1, 42}}) // an unknown id is ignored
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.Ships) != 0 {
|
||||
t.Fatalf("%d ships left after scrapping", len(s.Ships))
|
||||
}
|
||||
c := s.Containers[0]
|
||||
if c.Towed != 0 || c.Dropped != 1 {
|
||||
t.Fatalf("container after scrapping: towed %d dropped %d, want adrift", c.Towed, c.Dropped)
|
||||
}
|
||||
found := false
|
||||
for _, e := range res.Events {
|
||||
found = found || (e.Kind == "scrapped" && e.ShipID == 1)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no scrapped event")
|
||||
}
|
||||
}
|
||||
|
||||
+11
-13
@@ -16,7 +16,6 @@ import (
|
||||
type Hull struct {
|
||||
DryMass int64 // kg
|
||||
FuelCap int64 // kg
|
||||
CargoCap int64 // kg
|
||||
Thrust int64 // newtons at full throttle
|
||||
ExhaustVel int64 // m/s
|
||||
MineRate int64 // kg mined per tick
|
||||
@@ -26,7 +25,6 @@ type Hull struct {
|
||||
var CommandShip = Hull{
|
||||
DryMass: 8000,
|
||||
FuelCap: 4000,
|
||||
CargoCap: 6000,
|
||||
Thrust: 6000,
|
||||
ExhaustVel: 30000,
|
||||
MineRate: 10,
|
||||
@@ -35,9 +33,10 @@ var CommandShip = Hull{
|
||||
// Docking / mining limits.
|
||||
const (
|
||||
MineRangeKm = 5 // max distance to an asteroid for mining
|
||||
DockRangeKm = 20 // max distance to the station for selling
|
||||
DockRangeKm = 20 // how close counts as "near the station"
|
||||
MaxRelSpeedM = 100 // max relative speed (m/s) for mining or docking
|
||||
StarRadiusKm = 200_000
|
||||
ScanRangeKm = 500_000 // the scanner sees nothing farther away than this
|
||||
)
|
||||
|
||||
type Ship struct {
|
||||
@@ -47,6 +46,8 @@ type Ship struct {
|
||||
Pos fixed.Vec
|
||||
Vel fixed.Vec
|
||||
Fuel fixed.F
|
||||
// Cargo is the hold of the earlier design, before ore travelled in containers.
|
||||
// It is only read to convert what old ships still carry (migrateLegacyCargo).
|
||||
Cargo [world.NumOre]int64
|
||||
CPU *vm.CPU
|
||||
Alive bool
|
||||
@@ -65,16 +66,9 @@ type Ship struct {
|
||||
MathZ int32
|
||||
}
|
||||
|
||||
func (s *Ship) CargoTotal() int64 {
|
||||
var t int64
|
||||
for _, v := range s.Cargo {
|
||||
t += v
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (s *Ship) Mass() fixed.F {
|
||||
return fixed.FromInt(s.Hull.DryMass+s.CargoTotal()) + s.Fuel
|
||||
// baseMass is the hull and its fuel; towed containers add to it (State.shipMass).
|
||||
func (s *Ship) baseMass() fixed.F {
|
||||
return fixed.FromInt(s.Hull.DryMass) + s.Fuel
|
||||
}
|
||||
|
||||
// State is everything that persists between daily runs.
|
||||
@@ -83,6 +77,9 @@ type State struct {
|
||||
Asteroids []world.Asteroid // sorted by ID
|
||||
Station world.Station
|
||||
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
|
||||
Market market.Market
|
||||
}
|
||||
@@ -123,6 +120,7 @@ type Uplink struct {
|
||||
type DayInput struct {
|
||||
Launches []Launch // processed in slice order (must be deterministic)
|
||||
Uplinks []Uplink
|
||||
Scraps []int64 // ships to remove from the world before the day starts, in slice order
|
||||
}
|
||||
|
||||
// Downlink is the content of a ship's transmit buffer at the end of the day.
|
||||
|
||||
+31
-6
@@ -37,6 +37,22 @@ func (s *State) RunDay(cfg config.Config, in DayInput) (DayResult, error) {
|
||||
res.Events = append(res.Events, Event{0, l.ShipID, "launch", ""})
|
||||
}
|
||||
sort.Slice(s.Ships, func(i, j int) bool { return s.Ships[i].ID < s.Ships[j].ID })
|
||||
s.migrateLegacyCargo()
|
||||
|
||||
// Scrapped ships vanish; whatever they were towing is left adrift where it is.
|
||||
for _, id := range in.Scraps {
|
||||
for i, sh := range s.Ships {
|
||||
if sh.ID != id {
|
||||
continue
|
||||
}
|
||||
if sh.Alive {
|
||||
s.releaseTow(sh, dayStart)
|
||||
}
|
||||
s.Ships = append(s.Ships[:i], s.Ships[i+1:]...)
|
||||
res.Events = append(res.Events, Event{0, id, "scrapped", ""})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Uplinks land in the RX buffer at the start of RAM.
|
||||
for _, u := range in.Uplinks {
|
||||
@@ -57,17 +73,25 @@ func (s *State) RunDay(cfg config.Config, in DayInput) (DayResult, error) {
|
||||
now := dayStart + int64(tick)*dt
|
||||
stPos, stVel = s.Station.Orbit.State(now)
|
||||
tc := newTickCtx(s, cfg, tick, now, stPos, stVel, &res, &sold)
|
||||
s.sellNearStation(tc) // ore in containers left near the station is sold...
|
||||
s.expireContainers(tc) // ...and containers adrift for ten days vanish
|
||||
for _, sh := range s.Ships {
|
||||
if !sh.Alive {
|
||||
continue
|
||||
}
|
||||
sh.CPU.Run(&shipBus{tc: tc, sh: sh}, cfg.CyclesPerTick)
|
||||
s.physics(cfg, sh, dt, tick, &res)
|
||||
if sh.Alive && sh.Mining {
|
||||
if sh.Alive {
|
||||
s.syncTow(sh)
|
||||
if sh.Mining {
|
||||
s.mine(sh, tc.now+dt)
|
||||
}
|
||||
} else {
|
||||
s.releaseTow(sh, tc.now)
|
||||
}
|
||||
}
|
||||
s.driftContainers(cfg.OrbitSpeed, dt, tick, &res)
|
||||
}
|
||||
|
||||
for _, sh := range s.Ships {
|
||||
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
|
||||
}
|
||||
// dv (km/s) = thrust*dt / mass / 1000.
|
||||
dv := fixed.FromInt(thrust * dt).Div(sh.Mass()).DivInt(1000)
|
||||
dv := fixed.FromInt(thrust * dt).Div(s.shipMass(sh)).DivInt(1000)
|
||||
sh.Vel = sh.Vel.Add(thrustDir(sh).Scale(dv))
|
||||
sh.Fuel -= burn
|
||||
}
|
||||
@@ -132,8 +156,9 @@ func (s *State) canReach(sh *Ship, pos, vel fixed.Vec, rangeKm int64) bool {
|
||||
relV.Len() <= fixed.FromRatio(MaxRelSpeedM, 1000)
|
||||
}
|
||||
|
||||
// mine moves ore from the target asteroid into the ship's hold, split
|
||||
// proportionally to the asteroid's composition. at is the absolute time (s)
|
||||
// mine moves ore from the target asteroid into the containers the ship is towing,
|
||||
// split proportionally to the asteroid's composition. With no container in tow, or
|
||||
// none with room, nothing is mined. at is the absolute time (s)
|
||||
// at which range is evaluated.
|
||||
func (s *State) mine(sh *Ship, at int64) {
|
||||
a := s.asteroid(sh.Target)
|
||||
@@ -141,7 +166,7 @@ func (s *State) mine(sh *Ship, at int64) {
|
||||
return
|
||||
}
|
||||
total := a.TotalOre()
|
||||
room := sh.Hull.CargoCap - sh.CargoTotal()
|
||||
room := s.towOf(sh.ID).Room
|
||||
amt := min(sh.Hull.MineRate, room, total)
|
||||
if amt <= 0 {
|
||||
return
|
||||
@@ -163,8 +188,8 @@ func (s *State) mine(sh *Ship, at int64) {
|
||||
}
|
||||
for o, kg := range taken {
|
||||
a.Ore[o] -= kg
|
||||
sh.Cargo[o] += kg
|
||||
}
|
||||
s.fillTow(sh.ID, taken)
|
||||
}
|
||||
|
||||
// thrustDir converts the ship's azimuth/pitch (milliradians) to a unit vector.
|
||||
|
||||
+157
@@ -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
@@ -19,6 +19,8 @@ const (
|
||||
StatusInventory = "inventory" // built, can be programmed
|
||||
StatusLaunching = "launching" // enters the belt on the next run
|
||||
StatusActive = "active" // in the belt
|
||||
StatusScrapping = "scrapping" // scrapped: leaves the belt on the next run, a fresh ship replaces it
|
||||
StatusScrapped = "scrapped" // gone; hidden from the fleet list
|
||||
StatusDestroyed = "destroyed"
|
||||
)
|
||||
|
||||
@@ -91,7 +93,7 @@ func (q *Q) PlayerByKey(ctx context.Context, key string) (Player, error) {
|
||||
|
||||
func (q *Q) PlayerShips(ctx context.Context, owner int64) ([]Ship, error) {
|
||||
rows, err := q.query(ctx, `SELECT id, owner_id, status, COALESCE(LENGTH(program), 0), downlink_day
|
||||
FROM ships WHERE owner_id = ? ORDER BY id`, owner)
|
||||
FROM ships WHERE owner_id = ? AND status <> ? ORDER BY id`, owner, StatusScrapped)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -148,6 +150,35 @@ func (q *Q) Launch(ctx context.Context, owner, ship int64) error {
|
||||
return q.requireOneRow(res, err)
|
||||
}
|
||||
|
||||
// Scrap queues a ship that is in the belt to be scrapped on the next daily
|
||||
// run: it is removed from the world and its owner gets a new ship in inventory.
|
||||
func (q *Q) Scrap(ctx context.Context, owner, ship int64) error {
|
||||
if _, err := q.ownedStatus(ctx, owner, ship); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := q.exec(ctx, `UPDATE ships SET status = ?, uplink = NULL WHERE id = ? AND owner_id = ? AND status = ?`,
|
||||
StatusScrapping, ship, owner, StatusActive)
|
||||
return q.requireOneRow(res, err)
|
||||
}
|
||||
|
||||
// PendingScraps returns the ships to remove on the next run, ordered by id.
|
||||
func (q *Q) PendingScraps(ctx context.Context) ([]int64, error) {
|
||||
rows, err := q.query(ctx, `SELECT id FROM ships WHERE status = ? ORDER BY id`, StatusScrapping)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// SetUplink stores the message delivered on the next run, replacing any
|
||||
// message already queued.
|
||||
func (q *Q) SetUplink(ctx context.Context, owner, ship int64, data []byte) error {
|
||||
@@ -173,6 +204,36 @@ func (q *Q) Downlink(ctx context.Context, owner, ship int64) ([]byte, int64, err
|
||||
return data, day, err
|
||||
}
|
||||
|
||||
// DownlinkAt returns the downlink a ship produced in the run of the given day.
|
||||
func (q *Q) DownlinkAt(ctx context.Context, owner, ship, day int64) ([]byte, error) {
|
||||
var data []byte
|
||||
err := q.row(ctx, `SELECT d.data FROM downlinks d JOIN ships s ON s.id = d.ship_id
|
||||
WHERE d.ship_id = ? AND s.owner_id = ? AND d.day = ?`, ship, owner, day).Scan(&data)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
// DownlinkDays lists the days a ship has a stored downlink for, oldest first.
|
||||
func (q *Q) DownlinkDays(ctx context.Context, owner, ship int64) ([]int64, error) {
|
||||
rows, err := q.query(ctx, `SELECT d.day FROM downlinks d JOIN ships s ON s.id = d.ship_id
|
||||
WHERE d.ship_id = ? AND s.owner_id = ? ORDER BY d.day`, ship, owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
days := []int64{}
|
||||
for rows.Next() {
|
||||
var d int64
|
||||
if err := rows.Scan(&d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
days = append(days, d)
|
||||
}
|
||||
return days, rows.Err()
|
||||
}
|
||||
|
||||
// PendingLaunches returns ships due to launch, ordered by id.
|
||||
func (q *Q) PendingLaunches(ctx context.Context) ([]sim.Launch, error) {
|
||||
rows, err := q.query(ctx, `SELECT id, owner_id, program FROM ships WHERE status = ? ORDER BY id`, StatusLaunching)
|
||||
@@ -255,6 +316,11 @@ func (q *Q) RecordRun(ctx context.Context, res sim.DayResult, ranAt string) erro
|
||||
d.Data, res.Day, d.ShipID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := q.exec(ctx, `INSERT INTO downlinks (ship_id, day, data) VALUES (?, ?, ?)
|
||||
ON CONFLICT (ship_id, day) DO UPDATE SET data = excluded.data`,
|
||||
d.ShipID, res.Day, d.Data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -268,6 +334,32 @@ func (q *Q) AfterRun(ctx context.Context, st *sim.State) error {
|
||||
if _, err := q.exec(ctx, `UPDATE ships SET status = ? WHERE status = ?`, StatusActive, StatusLaunching); err != nil {
|
||||
return err
|
||||
}
|
||||
// Scrapped ships are gone; each owner gets a new ship on the dock in exchange.
|
||||
owners, err := q.query(ctx, `SELECT owner_id FROM ships WHERE status = ? ORDER BY id`, StatusScrapping)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var scrapped []int64
|
||||
for owners.Next() {
|
||||
var o int64
|
||||
if err := owners.Scan(&o); err != nil {
|
||||
owners.Close()
|
||||
return err
|
||||
}
|
||||
scrapped = append(scrapped, o)
|
||||
}
|
||||
owners.Close()
|
||||
if err := owners.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := q.exec(ctx, `UPDATE ships SET status = ? WHERE status = ?`, StatusScrapped, StatusScrapping); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, o := range scrapped {
|
||||
if _, err := q.exec(ctx, `INSERT INTO ships (owner_id, status) VALUES (?, ?)`, o, StatusInventory); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, sh := range st.Ships {
|
||||
if !sh.Alive {
|
||||
if _, err := q.exec(ctx, `UPDATE ships SET status = ? WHERE id = ?`, StatusDestroyed, sh.ID); err != nil {
|
||||
|
||||
@@ -123,10 +123,23 @@ var schema = []string{
|
||||
id {ID}, owner_id BIGINT NOT NULL REFERENCES players(id),
|
||||
status TEXT NOT NULL, program {BLOB}, uplink {BLOB}, downlink {BLOB},
|
||||
downlink_day BIGINT NOT NULL DEFAULT -1)`,
|
||||
`CREATE TABLE IF NOT EXISTS downlinks (
|
||||
ship_id BIGINT NOT NULL, day BIGINT NOT NULL, data {BLOB} NOT NULL,
|
||||
PRIMARY KEY (ship_id, day))`,
|
||||
// Downlinks kept before the history table existed: only the latest of each ship.
|
||||
`INSERT INTO downlinks (ship_id, day, data)
|
||||
SELECT id, downlink_day, downlink FROM ships WHERE downlink_day >= 0 AND downlink IS NOT NULL
|
||||
ON CONFLICT (ship_id, day) DO NOTHING`,
|
||||
`CREATE TABLE IF NOT EXISTS code_slots (
|
||||
player_id BIGINT NOT NULL REFERENCES players(id), slot INTEGER NOT NULL,
|
||||
name TEXT NOT NULL, source TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (player_id, slot))`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id {ID}, at BIGINT NOT NULL, player_id BIGINT NOT NULL, action TEXT NOT NULL,
|
||||
ship_id BIGINT NOT NULL DEFAULT 0, slot INTEGER NOT NULL DEFAULT 0,
|
||||
detail TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '')`,
|
||||
`CREATE INDEX IF NOT EXISTS audit_player ON audit_log(player_id, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS audit_action ON audit_log(action, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS ships_owner ON ships(owner_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS ships_status ON ships(status)`,
|
||||
`CREATE TABLE IF NOT EXISTS snapshots (
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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());
|
||||
@@ -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>
|
||||
@@ -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
@@ -77,17 +77,21 @@ export const api = {
|
||||
market: () => call('GET', '/market', { auth: false }),
|
||||
register: (name) => call('POST', '/register', { json: { name }, auth: false }),
|
||||
me: (key) => call('GET', '/me', { key }),
|
||||
// Signing in with an access key: like me(), and the server records the login.
|
||||
login: (key) => call('POST', '/login', { key }),
|
||||
ships: () => call('GET', '/ships'),
|
||||
assemble: (source) => call('POST', '/assemble', { body: source, type: 'text/plain', auth: false }),
|
||||
// Launch a docked ship, flying the program in code slot `slot`.
|
||||
launch: (id, slot) => call('POST', `/ships/${id}/launch`, { json: { slot } }),
|
||||
scrap: (id) => call('POST', `/ships/${id}/scrap`),
|
||||
slots: () => call('GET', '/slots'),
|
||||
slot: (n) => call('GET', `/slots/${n}`),
|
||||
saveSlot: (n, name, source) => call('PUT', `/slots/${n}`, { json: { name, source } }),
|
||||
deleteSlot: (n) => call('DELETE', `/slots/${n}`),
|
||||
setUplink: (id, bytes) => call('PUT', `/ships/${id}/uplink`, { body: bytes, type: 'application/octet-stream' }),
|
||||
async downlink(id) {
|
||||
const res = await call('GET', `/ships/${id}/downlink`, { raw: true });
|
||||
downlinkDays: async (id) => (await call('GET', `/ships/${id}/downlinks`)).days,
|
||||
async downlink(id, day) {
|
||||
const res = await call('GET', `/ships/${id}/downlink${day == null ? '' : `?day=${day}`}`, { raw: true });
|
||||
return {
|
||||
bytes: new Uint8Array(await res.arrayBuffer()),
|
||||
day: Number(res.headers.get('X-Downlink-Day')),
|
||||
|
||||
@@ -4,6 +4,7 @@ export const STATUS = {
|
||||
inventory: { label: 'DOCKED', cls: 'amber', hint: 'On the dock. Can be programmed and launched.' },
|
||||
launching: { label: 'LAUNCH PENDING', cls: 'mag', hint: 'Enters the belt on the next daily run.' },
|
||||
active: { label: 'IN BELT', cls: 'cyan', hint: 'Flying its program.' },
|
||||
scrapping: { label: 'SCRAP PENDING', cls: 'amber', hint: 'Leaves the belt on the next daily run; a new ship replaces it.' },
|
||||
destroyed: { label: 'LOST', cls: 'red', hint: 'Destroyed.' },
|
||||
};
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export function authView({ onLogin }) {
|
||||
btn.disabled = true;
|
||||
err.textContent = '';
|
||||
try {
|
||||
await api.me(k);
|
||||
await api.login(k);
|
||||
keyStore.set(k);
|
||||
onLogin();
|
||||
} catch (ex) {
|
||||
|
||||
@@ -11,15 +11,46 @@ export function downlinkView(ctx) {
|
||||
const dl = h('button', { class: 'btn', type: 'button', disabled: true }, 'DOWNLOAD .BIN');
|
||||
const refresh = h('button', { class: 'btn', type: 'button', disabled: !ship }, 'REFRESH');
|
||||
|
||||
// Earlier downlinks: pick a day, or step with the arrows. `days` is oldest first.
|
||||
const older = h('button', { class: 'btn', type: 'button', disabled: true, 'aria-label': 'Previous downlink' }, '<');
|
||||
const newer = h('button', { class: 'btn', type: 'button', disabled: true, 'aria-label': 'Next downlink' }, '>');
|
||||
const pick = h('select', { class: 'daypick', 'aria-label': 'Downlink day', disabled: true });
|
||||
let days = [];
|
||||
let shown = null; // the day on screen; null = the latest
|
||||
|
||||
function syncPicker() {
|
||||
clear(pick).append(...days.slice().reverse().map((d, i) =>
|
||||
h('option', { value: String(d) }, `DAY ${d}${i === 0 ? ' (LATEST)' : ''}`)));
|
||||
const at = shown == null ? days.length - 1 : days.indexOf(shown);
|
||||
if (at >= 0) pick.value = String(days[at]);
|
||||
pick.disabled = days.length < 2;
|
||||
older.disabled = at <= 0;
|
||||
newer.disabled = at < 0 || at >= days.length - 1;
|
||||
}
|
||||
function go(step) {
|
||||
const at = shown == null ? days.length - 1 : days.indexOf(shown);
|
||||
const to = days[at + step];
|
||||
if (to !== undefined) fetchIt(to);
|
||||
}
|
||||
older.addEventListener('click', () => go(-1));
|
||||
newer.addEventListener('click', () => go(1));
|
||||
pick.addEventListener('change', () => fetchIt(Number(pick.value)));
|
||||
|
||||
let current = null;
|
||||
async function fetchIt() {
|
||||
async function fetchIt(day) {
|
||||
if (!ship) return;
|
||||
refresh.disabled = true;
|
||||
try {
|
||||
const { bytes, day } = await api.downlink(ship.id);
|
||||
current = { bytes, day };
|
||||
days = await api.downlinkDays(ship.id);
|
||||
// Refreshing (no day given) jumps to the latest; a stale choice falls back to it too.
|
||||
shown = day != null && days.includes(day) ? day : null;
|
||||
const got = await api.downlink(ship.id, shown);
|
||||
const bytes = got.bytes;
|
||||
shown = got.day === days[days.length - 1] ? null : got.day;
|
||||
syncPicker();
|
||||
current = { bytes, day: got.day };
|
||||
const allZero = bytes.every((b) => b === 0);
|
||||
meta.textContent = `SHIP #${ship.id} // FROM THE RUN OF DAY ${day} // ${bytes.length} BYTES`;
|
||||
meta.textContent = `SHIP #${ship.id} // FROM THE RUN OF DAY ${got.day} // ${bytes.length} BYTES`;
|
||||
dl.disabled = false;
|
||||
clear(body).append(
|
||||
...(allZero ? [h('p', { class: 'warn' }, 'BUFFER IS ALL ZEROS: THE SHIP HAS NOT WRITTEN ANYTHING TO ITS DOWNLINK BUFFER.')] : []),
|
||||
@@ -28,6 +59,9 @@ export function downlinkView(ctx) {
|
||||
);
|
||||
} catch (e) {
|
||||
current = null;
|
||||
days = [];
|
||||
shown = null;
|
||||
syncPicker();
|
||||
dl.disabled = true;
|
||||
meta.textContent = '';
|
||||
clear(body).append(
|
||||
@@ -53,7 +87,7 @@ export function downlinkView(ctx) {
|
||||
|
||||
return h('section', { class: 'panel' },
|
||||
h('div', { class: 'panel-title' }, 'DOWNLINK RECEIVER', h('span', { class: 'spacer' }), meta),
|
||||
h('div', { class: 'toolbar pad' }, refresh, h('span', { class: 'spacer' }), dl),
|
||||
h('div', { class: 'toolbar pad' }, refresh, older, pick, newer, h('span', { class: 'spacer' }), dl),
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,26 @@ export function fleetView(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
async function scrap(ship) {
|
||||
const dlg = customDialog({
|
||||
title: `SCRAP SHIP #${ship.id}`,
|
||||
content: [
|
||||
h('p', null, 'THE SHIP IS BROKEN UP ON THE NEXT DAILY RUN AND LEAVES THE BELT. A NEW SHIP WITH NO PROGRAM APPEARS ON YOUR DOCK.'),
|
||||
h('p', { class: 'warn' }, 'ITS FUEL AND PROGRAM ARE LOST, AND CONTAINERS IT TOWS ARE LEFT ADRIFT. THIS CANNOT BE UNDONE.'),
|
||||
],
|
||||
confirmText: 'SCRAP',
|
||||
danger: true,
|
||||
});
|
||||
if (!(await dlg.result)) return;
|
||||
try {
|
||||
await api.scrap(ship.id);
|
||||
toast(`SHIP #${ship.id} WILL BE SCRAPPED ON THE NEXT DAILY RUN. A NEW SHIP JOINS YOUR DOCK THEN.`);
|
||||
await ctx.refresh();
|
||||
} catch (e) {
|
||||
toast(`SCRAP FAILED: ${e.message}`, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
const btn = (label, onclick, opts = {}) =>
|
||||
h('button', { class: `btn small ${opts.cls ?? ''}`, type: 'button', onclick, disabled: opts.disabled, title: opts.title }, label);
|
||||
|
||||
@@ -81,6 +101,7 @@ export function fleetView(ctx) {
|
||||
out.push(btn('LAUNCH', () => launch(s), { cls: 'primary', title: 'Choose a code slot and launch on the next daily run' }));
|
||||
}
|
||||
if (s.status === 'launching' || s.status === 'active') out.push(btn('UPLINK', () => ctx.selectShip(s.id, 'uplink')));
|
||||
if (s.status === 'active') out.push(btn('SCRAP', () => scrap(s), { cls: 'danger', title: 'Break the ship up: a new ship replaces it on your dock' }));
|
||||
if (s.downlink_day >= 0) out.push(btn('DOWNLINK', () => ctx.selectShip(s.id, 'downlink')));
|
||||
return out;
|
||||
}
|
||||
@@ -117,7 +138,7 @@ export function fleetView(ctx) {
|
||||
step('3', 'LAUNCH', 'the ship and choose which slot it flies. It enters the belt on the next daily run and the program is fixed for good.'),
|
||||
step('4', 'TALK', 'to it once a day: 1 KB up, 1 KB down, over the wormhole link.'),
|
||||
),
|
||||
h('p', { class: 'hint pad' }, 'The belt runs once a day. Ore delivered to the station earns credits.'),
|
||||
h('p', { class: 'hint pad' }, 'The belt runs once a day. Ore is towed to the station in containers. A container left there is taken, and paid for.'),
|
||||
h('p', { class: 'pad' }, h('a', { class: 'btn small', href: '/manual.pdf', target: '_blank', rel: 'noopener' }, 'OPEN THE HC-33 MANUAL (PDF)')),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -82,6 +82,10 @@ func secure(h http.Handler) http.Handler {
|
||||
hd.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
|
||||
hd.Set("X-Content-Type-Options", "nosniff")
|
||||
hd.Set("Referrer-Policy", "no-referrer")
|
||||
if strings.HasPrefix(r.URL.Path, "/admin") {
|
||||
hd.Set("X-Robots-Tag", "noindex, nofollow")
|
||||
hd.Set("Cache-Control", "no-store")
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user