Files
Halcyon/api/api.go
T
2026-09-19 20:21:47 +02:00

290 lines
8.1 KiB
Go

// Package api exposes the player-facing HTTP interface.
package api
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"wh/comms"
"wh/config"
"wh/runner"
"wh/store"
"wh/vm"
"wh/world"
)
type Server struct {
st *store.Store
cfg config.Config
}
// New returns the HTTP handler.
//
// POST /register {"name": "..."} -> player, api_key, ship_id
// GET /me -> name, credits, day
// GET /market -> ore prices
// GET /info -> day and the belt's limits
// 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)
// PUT /ships/{id}/uplink raw bytes (<= comm size) (replaces any queued uplink)
// GET /ships/{id}/downlink raw bytes, X-Downlink-Day header
//
// Authenticated routes take "Authorization: Bearer <api_key>".
func New(st *store.Store, cfg config.Config) *http.ServeMux {
s := &Server{st: st, cfg: cfg}
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("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("PUT /ships/{id}/uplink", s.auth(s.uplink))
mux.Handle("GET /ships/{id}/downlink", s.auth(s.downlink))
return mux
}
func (s *Server) auth(h func(http.ResponseWriter, *http.Request, store.Player)) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok {
httpError(w, http.StatusUnauthorized, "missing bearer token")
return
}
p, err := s.st.PlayerByKey(r.Context(), key)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
httpError(w, http.StatusUnauthorized, "invalid api key")
} else {
fail(w, err)
}
return
}
h(w, r, p)
})
}
func httpError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
// fail maps store errors to HTTP statuses.
func fail(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, store.ErrNotFound):
httpError(w, http.StatusNotFound, "not found")
case errors.Is(err, store.ErrState):
httpError(w, http.StatusConflict, err.Error())
case errors.Is(err, store.ErrTaken):
httpError(w, http.StatusConflict, err.Error())
default:
httpError(w, http.StatusInternalServerError, "internal error")
}
}
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<10)).Decode(&req); err != nil || len(req.Name) == 0 || len(req.Name) > 40 {
httpError(w, http.StatusBadRequest, `body must be {"name": "<1-40 chars>"}`)
return
}
p, key, ship, err := s.st.Register(r.Context(), req.Name)
if err != nil {
fail(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"player_id": p.ID, "api_key": key, "ship_id": ship})
}
func (s *Server) currentDay(ctx context.Context) int64 {
v, ok, _ := s.st.GetMeta(ctx, runner.MetaDay)
if !ok {
return 0
}
n, _ := strconv.ParseInt(v, 10, 64)
return n
}
func (s *Server) me(w http.ResponseWriter, r *http.Request, p store.Player) {
writeJSON(w, http.StatusOK, map[string]any{"name": p.Name, "credits": p.Credits, "day": s.currentDay(r.Context())})
}
func (s *Server) market(w http.ResponseWriter, r *http.Request) {
v, ok, err := s.st.GetMeta(r.Context(), runner.MetaMarket)
if err != nil {
fail(w, err)
return
}
if !ok {
v = "null"
}
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, v)
}
// info reports the day and the limits that apply to programs and messages.
func (s *Server) info(w http.ResponseWriter, r *http.Request) {
ores := make([]string, world.NumOre)
for o := range ores {
ores[o] = world.Ore(o).String()
}
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,
"program_bytes": s.cfg.ProgramBytes,
"ram_bytes": s.cfg.RAMBytes,
"comm_bytes": s.cfg.CommBytes,
"ores": ores,
})
}
// assemble compiles assembly source with the same assembler as cmd/asm, so
// that clients need no assembler of their own. Nothing is stored.
func (s *Server) assemble(w http.ResponseWriter, r *http.Request) {
b, ok := readBody(w, r, 64<<10)
if !ok {
return
}
prog, err := vm.Assemble(string(b))
if err != nil {
httpError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"size": len(prog),
"instructions": len(prog) / 4,
"limit": s.cfg.ProgramBytes,
"fits": len(prog) > 0 && len(prog) <= s.cfg.ProgramBytes,
"program": base64.StdEncoding.EncodeToString(prog),
})
}
func (s *Server) ships(w http.ResponseWriter, r *http.Request, p store.Player) {
ships, err := s.st.PlayerShips(r.Context(), p.ID)
if err != nil {
fail(w, err)
return
}
type out struct {
ID int64 `json:"id"`
Status string `json:"status"`
ProgramSize int `json:"program_bytes"`
DownlinkDay int64 `json:"downlink_day"`
}
res := make([]out, 0, len(ships))
for _, sh := range ships {
res = append(res, out{sh.ID, sh.Status, sh.ProgramSize, sh.DownlinkDay})
}
writeJSON(w, http.StatusOK, res)
}
func shipID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
httpError(w, http.StatusBadRequest, "bad ship id")
return 0, false
}
return id, true
}
// readBody reads at most limit bytes and rejects larger bodies.
func readBody(w http.ResponseWriter, r *http.Request, limit int) ([]byte, bool) {
b, err := io.ReadAll(io.LimitReader(r.Body, int64(limit)+1))
if err != nil || len(b) > limit {
httpError(w, http.StatusRequestEntityTooLarge, "body too large")
return nil, false
}
return b, true
}
func (s *Server) program(w http.ResponseWriter, r *http.Request, p store.Player) {
id, ok := shipID(w, r)
if !ok {
return
}
b, ok := readBody(w, r, s.cfg.ProgramBytes)
if !ok {
return
}
if err := comms.CheckProgram(b, s.cfg.ProgramBytes); err != nil {
httpError(w, http.StatusBadRequest, err.Error())
return
}
if err := s.st.SetProgram(r.Context(), p.ID, id, b); err != nil {
fail(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) launch(w http.ResponseWriter, r *http.Request, p store.Player) {
id, ok := shipID(w, r)
if !ok {
return
}
if err := s.st.Launch(r.Context(), p.ID, id); err != nil {
fail(w, err)
return
}
w.WriteHeader(http.StatusAccepted)
}
func (s *Server) uplink(w http.ResponseWriter, r *http.Request, p store.Player) {
id, ok := shipID(w, r)
if !ok {
return
}
b, ok := readBody(w, r, s.cfg.CommBytes)
if !ok {
return
}
if err := comms.CheckUplink(b, s.cfg.CommBytes); err != nil {
httpError(w, http.StatusBadRequest, err.Error())
return
}
if err := s.st.SetUplink(r.Context(), p.ID, id, b); err != nil {
fail(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
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)
if err != nil {
fail(w, err)
return
}
if day < 0 {
httpError(w, http.StatusNotFound, "no downlink yet")
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("X-Downlink-Day", strconv.FormatInt(day, 10))
w.Write(data)
}