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:
prosolis
2026-07-24 18:55:42 -07:00
parent b19ab5eff0
commit 6b0aae9f4a
10 changed files with 1178 additions and 9 deletions
+247
View File
@@ -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
}