adventure: let a player leave town from the web, not only read about it
W5a gave the web two verbs that cost nothing. These are the three that take arguments and spend coins: set out for a zone with a supply loadout, walk back into the run you extracted from, hire the pet sitter for a week or a month. Between them they cover the most common thing anybody does in the game, which until now could only be typed into Matrix. Arguments are the new surface, so they are the thing to be careful with. Nothing in a request is trusted: every zone, loadout and duration is looked up in the offer list gogobee pushed onto that owner's own private row, and the order stores what was found there rather than what was sent. A forged zone resolves to nothing and never becomes an order. gogobee then re-resolves all of it anyway, because an offer is a quote and a quote is not a permission. The money confirm is the equip panel's, lifted: cost, balance, and the balance it leaves. When it does not cover, it says so instead of printing a negative. Verified in a browser rather than only in tests, which is where both real defects came from: the confirm box was appending to the whole panel and so appeared at the bottom of the section instead of under the button that raised it, and button prices printed as EUR45000 above a dialog reading EUR45,000. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
+177
-1
@@ -150,7 +150,7 @@ func TestActionOrdersAreScopedToTheirOwner(t *testing.T) {
|
||||
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 {
|
||||
if _, err := storage.InsertAdvOrder("sub-2", "someone", "tok-other", "Other", storage.AdvActionExtract, nil); err != nil {
|
||||
t.Fatalf("insert other: %v", err)
|
||||
}
|
||||
|
||||
@@ -245,3 +245,179 @@ func postVerdict(t *testing.T, s *Server, token string, v advOrderVerdict) *http
|
||||
s.handleAdvOrderVerdict(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// ── W5b: the three verbs that take arguments ─────────────────────────────────
|
||||
|
||||
// seedOffers is seedActions with an offer list on the private detail row — which
|
||||
// is what gogobee pushes, and what every W5b param is resolved against.
|
||||
func seedOffers(t *testing.T, owner, status string, pd storage.PlayerDetail) *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, owner)
|
||||
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
|
||||
t.Fatalf("seed roster = %d", w.Code)
|
||||
}
|
||||
pd.Localpart = owner
|
||||
pd.Token = "tok-josie"
|
||||
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{pd}}); w.Code != 200 {
|
||||
t.Fatalf("seed detail = %d", w.Code)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func offeredZones() []storage.ZoneOffer {
|
||||
return []storage.ZoneOffer{{
|
||||
ID: "goblin_warrens", Display: "Goblin Warrens", Tier: 1,
|
||||
Loadouts: []storage.LoadoutOffer{
|
||||
{Key: "lean", Name: "lean", Cost: 40, Days: 3},
|
||||
{Key: "balanced", Name: "balanced", Cost: 80, Days: 5},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func placeParams(t *testing.T, s *Server, username string, req advOrderReq) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
r := as(t, s, username, "POST", "/api/adventure/order", req)
|
||||
w := httptest.NewRecorder()
|
||||
s.handleAdvOrder(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// The whole point of resolving params against the owner's own offer list: a
|
||||
// forged zone, or a loadout that zone does not sell, must never reach an order
|
||||
// row. gogobee would refuse them anyway — this is the cheap answer, thirty
|
||||
// seconds earlier, and it keeps the queue clean.
|
||||
func TestExpeditionParamsAreResolvedAgainstTheOwnersOffers(t *testing.T) {
|
||||
s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{Zones: offeredZones()})
|
||||
|
||||
if w := placeParams(t, s, "holymachina", advOrderReq{
|
||||
Action: storage.AdvActionExpedition, Zone: "dragons_lair", Loadout: "lean",
|
||||
}); w.Code != 409 {
|
||||
t.Fatalf("forged zone = %d, want 409 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if w := placeParams(t, s, "holymachina", advOrderReq{
|
||||
Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "enormous",
|
||||
}); w.Code != 409 {
|
||||
t.Fatalf("forged loadout = %d, want 409 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
w := placeParams(t, s, "holymachina", advOrderReq{
|
||||
Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "balanced",
|
||||
})
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("offered zone = %d, want 200 (%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.Params == nil || got.Params.Zone != "goblin_warrens" || got.Params.Loadout != "balanced" {
|
||||
t.Fatalf("params = %+v, want the resolved zone and loadout", got.Params)
|
||||
}
|
||||
// And they survive the round trip to gogobee's poll, which is the only reason
|
||||
// they are stored at all.
|
||||
pending, err := storage.PendingAdvOrders(10)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(pending) != 1 || pending[0].Params == nil || pending[0].Params.Zone != "goblin_warrens" {
|
||||
t.Fatalf("pending params lost in the round trip: %+v", pending)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty zone list means "already out there", not "nowhere to go" — gogobee
|
||||
// omits the offers entirely while the adventurer is on an expedition. Refusing
|
||||
// cheaply here beats a verdict thirty seconds later saying the same thing.
|
||||
func TestNoZoneOffersMeansAlreadyOut(t *testing.T) {
|
||||
s := seedOffers(t, "holymachina", "expedition", storage.PlayerDetail{})
|
||||
w := placeParams(t, s, "holymachina", advOrderReq{
|
||||
Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "lean",
|
||||
})
|
||||
if w.Code != 409 {
|
||||
t.Fatalf("departure with no offers = %d, want 409 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The sitter sells two durations and nothing else, and is not sold twice.
|
||||
func TestBabysitParamsAreTheTwoDurationsOnly(t *testing.T) {
|
||||
s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{
|
||||
Babysit: &storage.BabysitOffer{WeekCost: 700, MonthCost: 3000},
|
||||
})
|
||||
for _, days := range []int{0, 3, 365} {
|
||||
if w := placeParams(t, s, "holymachina", advOrderReq{
|
||||
Action: storage.AdvActionBabysit, Days: days,
|
||||
}); w.Code != 409 {
|
||||
t.Fatalf("%d-day sitter = %d, want 409", days, w.Code)
|
||||
}
|
||||
}
|
||||
if w := placeParams(t, s, "holymachina", advOrderReq{
|
||||
Action: storage.AdvActionBabysit, Days: 30,
|
||||
}); w.Code != 200 {
|
||||
t.Fatalf("month = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Already engaged: the page should not be offering this at all, but a stale
|
||||
// tab can still post it.
|
||||
s2 := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{
|
||||
Babysit: &storage.BabysitOffer{Active: true, WeekCost: 700, MonthCost: 3000},
|
||||
})
|
||||
if w := placeParams(t, s2, "holymachina", advOrderReq{
|
||||
Action: storage.AdvActionBabysit, Days: 7,
|
||||
}); w.Code != 409 {
|
||||
t.Fatalf("second sitter = %d, want 409", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Resume is refused when the snapshot positively says there is nothing waiting,
|
||||
// and accepted with a loadout the offer actually lists.
|
||||
func TestResumeParamsNeedAnOfferedLoadout(t *testing.T) {
|
||||
s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{})
|
||||
if w := placeParams(t, s, "holymachina", advOrderReq{
|
||||
Action: storage.AdvActionResume, Loadout: "lean",
|
||||
}); w.Code != 409 {
|
||||
t.Fatalf("resume with nothing waiting = %d, want 409", w.Code)
|
||||
}
|
||||
|
||||
s2 := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{
|
||||
Resume: &storage.ResumeOffer{ZoneID: "goblin_warrens", Display: "Goblin Warrens", Day: 3,
|
||||
Loadouts: []storage.LoadoutOffer{{Key: "lean", Name: "lean", Cost: 40, Days: 3}}},
|
||||
})
|
||||
if w := placeParams(t, s2, "holymachina", advOrderReq{
|
||||
Action: storage.AdvActionResume, Loadout: "heavy",
|
||||
}); w.Code != 409 {
|
||||
t.Fatalf("unoffered loadout = %d, want 409", w.Code)
|
||||
}
|
||||
if w := placeParams(t, s2, "holymachina", advOrderReq{
|
||||
Action: storage.AdvActionResume, Loadout: "lean",
|
||||
}); w.Code != 200 {
|
||||
t.Fatalf("offered loadout = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The offer list is the whole gate, so it is worth pinning that an empty one is
|
||||
// a refusal rather than a pass-through: gogobee omits the zones while the
|
||||
// adventurer is out, and a pass-through there would queue a departure that is
|
||||
// certain to come back "you're already on expedition".
|
||||
//
|
||||
// There is deliberately no "Pete has never heard of this player" case to test:
|
||||
// the detail row this resolves against is the same row the ownership check
|
||||
// already found, so it always exists by then. A gogobee too old to push offers
|
||||
// yields an empty list and the page renders no picker at all.
|
||||
func TestParamsResolveOnlyAgainstAPushedOffer(t *testing.T) {
|
||||
s := seedOffers(t, "holymachina", "idle", storage.PlayerDetail{Zones: offeredZones()})
|
||||
if w := placeParams(t, s, "holymachina", advOrderReq{
|
||||
Action: storage.AdvActionExpedition, Zone: "goblin_warrens", Loadout: "lean",
|
||||
}); w.Code != 200 {
|
||||
t.Fatalf("offered zone = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
// Resume is not on offer for this player at all, so it is refused even though
|
||||
// the loadout key is a real one from the zone list above.
|
||||
if w := placeParams(t, s, "holymachina", advOrderReq{
|
||||
Action: storage.AdvActionResume, Loadout: "lean",
|
||||
}); w.Code != 409 {
|
||||
t.Fatalf("resume with no offer = %d, want 409", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user