This commit is contained in:
root
2026-09-20 14:16:41 +02:00
parent 0798933b05
commit b6d1909d7f
56 changed files with 3752 additions and 1989 deletions
+35 -10
View File
@@ -11,12 +11,12 @@ import (
"strconv"
"strings"
"wh/comms"
"wh/config"
"wh/runner"
"wh/store"
"wh/vm"
"wh/world"
"halcyon/comms"
"halcyon/config"
"halcyon/runner"
"halcyon/store"
"halcyon/vm"
"halcyon/world"
)
type Server struct {
@@ -33,7 +33,9 @@ type Server struct {
// POST /assemble assembly text -> program (base64), size
// GET /ships -> your ships
// PUT /ships/{id}/program raw bytecode (ship must be in inventory)
// POST /ships/{id}/launch (enters the belt on the next run)
// POST /ships/{id}/launch {"slot": n} (optional) (enters the belt on the next run; with a slot, that
// 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
//
@@ -49,6 +51,10 @@ func New(st *store.Store, cfg config.Config) *http.ServeMux {
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("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))
return mux
@@ -148,9 +154,9 @@ func (s *Server) info(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{
"day": s.currentDay(r.Context()),
"ticks_per_day": s.cfg.TicksPerDay,
"tick_seconds": s.cfg.TickSeconds(),
"cycles_per_tick": s.cfg.CyclesPerTick,
"turns_per_day": s.cfg.TicksPerDay,
"turn_seconds": s.cfg.TickSeconds(),
"cycles_per_turn": s.cfg.CyclesPerTick,
"program_bytes": s.cfg.ProgramBytes,
"ram_bytes": s.cfg.RAMBytes,
"comm_bytes": s.cfg.CommBytes,
@@ -242,6 +248,25 @@ func (s *Server) launch(w http.ResponseWriter, r *http.Request, p store.Player)
if !ok {
return
}
// An optional {"slot": n} chooses which code slot the ship flies. Without it
// the ship launches with the program already sealed in it (PUT .../program).
body, ok := readBody(w, r, 1<<10)
if !ok {
return
}
if strings.TrimSpace(string(body)) != "" {
var req struct {
Slot *int `json:"slot"`
}
if err := json.Unmarshal(body, &req); err != nil {
httpError(w, http.StatusBadRequest, `body must be {"slot": <1-16>} or empty`)
return
}
if req.Slot != nil {
s.launchFromSlot(w, r, p, id, *req.Slot)
return
}
}
if err := s.st.Launch(r.Context(), p.ID, id); err != nil {
fail(w, err)
return
+8 -8
View File
@@ -12,12 +12,12 @@ import (
"path/filepath"
"testing"
"wh/api"
"wh/config"
"wh/runner"
"wh/sim"
"wh/store"
"wh/vm"
"halcyon/api"
"halcyon/config"
"halcyon/runner"
"halcyon/sim"
"halcyon/store"
"halcyon/vm"
)
func TestEndToEnd(t *testing.T) {
@@ -25,10 +25,10 @@ func TestEndToEnd(t *testing.T) {
cfg := config.Default()
cfg.AsteroidCount = 20
// Set WH_TEST_PG to a postgres:// URL to run this against PostgreSQL
// Set HALCYON_TEST_PG to a postgres:// URL to run this against PostgreSQL
// (its public schema is wiped first).
dsn := "sqlite:" + filepath.Join(t.TempDir(), "t.db")
if pg := os.Getenv("WH_TEST_PG"); pg != "" {
if pg := os.Getenv("HALCYON_TEST_PG"); pg != "" {
db, err := sql.Open("pgx", pg)
if err != nil {
t.Fatal(err)
+80 -11
View File
@@ -2,26 +2,46 @@ package api_test
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"wh/api"
"wh/config"
"wh/store"
"wh/web"
"halcyon/api"
"halcyon/config"
"halcyon/store"
"halcyon/web"
)
// newSite starts the API with the web front end mounted, as cmd/server does.
func newSite(t *testing.T) (*httptest.Server, config.Config) {
func newSite(t *testing.T, manualPDF string) (*httptest.Server, config.Config) {
srv, cfg, _ := newSiteStore(t, manualPDF)
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) {
t.Helper()
cfg := config.Default()
st, err := store.Open("sqlite:" + filepath.Join(t.TempDir(), "t.db"))
dsn := "sqlite:" + filepath.Join(t.TempDir(), "t.db")
if pg := os.Getenv("HALCYON_TEST_PG"); pg != "" { // run against PostgreSQL, wiping its public schema first
db, err := sql.Open("pgx", pg)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec("DROP SCHEMA public CASCADE; CREATE SCHEMA public"); err != nil {
t.Fatal(err)
}
db.Close()
dsn = pg
}
st, err := store.Open(dsn)
if err != nil {
t.Fatal(err)
}
@@ -30,10 +50,10 @@ func newSite(t *testing.T) (*httptest.Server, config.Config) {
t.Fatal(err)
}
mux := api.New(st, cfg)
mux.Handle("/", web.Handler())
mux.Handle("/", web.Handler(manualPDF))
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, cfg
return srv, cfg, st
}
func get(t *testing.T, url string) (*http.Response, string) {
@@ -48,7 +68,7 @@ func get(t *testing.T, url string) (*http.Response, string) {
}
func TestInfoAndAssemble(t *testing.T) {
srv, cfg := newSite(t)
srv, cfg := newSite(t, "")
_, body := get(t, srv.URL+"/info")
var info struct {
@@ -92,10 +112,10 @@ func TestInfoAndAssemble(t *testing.T) {
}
func TestWebAssets(t *testing.T) {
srv, _ := newSite(t)
srv, _ := newSite(t, "")
for path, want := range map[string]string{
"/": "<title>",
"/manual.txt": "PROGRAMMER'S REFERENCE MANUAL",
"/manual.md": "Programmer's Reference Manual",
"/examples/echo.s": "ECHO",
"/examples/index.json": `"pursue"`,
} {
@@ -108,8 +128,57 @@ func TestWebAssets(t *testing.T) {
if !strings.Contains(resp.Header.Get("Content-Security-Policy"), "default-src 'self'") {
t.Error("missing CSP header")
}
// The PDF is rendered separately; until it exists /manual.pdf explains how.
presp, pbody := get(t, srv.URL+"/manual.pdf")
if presp.StatusCode != 404 || !strings.Contains(pbody, "render-manual.sh") {
t.Errorf("/manual.pdf before any render: status %d: %s", presp.StatusCode, pbody)
}
// The API still wins over the catch-all.
if resp, _ := get(t, srv.URL+"/market"); !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
t.Errorf("/market served as %q", resp.Header.Get("Content-Type"))
}
}
func TestManualPDFServedFromFileAndPickedUpLive(t *testing.T) {
path := filepath.Join(t.TempDir(), "manual.pdf")
srv, _ := newSite(t, path)
if resp, _ := get(t, srv.URL+"/manual.pdf"); resp.StatusCode != 404 {
t.Fatalf("before the file exists: %d", resp.StatusCode)
}
write := func(content string) {
// Like the renderer: write beside the target, then rename into place.
if err := os.WriteFile(path+".tmp", []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Rename(path+".tmp", path); err != nil {
t.Fatal(err)
}
}
write("%PDF-1.7 first render")
resp, body := get(t, srv.URL+"/manual.pdf")
if resp.StatusCode != 200 || body != "%PDF-1.7 first render" || resp.Header.Get("Content-Type") != "application/pdf" {
t.Fatalf("first render: %d %q %q", resp.StatusCode, body, resp.Header.Get("Content-Type"))
}
if resp.Header.Get("Cache-Control") != "no-cache" || resp.Header.Get("Content-Security-Policy") != "" {
t.Errorf("headers: cache=%q csp=%q", resp.Header.Get("Cache-Control"), resp.Header.Get("Content-Security-Policy"))
}
// A re-render is served immediately, with no restart.
write("%PDF-1.7 second render, longer")
if _, body := get(t, srv.URL+"/manual.pdf"); body != "%PDF-1.7 second render, longer" {
t.Fatalf("new render not picked up: %q", body)
}
// Range requests work (PDF viewers rely on them).
req, _ := http.NewRequest("GET", srv.URL+"/manual.pdf", nil)
req.Header.Set("Range", "bytes=0-4")
rr, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer rr.Body.Close()
if part, _ := io.ReadAll(rr.Body); rr.StatusCode != 206 || string(part) != "%PDF-" {
t.Fatalf("range: %d %q", rr.StatusCode, part)
}
}
+177
View File
@@ -0,0 +1,177 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"halcyon/comms"
"halcyon/slots"
"halcyon/store"
"halcyon/vm"
)
const (
maxSourceBytes = 64 << 10
maxSlotName = 40
)
var (
errBadSlot = errors.New("slot must be a number from 1 to 16")
errEmptySlot = errors.New("that slot is empty")
)
type slotView struct {
Slot int `json:"slot"`
Name string `json:"name"`
Builtin bool `json:"builtin"`
Empty bool `json:"empty"`
}
func slotNumber(w http.ResponseWriter, r *http.Request) (int, bool) {
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || !slots.Valid(n) {
httpError(w, http.StatusBadRequest, errBadSlot.Error())
return 0, false
}
return n, true
}
// slotContent returns what is in slot n for this player. An empty slot has an
// empty source. The examples in slots 1-3 are the same for everyone.
func (s *Server) slotContent(r *http.Request, owner int64, n int) (slots.Slot, error) {
if !slots.Valid(n) {
return slots.Slot{}, errBadSlot
}
if b, ok := slots.Builtin(n); ok {
return b, nil
}
cs, err := s.st.GetCodeSlot(r.Context(), owner, n)
if errors.Is(err, store.ErrNotFound) {
return slots.Slot{Number: n}, nil
}
if err != nil {
return slots.Slot{}, err
}
return slots.Slot{Number: n, Name: cs.Name, Source: cs.Source}, nil
}
// GET /slots: the sixteen slots, without their source.
func (s *Server) listSlots(w http.ResponseWriter, r *http.Request, p store.Player) {
saved, err := s.st.ListCodeSlots(r.Context(), p.ID)
if err != nil {
fail(w, err)
return
}
out := make([]slotView, 0, slots.Count)
for n := 1; n <= slots.Count; n++ {
if b, ok := slots.Builtin(n); ok {
out = append(out, slotView{Slot: n, Name: b.Name, Builtin: true})
continue
}
name, has := saved[n]
out = append(out, slotView{Slot: n, Name: name, Empty: !has})
}
writeJSON(w, http.StatusOK, out)
}
// GET /slots/{n}: one slot with its source.
func (s *Server) getSlot(w http.ResponseWriter, r *http.Request, p store.Player) {
n, ok := slotNumber(w, r)
if !ok {
return
}
sl, err := s.slotContent(r, p.ID, n)
if err != nil {
fail(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"slot": n, "name": sl.Name, "builtin": sl.Builtin, "empty": sl.Source == "" && sl.Name == "", "source": sl.Source,
})
}
// PUT /slots/{n}: save source in one of slots 4-16.
func (s *Server) putSlot(w http.ResponseWriter, r *http.Request, p store.Player) {
n, ok := slotNumber(w, r)
if !ok {
return
}
if _, builtin := slots.Builtin(n); builtin {
httpError(w, http.StatusForbidden, "slots 1 to "+strconv.Itoa(slots.FirstUser-1)+" hold the examples and are read-only; save your copy in slot "+strconv.Itoa(slots.FirstUser)+" or higher")
return
}
body, ok := readBody(w, r, maxSourceBytes+4<<10)
if !ok {
return
}
var req struct {
Name string `json:"name"`
Source string `json:"source"`
}
if err := json.Unmarshal(body, &req); err != nil {
httpError(w, http.StatusBadRequest, `body must be {"name": "...", "source": "..."}`)
return
}
req.Name = strings.TrimSpace(req.Name)
switch {
case req.Name == "" || len([]rune(req.Name)) > maxSlotName:
httpError(w, http.StatusBadRequest, "name must be 1 to "+strconv.Itoa(maxSlotName)+" characters")
return
case len(req.Source) > maxSourceBytes:
httpError(w, http.StatusRequestEntityTooLarge, "source is larger than "+strconv.Itoa(maxSourceBytes)+" bytes")
return
}
if err := s.st.PutCodeSlot(r.Context(), p.ID, n, req.Name, req.Source, time.Now().UTC().Format(time.RFC3339)); err != nil {
fail(w, err)
return
}
writeJSON(w, http.StatusOK, slotView{Slot: n, Name: req.Name})
}
// DELETE /slots/{n}: empty one of slots 4-16.
func (s *Server) deleteSlot(w http.ResponseWriter, r *http.Request, p store.Player) {
n, ok := slotNumber(w, r)
if !ok {
return
}
if _, builtin := slots.Builtin(n); builtin {
httpError(w, http.StatusForbidden, "the example slots cannot be cleared")
return
}
if err := s.st.DeleteCodeSlot(r.Context(), p.ID, n); err != nil {
fail(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// launchFromSlot assembles the chosen slot and seals it into the ship as it launches.
func (s *Server) launchFromSlot(w http.ResponseWriter, r *http.Request, p store.Player, ship int64, n int) {
sl, err := s.slotContent(r, p.ID, n)
if err != nil {
httpError(w, http.StatusBadRequest, err.Error())
return
}
if strings.TrimSpace(sl.Source) == "" {
httpError(w, http.StatusBadRequest, "slot "+strconv.Itoa(n)+" is empty")
return
}
prog, err := vm.Assemble(sl.Source)
if err != nil {
httpError(w, http.StatusBadRequest, "slot "+strconv.Itoa(n)+" does not assemble: "+err.Error())
return
}
if err := comms.CheckProgram(prog, s.cfg.ProgramBytes); err != nil {
httpError(w, http.StatusBadRequest, "slot "+strconv.Itoa(n)+": "+err.Error())
return
}
if err := s.st.LaunchWithProgram(r.Context(), p.ID, ship, prog); err != nil {
fail(w, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"slot": n, "program_bytes": len(prog)})
}
+247
View File
@@ -0,0 +1,247 @@
package api_test
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"halcyon/runner"
)
// player is a registered account talking to the API.
type player struct {
t *testing.T
base string
key string
ship int64
}
func newPlayer(t *testing.T, base, name string) *player {
t.Helper()
resp, err := http.Post(base+"/register", "application/json", strings.NewReader(`{"name":"`+name+`"}`))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var r struct {
APIKey string `json:"api_key"`
ShipID int64 `json:"ship_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil || r.APIKey == "" {
t.Fatalf("register %s: %v", name, err)
}
return &player{t: t, base: base, key: r.APIKey, ship: r.ShipID}
}
// do sends a request and returns the body, failing unless the status is want.
func (p *player) do(method, path string, body any, want int) []byte {
p.t.Helper()
var rdr io.Reader
if body != nil {
if s, ok := body.(string); ok {
rdr = strings.NewReader(s)
} else {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
}
req, _ := http.NewRequest(method, p.base+path, rdr)
if p.key != "" {
req.Header.Set("Authorization", "Bearer "+p.key)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
p.t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != want {
p.t.Fatalf("%s %s: status %d want %d: %s", method, path, resp.StatusCode, want, b)
}
return b
}
type slotRow struct {
Slot int `json:"slot"`
Name string `json:"name"`
Builtin bool `json:"builtin"`
Empty bool `json:"empty"`
Source string `json:"source"`
}
func (p *player) slots() []slotRow {
var rows []slotRow
json.Unmarshal(p.do("GET", "/slots", nil, 200), &rows)
return rows
}
func (p *player) slot(n int) slotRow {
var r slotRow
json.Unmarshal(p.do("GET", "/slots/"+itoa(int64(n)), nil, 200), &r)
return r
}
func (p *player) shipStatus() (status string, programBytes int) {
var ships []struct {
Status string `json:"status"`
ProgramBytes int `json:"program_bytes"`
}
json.Unmarshal(p.do("GET", "/ships", nil, 200), &ships)
return ships[0].Status, ships[0].ProgramBytes
}
func TestSlotsListAndExamples(t *testing.T) {
srv, _ := newSite(t, "")
a := newPlayer(t, srv.URL, "ann")
rows := a.slots()
if len(rows) != 16 {
t.Fatalf("%d slots, want 16", len(rows))
}
for i, want := range []string{"ECHO", "TELEMETRY", "PURSUE"} {
if r := rows[i]; r.Slot != i+1 || r.Name != want || !r.Builtin || r.Empty {
t.Errorf("slot %d = %+v, want the %s example", i+1, r, want)
}
}
for _, r := range rows[3:] {
if r.Builtin || !r.Empty || r.Name != "" {
t.Errorf("slot %d should start empty: %+v", r.Slot, r)
}
}
if s := a.slot(2); !s.Builtin || !strings.Contains(s.Source, "TELEMETRY") {
t.Errorf("slot 2 = %+v", s)
}
if s := a.slot(9); !s.Empty || s.Source != "" {
t.Errorf("empty slot 9 = %+v", s)
}
}
func TestSlotsSaveLoadClearAndIsolation(t *testing.T) {
srv, _ := newSite(t, "")
a, b := newPlayer(t, srv.URL, "ann"), newPlayer(t, srv.URL, "bob")
a.do("PUT", "/slots/5", map[string]string{"name": "MINER", "source": "ldi r0, 1\nhalt"}, 200)
if s := a.slot(5); s.Name != "MINER" || s.Source != "ldi r0, 1\nhalt" || s.Empty || s.Builtin {
t.Fatalf("saved slot = %+v", s)
}
if r := a.slots()[4]; r.Name != "MINER" || r.Empty {
t.Fatalf("listing = %+v", r)
}
// Saving again replaces it, including the name.
a.do("PUT", "/slots/5", map[string]string{"name": "MINER 2", "source": "halt"}, 200)
if s := a.slot(5); s.Name != "MINER 2" || s.Source != "halt" {
t.Fatalf("overwritten slot = %+v", s)
}
// Slots are private to their owner.
if s := b.slot(5); !s.Empty {
t.Fatalf("bob can see ann's slot: %+v", s)
}
// The examples cannot be changed or cleared.
for _, n := range []string{"1", "2", "3"} {
a.do("PUT", "/slots/"+n, map[string]string{"name": "X", "source": "halt"}, 403)
a.do("DELETE", "/slots/"+n, nil, 403)
}
// Bad slot numbers and bodies.
for _, n := range []string{"0", "17", "abc", "-1"} {
a.do("GET", "/slots/"+n, nil, 400)
a.do("PUT", "/slots/"+n, map[string]string{"name": "X", "source": "halt"}, 400)
}
a.do("PUT", "/slots/6", map[string]string{"name": " ", "source": "halt"}, 400)
a.do("PUT", "/slots/6", map[string]string{"name": strings.Repeat("x", 41), "source": "halt"}, 400)
a.do("PUT", "/slots/6", "not json", 400)
a.do("PUT", "/slots/6", map[string]string{"name": "BIG", "source": strings.Repeat("x", 64<<10+1)}, 413)
a.do("PUT", "/slots/6", map[string]string{"name": "OK", "source": strings.Repeat("x", 64<<10)}, 200)
// Clearing.
a.do("DELETE", "/slots/5", nil, 204)
a.do("DELETE", "/slots/5", nil, 204) // clearing an empty slot is fine
if s := a.slot(5); !s.Empty {
t.Fatalf("cleared slot = %+v", s)
}
// Everything needs a key.
anon := &player{t: t, base: srv.URL}
anon.do("GET", "/slots", nil, 401)
anon.do("PUT", "/slots/5", map[string]string{"name": "X", "source": "halt"}, 401)
}
func TestLaunchFromSlot(t *testing.T) {
srv, cfg := newSite(t, "")
launch := func(p *player, slot any, want int) []byte {
return p.do("POST", "/ships/"+itoa(p.ship)+"/launch", map[string]any{"slot": slot}, want)
}
// A saved slot: assembled, sealed into the ship, and launched in one step.
a := newPlayer(t, srv.URL, "ann")
a.do("PUT", "/slots/6", map[string]string{"name": "TINY", "source": "ldi r0, 1\nhalt"}, 200)
var res struct {
Slot int `json:"slot"`
ProgramBytes int `json:"program_bytes"`
}
json.Unmarshal(launch(a, 6, 202), &res)
if res.Slot != 6 || res.ProgramBytes != 8 {
t.Fatalf("launch reply = %+v", res)
}
if st, n := a.shipStatus(); st != "launching" || n != 8 {
t.Fatalf("ship is %s with %d program bytes", st, n)
}
launch(a, 6, 409) // already launching
// Things that must be refused, leaving the ship docked and its program untouched.
b := newPlayer(t, srv.URL, "bob")
b.do("PUT", "/slots/7", map[string]string{"name": "BROKEN", "source": "ldi r1, 5\nbogus r1"}, 200)
b.do("PUT", "/slots/8", map[string]string{"name": "HUGE", "source": strings.Repeat("nop\n", cfg.ProgramBytes/4+1)}, 200)
b.do("PUT", "/slots/10", map[string]string{"name": "BLANK", "source": " \n"}, 200)
for name, c := range map[string]struct {
slot any
msg string
}{
"empty slot": {9, "empty"},
"blank source": {10, "empty"},
"does not compile": {7, "line 2"},
"too large": {8, "limit"},
"out of range": {99, "1 to 16"},
"zero": {0, "1 to 16"},
} {
if body := launch(b, c.slot, 400); !strings.Contains(string(body), c.msg) {
t.Errorf("%s: error %q should mention %q", name, body, c.msg)
}
}
if st, n := b.shipStatus(); st != "inventory" || n != 0 {
t.Fatalf("a refused launch changed the ship: %s, %d bytes", st, n)
}
b.do("POST", "/ships/"+itoa(b.ship)+"/launch", "{not json", 400)
b.do("POST", "/ships/"+itoa(b.ship)+"/launch", nil, 409) // no slot and no sealed program: as before
// Slots are per player: ann's saved slot 6 is empty for bob.
launch(b, 6, 400)
// Ann cannot launch bob's ship with her slot.
a.do("POST", "/ships/"+itoa(b.ship)+"/launch", map[string]int{"slot": 6}, 404)
// An example slot works for anyone.
launch(b, 2, 202)
if st, n := b.shipStatus(); st != "launching" || n == 0 {
t.Fatalf("after launching the telemetry example: %s, %d bytes", st, n)
}
}
// A ship launched from a slot really flies that slot's program.
func TestSlotProgramRunsInTheBelt(t *testing.T) {
srv, cfg, st := newSiteStore(t, "")
cfg.AsteroidCount = 20
p := newPlayer(t, srv.URL, "ann")
p.do("POST", "/ships/"+itoa(p.ship)+"/launch", map[string]int{"slot": 2}, 202) // TELEMETRY
if _, err := runner.RunNextDay(context.Background(), st, cfg); err != nil {
t.Fatal(err)
}
down := p.do("GET", "/ships/"+itoa(p.ship)+"/downlink", nil, 200)
if tick := binary.LittleEndian.Uint32(down[0:4]); tick != uint32(cfg.TicksPerDay-1) {
t.Fatalf("telemetry downlink reports tick %d, want the last tick %d", tick, cfg.TicksPerDay-1)
}
}