adventure: let a player act from the web, not just read about it
The equip queue proved the reverse pipe works. This gives it verbs that play the game: pull out of a run from the adventurer page, take today's bout from the war room. Its own table and its own poll, not more actions on equip_orders. Every column of that table is equip vocabulary (item, slot, tier) and these verbs act on the character rather than on something it is carrying. Nothing in a request names an adventurer. The session maps to one localpart and a localpart to one adventurer, so Pete resolves the character itself and there is no id on the wire to forge. The panel's copy is kept honest by the verdict: an applied action hides the offer it has just spent, a refusal puts the button back. Watching it run is what put that there, along with the strip's layout — gogobee answers a bout with a whole sentence of damage, which the equip strip's two-column row squeezed into a column and wrapped the verb. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// The action queue's web seam — the first verbs the web can play, as opposed to
|
||||
// the equip queue's dressing-up.
|
||||
//
|
||||
// Two audiences, same shape as equip and mischief. A signed-in owner clicks
|
||||
// "Pull out" on their own adventurer page or "Take your bout" on the war room;
|
||||
// gogobee hits the bearer-authed pair, polling pending orders and pushing a
|
||||
// verdict. Pete runs no game rule: it records that somebody asked, and renders
|
||||
// what gogobee answered. The UI says "asked for" and never claims it landed.
|
||||
//
|
||||
// The character is resolved from the SESSION, never from the request. A session
|
||||
// maps to exactly one localpart and a localpart to exactly one adventurer, so
|
||||
// there is nothing for the client to name and therefore nothing to forge — the
|
||||
// equip queue has to take an item id and a slot off the wire and re-resolve them;
|
||||
// this one has no such surface at all.
|
||||
|
||||
// advOrderBurstWindow / advOrderBurstMax blunt a stuck mouse button. The real
|
||||
// gates are gogobee's — one extraction ends the run, one bout per day — and the
|
||||
// pending-order guard below stops the common double-click outright.
|
||||
const (
|
||||
advOrderBurstWindow = time.Hour
|
||||
advOrderBurstMax = 30
|
||||
)
|
||||
|
||||
// advOrderReq is the browser's request. Just the verb: see the file comment on
|
||||
// why nothing identifies the character.
|
||||
type advOrderReq struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
// handleAdvOrder places a pending action for the signed-in owner. It asserts what
|
||||
// Pete can honestly know — the viewer is signed in, and gogobee has pushed a
|
||||
// self-detail row for them, which is gogobee's own proof that this person has an
|
||||
// adventurer. Everything about whether the action is legal *right now* is
|
||||
// gogobee's, at verdict time; the pre-checks here only produce a better message
|
||||
// than a verdict thirty seconds later would.
|
||||
func (s *Server) handleAdvOrder(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.requireUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
owner := buyerLocalpart(u)
|
||||
if owner == "" {
|
||||
writeAdvOrderError(w, http.StatusConflict, "please sign in again")
|
||||
return
|
||||
}
|
||||
|
||||
var req advOrderReq
|
||||
if !decodeStateBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
switch req.Action {
|
||||
case storage.AdvActionExtract, storage.AdvActionSiegeJoin:
|
||||
default:
|
||||
writeAdvOrderError(w, http.StatusBadRequest, "bad action")
|
||||
return
|
||||
}
|
||||
|
||||
// Ownership. The self-detail row is gogobee's own owner<->adventurer proof, the
|
||||
// same join the who page's private panels and the alert sender use. No row means
|
||||
// this account has no adventurer — or gogobee has stopped pushing, in which case
|
||||
// an order it can't attribute is not one we should queue.
|
||||
token, ok := storage.SelfToken(owner)
|
||||
if !ok {
|
||||
writeAdvOrderError(w, http.StatusForbidden, "no adventurer on the board for this account")
|
||||
return
|
||||
}
|
||||
|
||||
// One outstanding order per verb. Two queued extracts would apply in sequence
|
||||
// and the second would answer "no expedition to leave" — a rejection for
|
||||
// something that worked, which is the worst thing this strip could say.
|
||||
if pending, err := storage.HasPendingAdvOrder(u.Sub, req.Action); err != nil {
|
||||
slog.Error("orders: pending lookup", "err", err)
|
||||
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
} else if pending {
|
||||
writeAdvOrderError(w, http.StatusConflict, "already asked — waiting on the game box")
|
||||
return
|
||||
}
|
||||
|
||||
since := time.Now().Add(-advOrderBurstWindow).Unix()
|
||||
if n, err := storage.CountAdvOrdersSince(u.Sub, since); err != nil {
|
||||
slog.Error("orders: burst count", "err", err)
|
||||
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
} else if n >= advOrderBurstMax {
|
||||
writeAdvOrderError(w, http.StatusTooManyRequests, "slow down, too many requests in a short while")
|
||||
return
|
||||
}
|
||||
|
||||
// Per-verb pre-checks, all courtesy only. Both read Pete's snapshot copy, which
|
||||
// is up to two minutes behind the game box, so neither is authoritative and
|
||||
// neither is allowed to be the last word — a run that ended in that window comes
|
||||
// back from gogobee as rejected_not_running, which is the honest answer.
|
||||
characterName := ""
|
||||
entry, haveEntry, err := storage.RosterEntryByToken(token)
|
||||
if err != nil {
|
||||
slog.Error("orders: roster lookup", "err", err)
|
||||
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if haveEntry {
|
||||
characterName = entry.Name
|
||||
}
|
||||
switch req.Action {
|
||||
case storage.AdvActionExtract:
|
||||
if haveEntry && entry.Status != "expedition" {
|
||||
writeAdvOrderError(w, http.StatusConflict, "you're not on an expedition")
|
||||
return
|
||||
}
|
||||
case storage.AdvActionSiegeJoin:
|
||||
snap, known, err := storage.LoadSiege()
|
||||
if err != nil {
|
||||
slog.Error("orders: siege lookup", "err", err)
|
||||
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if known && !snap.Active {
|
||||
writeAdvOrderError(w, http.StatusConflict, "no Siege is camped outside town")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
order, err := storage.InsertAdvOrder(u.Sub, owner, token, characterName, req.Action)
|
||||
if err != nil {
|
||||
slog.Error("orders: insert order", "err", err)
|
||||
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
slog.Info("orders: action placed", "guid", order.GUID, "owner", owner, "action", req.Action)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, order)
|
||||
}
|
||||
|
||||
// handleAdvOrders returns the signed-in owner's own recent actions for the status
|
||||
// strip, newest first. Scoped to their OIDC subject.
|
||||
func (s *Server) handleAdvOrders(w http.ResponseWriter, r *http.Request) {
|
||||
u := s.requireUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
orders, err := storage.AdvOrdersByOwner(u.Sub, 10)
|
||||
if err != nil {
|
||||
slog.Error("orders: by owner", "err", err)
|
||||
writeAdvOrderError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if orders == nil {
|
||||
orders = []storage.AdvOrder{}
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, orders)
|
||||
}
|
||||
|
||||
// ---- the gogobee wire: bearer-authed, idempotent -------------------------------
|
||||
|
||||
// advOrderPollLimit caps one poll, matching the equip and mischief seams.
|
||||
const advOrderPollLimit = 50
|
||||
|
||||
// handleAdvOrdersPending is gogobee's poll: every action still waiting. Like the
|
||||
// seams beside it there is no stale-reoffer window — a gogobee that dies mid-apply
|
||||
// leaves the order pending to be offered again, and its guid ledger makes the
|
||||
// replay a no-op.
|
||||
func (s *Server) handleAdvOrdersPending(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
orders, err := storage.PendingAdvOrders(advOrderPollLimit)
|
||||
if err != nil {
|
||||
slog.Error("orders: pending", "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if orders == nil {
|
||||
orders = []storage.AdvOrder{}
|
||||
}
|
||||
writeJSON(w, orders)
|
||||
}
|
||||
|
||||
// advOrderVerdict is gogobee's answer on an order: the terminal status and a
|
||||
// human note to render.
|
||||
type advOrderVerdict struct {
|
||||
GUID string `json:"guid"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// handleAdvOrderVerdict files gogobee's verdict against a pending order.
|
||||
// Idempotent: gogobee's poll loop retries, so the same verdict can arrive more
|
||||
// than once and only the first moves the order. An unknown guid is a 400 — under
|
||||
// this seam's contract that parks the row for a human rather than retrying
|
||||
// forever against a row that will never exist.
|
||||
func (s *Server) handleAdvOrderVerdict(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.bearerOK(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
var v advOrderVerdict
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&v); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if v.GUID == "" {
|
||||
http.Error(w, "guid is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
order, err := storage.ResolveAdvOrder(v.GUID, v.Status, v.Detail)
|
||||
if errors.Is(err, storage.ErrNoSuchAdvOrder) {
|
||||
slog.Error("orders: verdict for an order we've never heard of", "guid", v.GUID, "status", v.Status)
|
||||
http.Error(w, "no such order", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("orders: resolve", "guid", v.GUID, "status", v.Status, "err", err)
|
||||
http.Error(w, "bad verdict", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
slog.Info("orders: action resolved", "guid", order.GUID, "action", order.Action, "status", order.Status)
|
||||
writeJSON(w, order)
|
||||
}
|
||||
|
||||
func writeAdvOrderError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pete/internal/storage"
|
||||
)
|
||||
|
||||
// W5: the action queue's web seam. Two contracts, same shape as the equip queue's
|
||||
// tests — the owner half must be unable to act for anybody but itself, and the
|
||||
// gogobee half is a bearer-authed, idempotent pending/verdict pair.
|
||||
|
||||
// seedActions stands up a board and a private detail row owned by `owner`, which
|
||||
// together are gogobee's proof that this account has an adventurer. `status` is
|
||||
// the roster status the mark carries ("expedition" or "idle"), because the
|
||||
// extract pre-check reads it.
|
||||
func seedActions(t *testing.T, owner, status string) *Server {
|
||||
t.Helper()
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
now := time.Now().Unix()
|
||||
|
||||
e := entry("tok-josie", "Josie", status, "holymachina")
|
||||
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
|
||||
t.Fatalf("seed roster = %d", w.Code)
|
||||
}
|
||||
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
|
||||
Localpart: owner, Token: "tok-josie",
|
||||
}}}); w.Code != 200 {
|
||||
t.Fatalf("seed detail = %d", w.Code)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func placeAction(t *testing.T, s *Server, username, action string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
r := as(t, s, username, "POST", "/api/adventure/order", advOrderReq{Action: action})
|
||||
w := httptest.NewRecorder()
|
||||
s.handleAdvOrder(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// TestActionOrderNamesNoCharacter is the reason this seam has a smaller attack
|
||||
// surface than the equip queue's: nothing in the request identifies an
|
||||
// adventurer, so there is no id to forge. The order that lands must be attributed
|
||||
// to the session's own localpart and its own token, whatever the body said.
|
||||
func TestActionOrderNamesNoCharacter(t *testing.T) {
|
||||
s := seedActions(t, "holymachina", "expedition")
|
||||
|
||||
// A body carrying extra fields — a token, a localpart — must change nothing:
|
||||
// the handler reads only Action off it.
|
||||
r := as(t, s, "holymachina", "POST", "/api/adventure/order", map[string]any{
|
||||
"action": "extract", "token": "tok-somebody-else", "owner_localpart": "someone",
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
s.handleAdvOrder(w, r)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("order = %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var got storage.AdvOrder
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if got.OwnerLocalpart != "holymachina" {
|
||||
t.Fatalf("owner = %q, want the session's localpart", got.OwnerLocalpart)
|
||||
}
|
||||
if got.Token != "tok-josie" {
|
||||
t.Fatalf("token = %q, want the token resolved from the session, not the body", got.Token)
|
||||
}
|
||||
if got.Status != storage.AdvOrderPending {
|
||||
t.Fatalf("status = %q, want pending — Pete never claims an action landed", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActionOrderNeedsAnAdventurer: a signed-in visitor with no self-detail row
|
||||
// has no adventurer for gogobee to act on. Queuing the order anyway would file
|
||||
// something gogobee can only answer with a rejection.
|
||||
func TestActionOrderNeedsAnAdventurer(t *testing.T) {
|
||||
s, _ := newAdvServer(t, "tok")
|
||||
s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
|
||||
if w := placeAction(t, s, "stranger", "extract"); w.Code != 403 {
|
||||
t.Fatalf("order without an adventurer = %d, want 403", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnlyOneOutstandingOrderPerVerb. Two queued extracts apply in sequence and
|
||||
// the second answers "you weren't on an expedition" — a rejection for something
|
||||
// that worked, which is the worst thing the strip could say. The guard is per
|
||||
// verb, so a pending extract must not block a Siege bout.
|
||||
func TestOnlyOneOutstandingOrderPerVerb(t *testing.T) {
|
||||
s := seedActions(t, "holymachina", "expedition")
|
||||
postSiege(t, s, "tok", liveSiege(time.Now().Unix(), 800))
|
||||
|
||||
if w := placeAction(t, s, "holymachina", "extract"); w.Code != 200 {
|
||||
t.Fatalf("first extract = %d (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
w := placeAction(t, s, "holymachina", "extract")
|
||||
if w.Code != 409 {
|
||||
t.Fatalf("second extract = %d, want 409", w.Code)
|
||||
}
|
||||
if w := placeAction(t, s, "holymachina", "siege_join"); w.Code != 200 {
|
||||
t.Fatalf("bout blocked by a pending extract = %d (%s); the guard is per verb", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestActionPreChecksAreCourtesyOnly pins both halves of a deliberate asymmetry.
|
||||
// Pete refuses what its own snapshot says is impossible — but the snapshot is up
|
||||
// to two minutes old, so the refusal must be cheap and local (a 409 the button
|
||||
// shows immediately), never a queued order gogobee has to answer.
|
||||
func TestActionPreChecksAreCourtesyOnly(t *testing.T) {
|
||||
// Idle mark: extract refused up front.
|
||||
s := seedActions(t, "holymachina", "idle")
|
||||
if w := placeAction(t, s, "holymachina", "extract"); w.Code != 409 {
|
||||
t.Fatalf("extract while idle = %d, want 409", w.Code)
|
||||
}
|
||||
|
||||
// No Siege pushed at all: unknown, not "inactive". Pete has never heard from
|
||||
// gogobee about a boss, and refusing on that would make the button dead on a
|
||||
// fresh deploy. It must go through and let gogobee answer.
|
||||
if w := placeAction(t, s, "holymachina", "siege_join"); w.Code != 200 {
|
||||
t.Fatalf("bout with no siege snapshot at all = %d, want it queued", w.Code)
|
||||
}
|
||||
|
||||
// A snapshot that positively says no boss is camped: refuse.
|
||||
s2 := seedActions(t, "holymachina", "idle")
|
||||
now := time.Now().Unix()
|
||||
postSiege(t, s2, "tok", siegePush{SnapshotAt: now, Siege: storage.Siege{Active: false}})
|
||||
if w := placeAction(t, s2, "holymachina", "siege_join"); w.Code != 409 {
|
||||
t.Fatalf("bout with no boss camped = %d, want 409", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionOrderRejectsAnUnknownVerb(t *testing.T) {
|
||||
s := seedActions(t, "holymachina", "expedition")
|
||||
if w := placeAction(t, s, "holymachina", "sell_house"); w.Code != 400 {
|
||||
t.Fatalf("unknown action = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActionOrdersAreScopedToTheirOwner: the strip is read back by OIDC subject.
|
||||
// `as` signs every session as sub-1, so this drives the storage layer directly to
|
||||
// prove the scoping rather than pretending two sessions exist.
|
||||
func TestActionOrdersAreScopedToTheirOwner(t *testing.T) {
|
||||
s := seedActions(t, "holymachina", "expedition")
|
||||
if w := placeAction(t, s, "holymachina", "extract"); w.Code != 200 {
|
||||
t.Fatalf("place = %d", w.Code)
|
||||
}
|
||||
if _, err := storage.InsertAdvOrder("sub-2", "someone", "tok-other", "Other", storage.AdvActionExtract); err != nil {
|
||||
t.Fatalf("insert other: %v", err)
|
||||
}
|
||||
|
||||
r := as(t, s, "holymachina", "GET", "/api/adventure/orders", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleAdvOrders(w, r)
|
||||
var got []storage.AdvOrder
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].OwnerLocalpart != "holymachina" {
|
||||
t.Fatalf("orders = %+v, want only the signed-in owner's", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActionVerdictIsIdempotent: gogobee's poll loop retries, so the same verdict
|
||||
// arrives more than once and only the first may move the order. A second verdict
|
||||
// overwriting the first would let a re-offer's "no expedition to leave" replace
|
||||
// the "done" that was true.
|
||||
func TestActionVerdictIsIdempotent(t *testing.T) {
|
||||
s := seedActions(t, "holymachina", "expedition")
|
||||
w := placeAction(t, s, "holymachina", "extract")
|
||||
var order storage.AdvOrder
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &order)
|
||||
|
||||
first := postVerdict(t, s, "tok", advOrderVerdict{
|
||||
GUID: order.GUID, Status: storage.AdvOrderApplied, Detail: "Out on day 3.",
|
||||
})
|
||||
if first.Code != 200 {
|
||||
t.Fatalf("verdict = %d (%s)", first.Code, first.Body.String())
|
||||
}
|
||||
second := postVerdict(t, s, "tok", advOrderVerdict{
|
||||
GUID: order.GUID, Status: storage.AdvRejectedNotRunning, Detail: "no run",
|
||||
})
|
||||
if second.Code != 200 {
|
||||
t.Fatalf("retried verdict = %d, want a quiet 200", second.Code)
|
||||
}
|
||||
got, err := storage.AdvOrderByGUID(order.GUID)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if got.Status != storage.AdvOrderApplied || !strings.Contains(got.Detail, "day 3") {
|
||||
t.Fatalf("order = %q/%q, want the first verdict to stand", got.Status, got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActionWireNeedsTheBearerToken: the poll and the verdict are gogobee's, and
|
||||
// the pending list names every player who has asked for something.
|
||||
func TestActionWireNeedsTheBearerToken(t *testing.T) {
|
||||
s := seedActions(t, "holymachina", "expedition")
|
||||
placeAction(t, s, "holymachina", "extract")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/adventure/orders/pending", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleAdvOrdersPending(w, req)
|
||||
if w.Code != 401 {
|
||||
t.Fatalf("unauthed poll = %d, want 401", w.Code)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest("GET", "/api/adventure/orders/pending", nil)
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
w = httptest.NewRecorder()
|
||||
s.handleAdvOrdersPending(w, req)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("authed poll = %d", w.Code)
|
||||
}
|
||||
var pending []storage.AdvOrder
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].Action != storage.AdvActionExtract {
|
||||
t.Fatalf("pending = %+v, want the one queued extract", pending)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerdictForAnUnknownOrderIs400: under this seam's contract that parks the
|
||||
// row for a human rather than retrying forever against a row that can never
|
||||
// exist.
|
||||
func TestVerdictForAnUnknownOrderIs400(t *testing.T) {
|
||||
s := seedActions(t, "holymachina", "expedition")
|
||||
if w := postVerdict(t, s, "tok", advOrderVerdict{GUID: "nope", Status: storage.AdvOrderApplied}); w.Code != 400 {
|
||||
t.Fatalf("verdict for an unknown guid = %d, want 400", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func postVerdict(t *testing.T, s *Server, token string, v advOrderVerdict) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(v)
|
||||
req := httptest.NewRequest("POST", "/api/adventure/orders/verdict", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleAdvOrderVerdict(w, req)
|
||||
return w
|
||||
}
|
||||
@@ -295,6 +295,13 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
mux.HandleFunc("GET /api/equip/pending", s.handleEquipPending)
|
||||
mux.HandleFunc("POST /api/equip/verdict", s.handleEquipVerdict)
|
||||
|
||||
// The action queue's game-box wire: gogobee polls the verbs an owner asked for
|
||||
// from the web (pull out of a run, take today's bout) and pushes a verdict.
|
||||
// Bearer-authed for the same reason as every seam above it. Its own poll and
|
||||
// its own table, not more actions on the equip queue — see storage/orders.go.
|
||||
mux.HandleFunc("GET /api/adventure/orders/pending", s.handleAdvOrdersPending)
|
||||
mux.HandleFunc("POST /api/adventure/orders/verdict", s.handleAdvOrderVerdict)
|
||||
|
||||
// The casino. Signed-in only — there is money in it — so these hang off the
|
||||
// auth block, and gamesReady() also insists on a Matrix server name: without
|
||||
// one, no player can be named to gogobee's ledger and the tables stay shut.
|
||||
@@ -328,6 +335,12 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
// storefront, since without a board there is no detail page to equip from.
|
||||
mux.HandleFunc("POST /api/equip/order", s.handleEquipOrder)
|
||||
mux.HandleFunc("GET /api/equip/orders", s.handleEquipOrders)
|
||||
|
||||
// The action queue, owner side. Signed-in only — the session IS the
|
||||
// character, there is nothing in the request to identify one — and gated
|
||||
// on the adventure seam like everything else here.
|
||||
mux.HandleFunc("POST /api/adventure/order", s.handleAdvOrder)
|
||||
mux.HandleFunc("GET /api/adventure/orders", s.handleAdvOrders)
|
||||
}
|
||||
if s.cfg.Push.Enabled {
|
||||
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
|
||||
|
||||
+26
-1
@@ -82,6 +82,18 @@ type SiegePastView struct {
|
||||
type siegePage struct {
|
||||
pageData
|
||||
Siege SiegeView
|
||||
// The viewer's own standing in the muster, when they are signed in and have
|
||||
// an adventurer. This is the only personal thing on an otherwise wholly
|
||||
// public page, and it exists to hang one button off: the war room is where
|
||||
// somebody realises the town needs them, so it is where they should be able
|
||||
// to answer.
|
||||
//
|
||||
// YouFought reads a snapshot up to two minutes old, so it decides what the
|
||||
// page OFFERS and never what the game allows — a bout taken in Matrix inside
|
||||
// that window comes back from gogobee as rejected_already_fought, which is
|
||||
// the honest answer and the one the strip shows.
|
||||
YouOnBoard bool
|
||||
YouFought bool
|
||||
}
|
||||
|
||||
// handleSiegeIngest replaces the war room with gogobee's latest snapshot.
|
||||
@@ -153,11 +165,24 @@ func (s *Server) handleSiegePage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
base := s.base(r)
|
||||
base.Active = "adventure"
|
||||
view := s.siege()
|
||||
page := siegePage{pageData: base, Siege: view}
|
||||
if base.User != nil {
|
||||
if token, ok := storage.SelfToken(buyerLocalpart(base.User)); ok {
|
||||
page.YouOnBoard = true
|
||||
for _, d := range view.Fought {
|
||||
if d.Token == token {
|
||||
page.YouFought = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unlike the who page this one is NOT noindex: it names a boss and a town,
|
||||
// and the defender list is character names that are already public on the
|
||||
// board. There is nothing here that ties a page to a person more than
|
||||
// /adventure already does.
|
||||
s.render(w, "siege", siegePage{pageData: base, Siege: s.siege()})
|
||||
s.render(w, "siege", page)
|
||||
}
|
||||
|
||||
// handleSiegeAPI serves the war room as JSON for the page's own re-poll. This
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// The action queue, owner side — the first buttons on this site that play the
|
||||
// game rather than read it.
|
||||
//
|
||||
// Same honesty rule as the equip queue: clicking records an intent, and the game
|
||||
// box acts on its next poll. So nothing here ever says "done" on its own. It says
|
||||
// "asked for", then shows whatever verdict gogobee filed, including a refusal.
|
||||
//
|
||||
// Shared by the adventurer page (pull out of a run) and the war room (take
|
||||
// today's bout), which is why it lives in a file rather than inline in either.
|
||||
(function () {
|
||||
var panels = Array.prototype.slice.call(document.querySelectorAll('.adv-actions'));
|
||||
if (!panels.length) return; // not an owner, or not a page with actions
|
||||
|
||||
var list = document.getElementById('adv-action-orders');
|
||||
var box = document.getElementById('adv-action-orders-box');
|
||||
|
||||
// How each terminal status reads. gogobee's own detail line is preferred when
|
||||
// it sent one — it names the zone, the day, the damage — and these are the
|
||||
// fallback for a verdict that arrived without prose.
|
||||
var STATUS = {
|
||||
pending: 'asked for…',
|
||||
applied: 'done',
|
||||
rejected_not_running: "couldn't, you weren't on an expedition",
|
||||
rejected_not_leader: "couldn't, only the party leader can call it",
|
||||
rejected_no_siege: "couldn't, no Siege is camped outside town",
|
||||
rejected_already_fought: "couldn't, today's bout is already spent",
|
||||
rejected_unavailable: "couldn't right now"
|
||||
};
|
||||
|
||||
// syncOffers keeps the panel's own copy from outliving the truth. Watching it
|
||||
// run for real is what put this here: after a bout landed, the page went on
|
||||
// saying "your bout is unspent" above a dead button, under a verdict that said
|
||||
// the fight was over.
|
||||
//
|
||||
// Applied hides the offer, because the thing on offer has happened. A REFUSAL
|
||||
// puts the button back, and that asymmetry is the point: a refusal is often
|
||||
// about a stale page, and taking away the retry would leave them nothing to do
|
||||
// about it.
|
||||
function syncOffers(orders) {
|
||||
var newest = {};
|
||||
orders.forEach(function (o) { if (!(o.action in newest)) newest[o.action] = o; });
|
||||
Object.keys(newest).forEach(function (action) {
|
||||
var o = newest[action];
|
||||
if (o.status === 'pending') return; // still out; leave the button disabled
|
||||
var btn = document.querySelector('.adv-action-btn[data-action="' + action + '"]');
|
||||
if (!btn) return;
|
||||
var offer = btn.closest('[data-offer]') || btn;
|
||||
if (o.status === 'applied') {
|
||||
offer.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
// Both halves of the restore matter, and the second is easy to forget:
|
||||
// re-enabling a button inside a wrapper this function hid on an earlier
|
||||
// pass gives back a control nobody can see.
|
||||
offer.classList.remove('hidden');
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-50');
|
||||
btn.textContent = btn.getAttribute('data-label') || btn.textContent;
|
||||
});
|
||||
}
|
||||
|
||||
var VERB = { extract: 'Pull out', siege_join: 'Join the defence' };
|
||||
|
||||
var pollTimer = null;
|
||||
|
||||
function render(orders) {
|
||||
if (!list || !box) return;
|
||||
list.innerHTML = '';
|
||||
if (!orders || !orders.length) { box.classList.add('hidden'); return; }
|
||||
box.classList.remove('hidden');
|
||||
var anyPending = false;
|
||||
orders.forEach(function (o) {
|
||||
if (o.status === 'pending') anyPending = true;
|
||||
// Stacked, not the equip strip's justify-between row. That layout is right
|
||||
// for a two-word verdict and wrong here: gogobee answers a bout with a
|
||||
// whole sentence of damage numbers, which squeezed into a right-hand column
|
||||
// and pushed the verb itself onto two lines.
|
||||
var li = document.createElement('li');
|
||||
var verb = document.createElement('div');
|
||||
verb.className = 'font-semibold text-[color:var(--ink)]/70';
|
||||
verb.textContent = VERB[o.action] || o.action;
|
||||
var said = document.createElement('div');
|
||||
said.className = 'mt-0.5 leading-snug ' + (o.status === 'pending'
|
||||
? 'text-[color:var(--ink)]/45'
|
||||
: (o.status === 'applied' ? 'text-theme-adventure font-semibold' : 'text-[color:var(--warn)]'));
|
||||
said.textContent = o.detail || STATUS[o.status] || o.status;
|
||||
li.appendChild(verb); li.appendChild(said);
|
||||
list.appendChild(li);
|
||||
});
|
||||
syncOffers(orders);
|
||||
// Keep refreshing while anything is unanswered so the verdict lands without a
|
||||
// reload; stop once everything is terminal. A Siege bout runs a whole combat
|
||||
// on the game box, so this can legitimately sit on "asked for" for a while.
|
||||
if (anyPending && !pollTimer) {
|
||||
pollTimer = setInterval(loadOrders, 10000);
|
||||
} else if (!anyPending && pollTimer) {
|
||||
clearInterval(pollTimer); pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadOrders() {
|
||||
fetch('/api/adventure/orders', { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (o) { if (o) render(o); })
|
||||
.catch(function () { /* transient — a later tick will do */ });
|
||||
}
|
||||
|
||||
function placeOrder(btn) {
|
||||
btn.disabled = true;
|
||||
btn.classList.add('opacity-50');
|
||||
var was = btn.textContent;
|
||||
btn.textContent = 'asking…';
|
||||
fetch('/api/adventure/order', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: btn.getAttribute('data-action') })
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-50');
|
||||
btn.textContent = (res.body && res.body.error) || 'try again';
|
||||
setTimeout(function () { btn.textContent = was; }, 4000);
|
||||
return;
|
||||
}
|
||||
btn.textContent = 'asked for';
|
||||
loadOrders();
|
||||
})
|
||||
.catch(function () {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-50');
|
||||
btn.textContent = 'try again';
|
||||
setTimeout(function () { btn.textContent = was; }, 4000);
|
||||
});
|
||||
}
|
||||
|
||||
// Both verbs are one-way — an extraction ends the run for the whole party and a
|
||||
// bout is the only one you get today — so both confirm. Built in the DOM rather
|
||||
// than with confirm(), which would block the event loop and, on this site's own
|
||||
// evidence, wedge an automated browser.
|
||||
function askConfirm(btn) {
|
||||
var panel = btn.closest('.adv-actions') || btn.parentElement;
|
||||
var existing = panel.querySelector('.adv-action-confirm');
|
||||
if (existing) existing.remove();
|
||||
|
||||
var boxEl = document.createElement('div');
|
||||
boxEl.className = 'adv-action-confirm mt-3 rounded-xl bg-[color:var(--ink)]/5 p-3 text-sm';
|
||||
var p = document.createElement('p');
|
||||
p.className = 'text-[color:var(--ink)]/70';
|
||||
p.textContent = btn.getAttribute('data-confirm') || 'Are you sure?';
|
||||
var row = document.createElement('div');
|
||||
row.className = 'mt-2 flex gap-1.5';
|
||||
var yes = document.createElement('button');
|
||||
yes.type = 'button';
|
||||
yes.className = 'rounded-full bg-theme-adventure text-white px-3 py-1 font-semibold';
|
||||
yes.textContent = btn.getAttribute('data-confirm-label') || 'Yes, do it';
|
||||
yes.addEventListener('click', function () { boxEl.remove(); placeOrder(btn); });
|
||||
var no = document.createElement('button');
|
||||
no.type = 'button';
|
||||
no.className = 'rounded-full border border-[color:var(--ink)]/20 text-[color:var(--ink)]/60 px-3 py-1';
|
||||
no.textContent = 'Not yet';
|
||||
no.addEventListener('click', function () { boxEl.remove(); });
|
||||
row.appendChild(yes); row.appendChild(no);
|
||||
boxEl.appendChild(p); boxEl.appendChild(row);
|
||||
panel.appendChild(boxEl);
|
||||
}
|
||||
|
||||
panels.forEach(function (panel) {
|
||||
panel.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.adv-action-btn');
|
||||
if (!btn || btn.disabled) return;
|
||||
askConfirm(btn);
|
||||
});
|
||||
});
|
||||
|
||||
loadOrders();
|
||||
})();
|
||||
@@ -58,14 +58,46 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- How to actually join in. The bout is a Matrix command today; saying so
|
||||
plainly beats a page that shows a fight nobody can tell how to enter. -->
|
||||
<div class="mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-theme-adventure/30 p-5 shadow-pete">
|
||||
<p class="text-sm text-[color:var(--ink)]/75">
|
||||
<span class="font-semibold text-theme-adventure">Taking your bout:</span>
|
||||
say <code class="rounded bg-[color:var(--ink)]/8 px-1.5 py-0.5 font-mono text-xs">!adventure siege fight</code>
|
||||
to me in Matrix. One a day, each. Damage counts whether you win the fight or not — turning up is the mechanic.
|
||||
</p>
|
||||
<!-- How to actually join in. A signed-in adventurer can do it from here; the
|
||||
Matrix command stays on the page for everyone else, because it is still
|
||||
the only door for a visitor who isn't signed in — and the blow-by-blow of
|
||||
the fight arrives there whichever door you came through. -->
|
||||
<div id="adv-actions" class="adv-actions mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-theme-adventure/30 p-5 shadow-pete">
|
||||
{{if .YouOnBoard}}
|
||||
{{if .YouFought}}
|
||||
<p class="text-sm text-[color:var(--ink)]/75">
|
||||
<span class="font-semibold text-theme-adventure">You've taken your bout today.</span>
|
||||
Come back tomorrow. One fight each, per day, and everyone in the right-hand column below still has theirs.
|
||||
</p>
|
||||
{{else}}
|
||||
{{/* data-offer marks the half of this panel that stops being true the
|
||||
moment the bout lands. The script hides it on an applied verdict, so
|
||||
the page can't go on saying "unspent" over a fight that just
|
||||
happened. */}}
|
||||
<div data-offer>
|
||||
<p class="text-sm text-[color:var(--ink)]/75 mb-3">
|
||||
<span class="font-semibold text-theme-adventure">Your bout is unspent.</span>
|
||||
Damage counts whether you win the fight or not. Turning up is the mechanic, and the blow-by-blow lands in Matrix.
|
||||
</p>
|
||||
<button type="button"
|
||||
class="adv-action-btn rounded-full bg-theme-adventure text-white px-4 py-1.5 text-sm font-semibold hover:opacity-90 transition"
|
||||
data-action="siege_join"
|
||||
data-label="Take your bout"
|
||||
data-confirm-label="Yes, take my bout"
|
||||
data-confirm="Take your bout against this boss now? It's the only one you get today, and it costs real HP, though you can't die from it. The damage comes off the pool whether you win or lose.">Take your bout</button>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="text-sm text-[color:var(--ink)]/75">
|
||||
<span class="font-semibold text-theme-adventure">Taking your bout:</span>
|
||||
say <code class="rounded bg-[color:var(--ink)]/8 px-1.5 py-0.5 font-mono text-xs">!adventure siege fight</code>
|
||||
to me in Matrix. One a day, each. Damage counts whether you win the fight or not; turning up is the mechanic.
|
||||
</p>
|
||||
{{end}}
|
||||
|
||||
<div id="adv-action-orders-box" class="mt-4 hidden">
|
||||
<ul id="adv-action-orders" class="space-y-1.5 text-xs"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The muster. Two columns, and the right-hand one is the point: a bout not
|
||||
@@ -206,3 +238,5 @@
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}<script src="/static/js/adventure-actions.js" defer></script>{{end}}
|
||||
|
||||
@@ -195,6 +195,42 @@
|
||||
</a>
|
||||
</section>
|
||||
|
||||
{{if .HasSelf}}
|
||||
<!-- Your call. The panels above are a spectator view of a run going well or
|
||||
badly; this is the one thing the watcher can do about it. Owner-only, and
|
||||
shown whether or not the mark is currently on a run — the board is up to
|
||||
two minutes stale, so hiding the button on a snapshot that says "in town"
|
||||
would be the page refusing an action the game would have allowed. gogobee
|
||||
answers rejected_not_running if it really has ended, and that answer shows
|
||||
up in the strip below. -->
|
||||
<section id="adv-actions" class="adv-actions mt-6 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 shadow-pete">
|
||||
<h2 class="font-display text-xl font-bold mb-1">Your call</h2>
|
||||
<p class="text-sm text-[color:var(--ink)]/60 mb-4">
|
||||
Asked for here, done on the game box. It picks these up within a few seconds.
|
||||
</p>
|
||||
{{/* data-offer marks what stops being true once the extraction lands: there
|
||||
is no run left to pull out of. Hidden by the script on an applied
|
||||
verdict, restored on a refusal, which is the one case where the reader
|
||||
still needs the retry. */}}
|
||||
<div data-offer>
|
||||
<button type="button"
|
||||
class="adv-action-btn rounded-full border border-theme-adventure/40 text-theme-adventure hover:bg-theme-adventure/10 px-4 py-1.5 text-sm font-semibold transition-colors"
|
||||
data-action="extract"
|
||||
data-label="Pull out of the run"
|
||||
data-confirm-label="Yes, pull out"
|
||||
data-confirm="Pull out of the dungeon now? You keep the loot, XP and coins you're carrying, and the run waits where you left it: you have seven days to go back in. If you're leading a party, it ends the day for all of you.">Pull out of the run</button>
|
||||
</div>
|
||||
|
||||
<!-- The queue is honest: an action lands on the game box's next poll, so a
|
||||
fresh one reads "asked for", never "done". JS fills this from
|
||||
/api/adventure/orders. -->
|
||||
<div id="adv-action-orders-box" class="mt-5 hidden">
|
||||
<h3 class="font-display text-base font-bold mb-2">What you've asked for</h3>
|
||||
<ul id="adv-action-orders" class="space-y-1.5 text-xs"></ul>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .HasHistory}}
|
||||
<!-- The record. Public, like the dispatches it's counted from — this is the
|
||||
same information the /adventure feed already printed, only as numbers
|
||||
@@ -714,3 +750,5 @@
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}<script src="/static/js/adventure-actions.js" defer></script>{{end}}
|
||||
|
||||
Reference in New Issue
Block a user