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