Files
Halcyon/api/slots.go
T
2026-09-20 14:16:41 +02:00

178 lines
5.0 KiB
Go

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)})
}