adventure: let a player back out from the web, not only in Matrix

Three verbs to match gogobee's: call off an expedition, turn back out of
somebody else's party, send the pet sitter home. Which one this page
offers is derived here rather than pushed — leadership is already legible
in the party seats and the sitter's standing is already in the babysit
offer, so nothing new crosses the wire.

Two things running it turned up that no test would have. An applied
abandon left "Pull out of the run" sitting under a verdict saying the
expedition was over, so an applied verb now also hides the other verbs it
just made untrue. And a party member was being offered that same button
in the first place, beside the one that actually works — Pete knows from
the seat it just read that gogobee would refuse it, so it is withheld.

Also: heal the Matrix handle onto push rows stored before the column
existed, on its own endpoint rather than through the subscribe upsert,
which resets both watermarks and would have silenced the digest for
anybody who reads the site regularly. And stack the board row below sm —
four flex columns that wrapped to six lines on a phone, pre-existing.
This commit is contained in:
prosolis
2026-07-24 21:42:59 -07:00
parent 0d8dba90df
commit b07abc1d13
15 changed files with 780 additions and 30 deletions
+27 -2
View File
@@ -72,12 +72,29 @@ type AdvOrderParams struct {
// verdict tells people to type !resume, and this is the door. // verdict tells people to type !resume, and this is the door.
// babysit engage the pet sitter for a week or a month — `!adventure // babysit engage the pet sitter for a week or a month — `!adventure
// babysit week|month`. // babysit week|month`.
//
// W9 adds the three that undo the ones above. Each was already named inside a
// refusal or a confirm this page shows — "`!expedition abandon` first",
// "`!expedition leave` to walk out alone", "no refund if you cancel early" — so
// until now the web told people to go and type a command it could have offered.
// None takes an argument and none spends a euro:
//
// expedition_abandon end the expedition outright, for the whole party. Leader
// only, which gogobee enforces. Also the way to close an
// extracted run without paying to walk back into it first.
// expedition_leave walk out of somebody else's party alone, supplies left in
// the pool. Member only — the leader's row IS the expedition.
// babysit_cancel dismiss the sitter early. No refund, by the game's design.
const ( const (
AdvActionExtract = "extract" AdvActionExtract = "extract"
AdvActionSiegeJoin = "siege_join" AdvActionSiegeJoin = "siege_join"
AdvActionExpedition = "expedition_start" AdvActionExpedition = "expedition_start"
AdvActionResume = "expedition_resume" AdvActionResume = "expedition_resume"
AdvActionBabysit = "babysit" AdvActionBabysit = "babysit"
AdvActionAbandon = "expedition_abandon"
AdvActionLeave = "expedition_leave"
AdvActionBabysitCancel = "babysit_cancel"
) )
// Order states. Terminal states are enumerated rather than free-text so the page // Order states. Terminal states are enumerated rather than free-text so the page
@@ -98,12 +115,20 @@ const (
AdvRejectedInsufficientFunds = "rejected_insufficient_funds" // could not cover the cost AdvRejectedInsufficientFunds = "rejected_insufficient_funds" // could not cover the cost
AdvRejectedZoneLocked = "rejected_zone_locked" // that zone is not open at this level AdvRejectedZoneLocked = "rejected_zone_locked" // that zone is not open at this level
AdvRejectedNothingToResume = "rejected_nothing_to_resume" // nothing extracted, or its window closed AdvRejectedNothingToResume = "rejected_nothing_to_resume" // nothing extracted, or its window closed
// W9's two. rejected_is_leader is deliberately not rejected_not_leader read
// backwards: they are opposite facts about the same person, and collapsing
// them would answer a leader who tried to walk out by telling them they are
// not the leader.
AdvRejectedIsLeader = "rejected_is_leader" // expedition_leave: the leader's row is the expedition
AdvRejectedNothingToCancel = "rejected_nothing_to_cancel" // babysit_cancel: no sitter is engaged
) )
func validAdvAction(action string) bool { func validAdvAction(action string) bool {
switch action { switch action {
case AdvActionExtract, AdvActionSiegeJoin, case AdvActionExtract, AdvActionSiegeJoin,
AdvActionExpedition, AdvActionResume, AdvActionBabysit: AdvActionExpedition, AdvActionResume, AdvActionBabysit,
AdvActionAbandon, AdvActionLeave, AdvActionBabysitCancel:
return true return true
} }
return false return false
@@ -115,7 +140,7 @@ func validAdvVerdict(status string) bool {
case AdvOrderApplied, AdvRejectedNotRunning, AdvRejectedNotLeader, case AdvOrderApplied, AdvRejectedNotRunning, AdvRejectedNotLeader,
AdvRejectedNoSiege, AdvRejectedAlreadyFought, AdvRejectedUnavailable, AdvRejectedNoSiege, AdvRejectedAlreadyFought, AdvRejectedUnavailable,
AdvRejectedBusy, AdvRejectedInsufficientFunds, AdvRejectedZoneLocked, AdvRejectedBusy, AdvRejectedInsufficientFunds, AdvRejectedZoneLocked,
AdvRejectedNothingToResume: AdvRejectedNothingToResume, AdvRejectedIsLeader, AdvRejectedNothingToCancel:
return true return true
} }
return false return false
+27
View File
@@ -45,6 +45,33 @@ func AddPushSubscription(sub, localpart, endpoint, p256dh, auth string) error {
return nil return nil
} }
// HealPushSubscriptionLocalpart fills in the Matrix handle on a row that was
// stored before push_subscriptions had the column — the rows that can never match
// an owner-scoped adventure alert, and whose owners have no way to notice.
//
// It is deliberately NOT AddPushSubscription with the same arguments. That upsert
// resets both watermarks to now, which is right when somebody opts in and
// catastrophic on a heal: the browser would call it on every page load, so a
// reader who visits daily would silently never receive a digest or an alert
// again. This touches one column and no clock.
//
// Scoped to user_sub so presenting somebody else's endpoint rewrites nothing, and
// restricted to rows whose localpart is still empty — so it is a no-op after the
// first success, and it can never overwrite a good handle with a stale one.
func HealPushSubscriptionLocalpart(sub, endpoint, localpart string) error {
if localpart == "" {
return nil // nothing to heal with; see AddPushSubscription on empty handles
}
_, err := Get().Exec(
`UPDATE push_subscriptions SET user_localpart = ?
WHERE endpoint = ? AND user_sub = ? AND user_localpart = ''`,
localpart, endpoint, sub)
if err != nil {
return fmt.Errorf("heal push subscription: %w", err)
}
return nil
}
// RemovePushSubscription drops one endpoint regardless of owner. Reserved for // RemovePushSubscription drops one endpoint regardless of owner. Reserved for
// the digest sender's prune path, where a push service has reported the endpoint // the digest sender's prune path, where a push service has reported the endpoint
// gone (404/410) and there's no caller identity to scope by. User-initiated // gone (404/410) and there's no caller identity to scope by. User-initiated
+122
View File
@@ -0,0 +1,122 @@
package storage
import "testing"
// W9: healing the Matrix handle onto a subscription stored before the column
// existed. Those rows can never match an owner-scoped adventure alert, and their
// owners have no way to notice — the browser only re-subscribes on a click.
//
// The trap this exists to avoid is worth stating plainly, because the obvious
// implementation is a one-liner that reuses AddPushSubscription with the same
// arguments: that upsert resets BOTH watermarks to now. The heal runs from the
// page, so it would fire far more often than a subscribe does, and every run
// would push the digest's own "last told them about" stamp forward — a reader who
// visits daily would silently stop receiving digests and adventure alerts alike,
// from a change made to fix notifications.
func findSub(t *testing.T, endpoint string) PushSubscription {
t.Helper()
subs, err := ListPushSubscriptions()
if err != nil {
t.Fatal(err)
}
for _, s := range subs {
if s.Endpoint == endpoint {
return s
}
}
t.Fatalf("no subscription for %q", endpoint)
return PushSubscription{}
}
func TestHealFillsAnEmptyLocalpartAndNothingElse(t *testing.T) {
setupTestDB(t)
const ep = "https://push.example/ep-old"
// A row as a pre-W6 build left it: no Matrix handle.
if err := AddPushSubscription("sub-1", "", ep, "p256", "auth"); err != nil {
t.Fatal(err)
}
before := findSub(t, ep)
if before.Localpart != "" {
t.Fatalf("seed carries a localpart %q; the test isn't testing anything", before.Localpart)
}
// Move both watermarks off "now" so a reset would be visible rather than
// coincidentally equal.
if err := TouchPushSubscription(ep, 1000); err != nil {
t.Fatal(err)
}
if err := TouchAdvPushSubscription(ep, 2000); err != nil {
t.Fatal(err)
}
if err := HealPushSubscriptionLocalpart("sub-1", ep, "josie"); err != nil {
t.Fatal(err)
}
got := findSub(t, ep)
if got.Localpart != "josie" {
t.Fatalf("localpart = %q, want josie", got.Localpart)
}
// The whole point: the clocks did not move.
if got.LastNotifiedAt != 1000 {
t.Fatalf("digest watermark = %d, want 1000 — a heal that resets it silences the digest",
got.LastNotifiedAt)
}
if got.LastAdvNotifiedAt != 2000 {
t.Fatalf("adventure watermark = %d, want 2000 — a heal that resets it silences the alerts",
got.LastAdvNotifiedAt)
}
if got.P256dh != "p256" || got.Auth != "auth" {
t.Fatal("the heal rewrote the encryption keys; it must touch one column")
}
}
func TestHealNeverOverwritesAKnownHandle(t *testing.T) {
setupTestDB(t)
const ep = "https://push.example/ep-good"
if err := AddPushSubscription("sub-1", "josie", ep, "p256", "auth"); err != nil {
t.Fatal(err)
}
// A later session whose username resolved differently must not be able to
// rewrite a handle that is already good — the heal is for empty rows only, so
// it is a no-op the moment one has succeeded.
if err := HealPushSubscriptionLocalpart("sub-1", ep, "someone-else"); err != nil {
t.Fatal(err)
}
if got := findSub(t, ep); got.Localpart != "josie" {
t.Fatalf("localpart = %q, want the original josie", got.Localpart)
}
}
func TestHealIsScopedToTheCaller(t *testing.T) {
setupTestDB(t)
const ep = "https://push.example/ep-theirs"
if err := AddPushSubscription("sub-owner", "", ep, "p256", "auth"); err != nil {
t.Fatal(err)
}
// Somebody else presenting the endpoint string writes nothing. Endpoints are
// not secrets and the client hands one straight up, so this is the guard that
// stops a stranger attaching their own handle to another account's device.
if err := HealPushSubscriptionLocalpart("sub-attacker", ep, "attacker"); err != nil {
t.Fatal(err)
}
if got := findSub(t, ep); got.Localpart != "" {
t.Fatalf("localpart = %q; another account healed a row it does not own", got.Localpart)
}
}
func TestHealWithNoHandleIsANoOp(t *testing.T) {
setupTestDB(t)
const ep = "https://push.example/ep-nouser"
if err := AddPushSubscription("sub-1", "", ep, "p256", "auth"); err != nil {
t.Fatal(err)
}
// A session minted before the game economy existed carries no username. There
// is nothing to heal with, and writing "" over "" is not worth a statement.
if err := HealPushSubscriptionLocalpart("sub-1", ep, ""); err != nil {
t.Fatal(err)
}
if got := findSub(t, ep); got.Localpart != "" {
t.Fatalf("localpart = %q, want empty", got.Localpart)
}
}
+9 -3
View File
@@ -404,11 +404,17 @@ CREATE INDEX IF NOT EXISTS idx_equip_orders_owner ON equip_orders(owner_sub, cre
-- The status ladder: -- The status ladder:
-- --
-- pending -> applied (it happened; detail says what) -- pending -> applied (it happened; detail says what)
-- -> rejected_not_running (extract: no expedition to leave) -- -> rejected_not_running (extract/abandon/leave: no expedition)
-- -> rejected_not_leader (extract: a party member can't call it) -- -> rejected_not_leader (extract/abandon: a member can't call it)
-- -> rejected_is_leader (leave: the leader's row IS the expedition)
-- -> rejected_no_siege (siege_join: nothing camped outside town) -- -> rejected_no_siege (siege_join: nothing camped outside town)
-- -> rejected_already_fought (siege_join: today's bout is already spent) -- -> rejected_already_fought (siege_join: today's bout is already spent)
-- -> rejected_unavailable (no character, or dead) -- -> rejected_busy (already out, seated, or has a sitter)
-- -> rejected_insufficient_funds (could not cover the cost)
-- -> rejected_zone_locked (expedition_start: not open at this level)
-- -> rejected_nothing_to_resume (nothing extracted, or the window closed)
-- -> rejected_nothing_to_cancel (babysit_cancel: no sitter is engaged)
-- -> rejected_unavailable (no character, dead, or an unsold argument)
-- --
-- Like the equip queue, the underlying game action is NOT idempotent — an extract -- Like the equip queue, the underlying game action is NOT idempotent — an extract
-- ends an expedition and a bout spends a day — so gogobee short-circuits on the -- ends an expedition and a bout spends a day — so gogobee short-circuits on the
+25 -2
View File
@@ -72,7 +72,8 @@ func (s *Server) handleAdvOrder(w http.ResponseWriter, r *http.Request) {
} }
switch req.Action { switch req.Action {
case storage.AdvActionExtract, storage.AdvActionSiegeJoin, case storage.AdvActionExtract, storage.AdvActionSiegeJoin,
storage.AdvActionExpedition, storage.AdvActionResume, storage.AdvActionBabysit: storage.AdvActionExpedition, storage.AdvActionResume, storage.AdvActionBabysit,
storage.AdvActionAbandon, storage.AdvActionLeave, storage.AdvActionBabysitCancel:
default: default:
writeAdvOrderError(w, http.StatusBadRequest, "bad action") writeAdvOrderError(w, http.StatusBadRequest, "bad action")
return return
@@ -165,7 +166,9 @@ func (s *Server) handleAdvOrder(w http.ResponseWriter, r *http.Request) {
// resolveAdvOrderParams turns the browser's arguments into the stored ones by // resolveAdvOrderParams turns the browser's arguments into the stored ones by
// looking each up in the owner's pushed offer list, and returns the reason to // looking each up in the owner's pushed offer list, and returns the reason to
// refuse when it cannot. The two W5a verbs take no arguments and resolve to nil. // refuse when it cannot. Five of the eight verbs take no arguments and resolve to
// nil — but babysit_cancel still comes through here, because the offer row is
// the one place Pete can see that there is no sitter to dismiss.
// //
// Note what W5a's "Pete has never heard about it" asymmetry does NOT need to // Note what W5a's "Pete has never heard about it" asymmetry does NOT need to
// become here. There is no such state to defer on: the detail row this reads is // become here. There is no such state to defer on: the detail row this reads is
@@ -177,6 +180,15 @@ func resolveAdvOrderParams(owner, token string, req advOrderReq) (*storage.AdvOr
switch req.Action { switch req.Action {
case storage.AdvActionExtract, storage.AdvActionSiegeJoin: case storage.AdvActionExtract, storage.AdvActionSiegeJoin:
return nil, "" return nil, ""
case storage.AdvActionAbandon, storage.AdvActionLeave:
// No snapshot pre-check for either, deliberately, and it is the same call
// W5a made for extract: the board is up to two minutes stale, so the only
// thing Pete could test — "the mark reads idle" — would refuse actions the
// game would have allowed. Abandon is worse than extract in that respect,
// because an *extracted* expedition is still abandonable while its owner
// reads as standing in town. gogobee answers rejected_not_running /
// rejected_not_leader / rejected_is_leader, and the strip shows it.
return nil, ""
} }
detail, haveDetail, err := storage.PlayerDetailByOwner(owner, token) detail, haveDetail, err := storage.PlayerDetailByOwner(owner, token)
if err != nil { if err != nil {
@@ -229,6 +241,17 @@ func resolveAdvOrderParams(owner, token string, req advOrderReq) (*storage.AdvOr
return nil, "a sitter is already looking after your camp" return nil, "a sitter is already looking after your camp"
} }
return &storage.AdvOrderParams{Days: req.Days}, "" return &storage.AdvOrderParams{Days: req.Days}, ""
case storage.AdvActionBabysitCancel:
// The mirror of the check above, and the one W9 verb where the snapshot
// really does contradict the request: a sitter's engagement is a fact about
// the character, not about where they are standing, so it does not go stale
// the way "on an expedition" does. A missing offer is still not a refusal —
// that is a gogobee too old to push one, not a player without a sitter.
if detail.Babysit != nil && !detail.Babysit.Active {
return nil, "there's no sitter to dismiss"
}
return nil, ""
} }
return nil, "bad action" return nil, "bad action"
} }
+253
View File
@@ -0,0 +1,253 @@
package web
import (
"testing"
"time"
"pete/internal/storage"
)
// W9: the three verbs that undo something. Two halves are worth pinning.
//
// The first is offersToUndo, which is the only place on this site where Pete
// decides what a player MAY do from facts rather than from a list gogobee handed
// it. Getting it wrong in the generous direction puts "Call the whole thing off"
// — a button that throws away four people's day — in front of somebody who is not
// the leader, so the interesting cases are the ones where it must stay quiet.
//
// The second is that the two new verdict names round-trip. gogobee 400s on an
// unknown verdict and parks the order, so a name that exists on one side and not
// the other is a player watching "asked for…" forever.
func seat(kind, name, token string, level int) partySeat {
return partySeat{Kind: kind, Name: name, Token: token, Level: level}
}
// TestOffersToUndoReadsTheViewersOwnSeat is the core of the phase. A shared
// expedition publishes a seat per body, so which button this page offers is
// decided by finding the viewer among them — never by "there is a party, so
// somebody can abandon it".
func TestOffersToUndoReadsTheViewersOwnSeat(t *testing.T) {
party := []partySeat{
seat("leader", "Josie", "tok-josie", 14),
seat("member", "Camcast", "tok-cam", 11),
seat("companion", "Pete", "", 9),
}
cases := []struct {
name string
token, status string
haveParty bool
party []partySeat
self storage.PlayerDetail
abandon, leave, cancel bool
}{
{
name: "leader of a party is offered the abandon",
token: "tok-josie", status: "expedition", haveParty: true, party: party,
abandon: true,
},
{
name: "member of a party is offered the exit, never the abandon",
token: "tok-cam", status: "expedition", haveParty: true, party: party,
leave: true,
},
{
// A solo run publishes no party at all (partySeatViews returns nil below
// two seats), so an empty list on a live run means "nobody else", not
// "we don't know" — and the one body down there is the leader.
name: "solo run is offered the abandon",
token: "tok-josie", status: "expedition", haveParty: true,
abandon: true,
},
{
// The fail-closed case. A party we cannot find ourselves in is a
// snapshot we do not understand, and the safe answer is to offer
// nothing rather than guess which of the two buttons applies.
name: "a party with no seat for the viewer offers nothing",
token: "tok-nobody", status: "expedition", haveParty: true, party: party,
},
{
// The one that came out of running it. An undecodable public sheet
// gives the same empty slice as a solo run, and treating the two alike
// offered a party MEMBER the abandon — convincingly, with the rest of
// the page looking fine. haveParty is what tells them apart.
name: "a run whose sheet did not decode offers nothing",
token: "tok-cam", status: "expedition", haveParty: false,
},
{
name: "standing in town with nothing open offers nothing",
token: "tok-josie", status: "idle", haveParty: true, party: nil,
},
{
// The case the roster status cannot see: an extracted expedition is
// still its owner's to close, and its owner reads as idle in town with
// no party. The resume offer is the only sign the run is still open.
name: "an extracted run is abandonable from town",
token: "tok-josie", status: "idle",
self: storage.PlayerDetail{Resume: &storage.ResumeOffer{ZoneID: "holymachina", Day: 3}},
abandon: true,
},
{
name: "an engaged sitter can be sent home",
token: "tok-josie", status: "idle",
self: storage.PlayerDetail{Babysit: &storage.BabysitOffer{Active: true}},
cancel: true,
},
{
name: "an unengaged sitter cannot",
token: "tok-josie", status: "idle",
self: storage.PlayerDetail{Babysit: &storage.BabysitOffer{Active: false, WeekCost: 700}},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
abandon, leave, cancel := offersToUndo(tc.token, tc.status, tc.haveParty, tc.party, tc.self)
if abandon != tc.abandon || leave != tc.leave || cancel != tc.cancel {
t.Fatalf("offers = abandon:%v leave:%v cancel:%v, want abandon:%v leave:%v cancel:%v",
abandon, leave, cancel, tc.abandon, tc.leave, tc.cancel)
}
})
}
}
// TestAbandonAndLeaveAreNeverBothOffered. They are opposite claims about the
// same person, and a page showing both would be asking the reader to work out
// which one they are. No input may produce the pair.
//
// This test found a real one: a member seated in somebody else's live run who
// ALSO has their own extracted run waiting has both facts true at once, about two
// different expeditions. offersToUndo suppresses the abandon in that case; see
// the comment on the Resume clause.
func TestAbandonAndLeaveAreNeverBothOffered(t *testing.T) {
for _, kind := range []string{"leader", "member", "companion", "", "nonsense"} {
party := []partySeat{seat("leader", "Josie", "tok-josie", 14), seat(kind, "Me", "tok-me", 8)}
for _, status := range []string{"expedition", "idle"} {
for _, resume := range []*storage.ResumeOffer{nil, {ZoneID: "z", Day: 2}} {
abandon, leave, _ := offersToUndo("tok-me", status, true, party, storage.PlayerDetail{Resume: resume})
if abandon && leave {
t.Fatalf("kind=%q status=%q resume=%v offered both ways out at once", kind, status, resume != nil)
}
}
}
}
}
// TestUndoOrdersAreAccepted: the three verbs must survive the action allow-list
// and land as pending orders. A verb Pete does not know is a 400 at the door,
// which is a dead button rather than a refusal anybody can read.
func TestUndoOrdersAreAccepted(t *testing.T) {
s := seedActions(t, "holymachina", "expedition")
for _, action := range []string{storage.AdvActionAbandon, storage.AdvActionLeave} {
if w := placeAction(t, s, "holymachina", action); w.Code != 200 {
t.Fatalf("%s = %d (%s)", action, w.Code, w.Body.String())
}
}
// Per verb, so the two do not block each other or anything already queued.
pending, err := storage.PendingAdvOrders(0)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 2 {
t.Fatalf("pending = %d orders, want 2", len(pending))
}
}
// TestAbandonIsOfferedToAMarkStandingInTown is W5a's asymmetry restated for the
// two expedition verbs, and it is the reason neither has a snapshot pre-check.
// The board is up to two minutes stale, and an EXTRACTED run is abandonable
// while its owner reads as idle — so refusing on "the mark is in town" would
// refuse the case the verb exists for. gogobee answers rejected_not_running if
// the run really has gone.
func TestAbandonIsOfferedToAMarkStandingInTown(t *testing.T) {
s := seedActions(t, "holymachina", "idle")
if w := placeAction(t, s, "holymachina", storage.AdvActionAbandon); w.Code != 200 {
t.Fatalf("abandon from town = %d (%s), want it queued and answered by the game box",
w.Code, w.Body.String())
}
if w := placeAction(t, s, "holymachina", storage.AdvActionLeave); w.Code != 200 {
t.Fatalf("leave from town = %d (%s)", w.Code, w.Body.String())
}
}
// TestBabysitCancelRefusesWhenThereIsNoSitter is the one W9 pre-check that IS
// allowed to be the last word locally, and the comment in resolveAdvOrderParams
// says why: an engagement is a fact about the character rather than about where
// they are standing, so it does not go stale the way "on an expedition" does.
//
// A MISSING offer is still not a refusal — that is a gogobee too old to push one,
// not a player without a sitter — and the second half here pins that.
func TestBabysitCancelRefusesWhenThereIsNoSitter(t *testing.T) {
s := seedActions(t, "holymachina", "idle")
now := time.Now().Unix()
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
Localpart: "holymachina", Token: "tok-josie",
Babysit: &storage.BabysitOffer{Active: false, WeekCost: 700, MonthCost: 2400},
}}}); w.Code != 200 {
t.Fatalf("detail push = %d", w.Code)
}
if w := placeAction(t, s, "holymachina", storage.AdvActionBabysitCancel); w.Code != 409 {
t.Fatalf("cancel with no sitter = %d, want 409", w.Code)
}
// Sitter engaged: allowed.
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
Localpart: "holymachina", Token: "tok-josie",
Babysit: &storage.BabysitOffer{Active: true, WeekCost: 700, MonthCost: 2400},
}}}); w.Code != 200 {
t.Fatalf("detail push = %d", w.Code)
}
if w := placeAction(t, s, "holymachina", storage.AdvActionBabysitCancel); w.Code != 200 {
t.Fatalf("cancel with a sitter = %d (%s)", w.Code, w.Body.String())
}
// No babysit offer at all: a gogobee that predates the offer push. Queue it
// and let the game box answer, rather than making the button dead.
if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
Localpart: "holymachina", Token: "tok-josie",
}}}); w.Code != 200 {
t.Fatalf("detail push = %d", w.Code)
}
storage.Get().Exec(`DELETE FROM adventure_orders`)
if w := placeAction(t, s, "holymachina", storage.AdvActionBabysitCancel); w.Code != 200 {
t.Fatalf("cancel with no offer pushed = %d (%s), want it deferred to gogobee",
w.Code, w.Body.String())
}
}
// TestNewVerdictsRoundTrip: gogobee 400s on a verdict Pete will not take, and
// that parks the order — the player watches "asked for…" and nothing ever
// answers. So every status the game box can file has to be accepted here.
func TestNewVerdictsRoundTrip(t *testing.T) {
s := seedActions(t, "holymachina", "expedition")
for _, tc := range []struct{ action, verdict string }{
{storage.AdvActionLeave, storage.AdvRejectedIsLeader},
{storage.AdvActionBabysitCancel, storage.AdvRejectedNothingToCancel},
{storage.AdvActionAbandon, storage.AdvRejectedNotLeader},
} {
storage.Get().Exec(`DELETE FROM adventure_orders`)
w := placeAction(t, s, "holymachina", tc.action)
if w.Code != 200 {
t.Fatalf("place %s = %d (%s)", tc.action, w.Code, w.Body.String())
}
pending, err := storage.PendingAdvOrders(0)
if err != nil || len(pending) != 1 {
t.Fatalf("pending = %v (%v)", pending, err)
}
rec := postVerdict(t, s, "tok", advOrderVerdict{
GUID: pending[0].GUID, Status: tc.verdict, Detail: "because.",
})
if rec.Code != 200 {
t.Fatalf("verdict %s = %d (%s) — an unknown status parks the order forever",
tc.verdict, rec.Code, rec.Body.String())
}
got, err := storage.AdvOrderByGUID(pending[0].GUID)
if err != nil {
t.Fatalf("read back: %v", err)
}
if got.Status != tc.verdict {
t.Fatalf("stored status = %q, want %q", got.Status, tc.verdict)
}
}
}
+41
View File
@@ -59,6 +59,47 @@ func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// handlePushHeal fills in the Matrix handle on a subscription stored before the
// column existed. W6 shipped owner-scoped adventure alerts keyed on the
// localpart, and every row that predates it carries an empty one — so those
// subscribers get the realm-wide Siege alerts and silently never get the ones
// about their own adventurer. Nothing in the browser re-subscribes on its own
// (pwa.js only calls subscribe() on a click), so without this they stay broken
// until they happen to toggle notifications off and on again.
//
// It takes only an endpoint, and it is deliberately not a subscribe: see
// HealPushSubscriptionLocalpart on why re-using the upsert here would have
// silenced the digest for anybody who reads the site regularly.
func (s *Server) handlePushHeal(w http.ResponseWriter, r *http.Request) {
u := s.requireUser(w, r)
if u == nil {
return
}
if !s.cfg.Push.Enabled {
http.Error(w, `{"error":"push disabled"}`, http.StatusNotFound)
return
}
var req struct {
Endpoint string `json:"endpoint"`
}
if !decodeStateBodyN(w, r, &req, maxPushBodyBytes) {
return
}
if req.Endpoint == "" {
http.Error(w, `{"error":"incomplete subscription"}`, http.StatusBadRequest)
return
}
// 204 whether or not a row moved. The client asks once per endpoint and has
// nothing to do with the answer, and reporting a miss would tell a caller
// whether somebody else's endpoint is on file.
if err := storage.HealPushSubscriptionLocalpart(u.Sub, req.Endpoint, buyerLocalpart(u)); err != nil {
slog.Error("push: heal failed", "sub", u.Sub, "err", err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handlePushUnsubscribe drops the caller's own stored subscription by endpoint. // handlePushUnsubscribe drops the caller's own stored subscription by endpoint.
// The delete is scoped to the signed-in user so one account can't remove // The delete is scoped to the signed-in user so one account can't remove
// another's subscription by presenting its endpoint string. // another's subscription by presenting its endpoint string.
+1
View File
@@ -345,6 +345,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
if s.cfg.Push.Enabled { if s.cfg.Push.Enabled {
mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe) mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe) mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
mux.HandleFunc("POST /api/push/heal", s.handlePushHeal)
} }
if s.tts != nil { if s.tts != nil {
mux.HandleFunc("POST /api/tts", s.handleTTS) mux.HandleFunc("POST /api/tts", s.handleTTS)
+48
View File
@@ -3078,3 +3078,51 @@ html[data-room] .pete-felt {
background: color-mix(in srgb, #c9a227 6%, transparent); background: color-mix(in srgb, #c9a227 6%, transparent);
} }
} }
@layer components {
/* The board row on /adventure and the channel page. It was four flex columns
that never collapsed, and at phone width it fell apart: "lv 14 human cleric"
wrapped onto three lines and the where-column onto three more, beside the
"send trouble" button. Pre-existing — a board pushed with no region and a
short zone name wrapped exactly the same way — so this is a layout fix, not
a regression fix for anything the adventure plan added.
Below sm the row is a small grid: the icon and the name on the top line with
the button pinned right, and the two descriptive columns stacked underneath
the name where they have the whole width to themselves. At sm and up it is
the single line it always was.
It lives here as component classes rather than as utilities in the markup
because the SAME row is built twice — server-side in channel.html and again
in that page's JS twin, which re-renders the list every poll. Two copies of a
utility soup drift; two copies of one class name cannot. */
.roster-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
column-gap: 0.75rem;
row-gap: 0.1rem;
padding: 0.75rem 1.25rem;
}
/* Fixed width, because the two glyphs are not the same size: the house is
wider than the crossed swords, so an auto column indented the stacked lines
under an idle adventurer further than those under a live one. Only visible
once the row stacks, which is why it took a phone-width shot to see. */
.roster-row-icon { grid-column: 1; grid-row: 1; width: 1.35rem; text-align: center; font-size: 1.125rem; line-height: 1.75rem; }
.roster-row-name { grid-column: 2; grid-row: 1; min-width: 0; }
.roster-row-act { grid-column: 3; grid-row: 1; }
.roster-row-meta { grid-column: 2; grid-row: 2; }
.roster-row-where { grid-column: 2; grid-row: 3; }
@media (min-width: 640px) {
.roster-row {
grid-template-columns: auto auto auto minmax(0, 1fr) auto;
column-gap: 1rem;
}
.roster-row-meta { grid-column: 3; grid-row: 1; }
/* The where-column keeps the ml-auto behaviour it had as a flex child: it is
the only 1fr track, so it takes the slack, and the text sits at its end. */
.roster-row-where { grid-column: 4; grid-row: 1; text-align: right; }
.roster-row-act { grid-column: 5; grid-row: 1; }
}
}
File diff suppressed because one or more lines are too long
+44 -3
View File
@@ -33,7 +33,9 @@
rejected_busy: "couldn't, you're already out there", rejected_busy: "couldn't, you're already out there",
rejected_insufficient_funds: "couldn't cover it", rejected_insufficient_funds: "couldn't cover it",
rejected_zone_locked: "couldn't, that zone isn't open to you", rejected_zone_locked: "couldn't, that zone isn't open to you",
rejected_nothing_to_resume: "couldn't, there's nothing waiting for you" rejected_nothing_to_resume: "couldn't, there's nothing waiting for you",
rejected_is_leader: "couldn't, you're the one leading it",
rejected_nothing_to_cancel: "couldn't, no sitter is engaged"
}; };
// syncOffers keeps the panel's own copy from outliving the truth. Watching it // syncOffers keeps the panel's own copy from outliving the truth. Watching it
@@ -45,6 +47,29 @@
// puts the button back, and that asymmetry is the point: a refusal is often // 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 a stale page, and taking away the retry would leave them nothing to do
// about it. // about it.
// INVALIDATES is what an applied verb makes untrue about the OTHER verbs on
// the page. Hiding only the offer that was taken is not enough, and watching it
// run is what showed why: after "Call the whole thing off" landed, the panel
// went on offering "Pull out of the run" — directly under a verdict saying the
// expedition had been abandoned. That is the same lie W5a fixed for the bout,
// in a new place.
//
// The one asymmetry worth keeping: an applied EXTRACT does not hide the
// abandon. An extracted run is still the owner's to close — that is exactly
// what the abandon verb is for from town — so taking the button away there
// would remove the next thing they might legitimately want.
var INVALIDATES = {
expedition_abandon: ['extract', 'expedition_leave'],
expedition_leave: ['extract', 'expedition_abandon'],
extract: ['expedition_leave']
};
function hideOffer(action) {
var btn = document.querySelector('.adv-action-btn[data-action="' + action + '"]');
if (!btn) return;
(btn.closest('[data-offer]') || btn).classList.add('hidden');
}
function syncOffers(orders) { function syncOffers(orders) {
var newest = {}; var newest = {};
orders.forEach(function (o) { if (!(o.action in newest)) newest[o.action] = o; }); orders.forEach(function (o) { if (!(o.action in newest)) newest[o.action] = o; });
@@ -56,6 +81,7 @@
var offer = btn.closest('[data-offer]') || btn; var offer = btn.closest('[data-offer]') || btn;
if (o.status === 'applied') { if (o.status === 'applied') {
offer.classList.add('hidden'); offer.classList.add('hidden');
(INVALIDATES[action] || []).forEach(hideOffer);
return; return;
} }
// All three halves of the restore matter, and each is easy to forget. // All three halves of the restore matter, and each is easy to forget.
@@ -80,7 +106,10 @@
siege_join: 'Join the defence', siege_join: 'Join the defence',
expedition_start: 'Set out', expedition_start: 'Set out',
expedition_resume: 'Go back in', expedition_resume: 'Go back in',
babysit: 'Hire the sitter' babysit: 'Hire the sitter',
expedition_abandon: 'Call it off',
expedition_leave: 'Turn back',
babysit_cancel: 'Send the sitter home'
}; };
// The owner's euro balance as of the render, for the money confirms. Absent on // The owner's euro balance as of the render, for the money confirms. Absent on
@@ -246,7 +275,19 @@
row.className = 'mt-2 flex gap-1.5'; row.className = 'mt-2 flex gap-1.5';
var yes = document.createElement('button'); var yes = document.createElement('button');
yes.type = 'button'; yes.type = 'button';
yes.className = 'rounded-full bg-theme-adventure text-white px-3 py-1 font-semibold'; // A destructive verb gets the red the mischief storefront already uses for
// "this is the one that does something to somebody". "Call the whole thing
// off" throws away a whole party's day and was raising a confirm identical to
// the one for hiring a pet sitter — the two most different decisions on the
// page, in the same purple.
//
// Red rather than the button's own --warn, and that is not a style
// preference: --warn is a dark amber in every light theme and a LIGHT amber
// in the dark one (it is the only dark card), so white on it is unreadable in
// exactly the theme this was first tried in. Seen, not reasoned about.
yes.className = btn.getAttribute('data-confirm-tone') === 'warn'
? 'rounded-full bg-red-500 text-white px-3 py-1 font-semibold'
: 'rounded-full bg-theme-adventure text-white px-3 py-1 font-semibold';
yes.textContent = (btn.getAttribute('data-confirm-label') || 'Yes, do it') + yes.textContent = (btn.getAttribute('data-confirm-label') || 'Yes, do it') +
(cost > 0 ? ' · €' + euroFmt(cost) : ''); (cost > 0 ? ' · €' + euroFmt(cost) : '');
yes.addEventListener('click', function () { boxEl.remove(); placeOrder(btn); }); yes.addEventListener('click', function () { boxEl.remove(); placeOrder(btn); });
+28
View File
@@ -50,6 +50,33 @@
}); });
} }
// A subscription stored before the server learned to record the Matrix handle
// can never match an owner-scoped adventure alert, and nothing re-subscribes on
// its own — subscribe() only runs on a click. So an existing subscription gets
// its handle topped up once, silently, from the page it is already on.
//
// Once per endpoint, not once per load: the marker is the endpoint itself, so a
// rotated subscription heals again and a browser that has already done it never
// asks twice. The server's update is a no-op on an already-healed row, so a lost
// marker costs one wasted request and nothing else.
var HEAL_KEY = "pete.pushHeal.v1";
function healLocalpart(sub) {
if (!sub || !sub.endpoint) return;
try {
if (localStorage.getItem(HEAL_KEY) === sub.endpoint) return;
} catch (e) { /* private mode: heal every load rather than never */ }
fetch("/api/push/heal", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: sub.endpoint }),
credentials: "same-origin",
}).then(function (res) {
if (!res.ok) return;
try { localStorage.setItem(HEAL_KEY, sub.endpoint); } catch (e) {}
}).catch(function () { /* transient — the next load will do */ });
}
function unsubscribe() { function unsubscribe() {
return currentSub().then(function (sub) { return currentSub().then(function (sub) {
if (!sub) return; if (!sub) return;
@@ -153,6 +180,7 @@
} }
currentSub().then(function (sub) { currentSub().then(function (sub) {
paint(!!sub, sub ? "You'll get a nudge when new stories land." : "Get a nudge when new stories land."); paint(!!sub, sub ? "You'll get a nudge when new stories land." : "Get a nudge when new stories land.");
healLocalpart(sub);
}); });
} }
+17 -12
View File
@@ -81,15 +81,18 @@
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card"> <div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card">
<ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list"> <ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list">
{{range .Roster}} {{range .Roster}}
<li class="flex items-center gap-4 px-5 py-3" data-token="{{.Token}}" data-name="{{.Name}}"> {{/* The layout is .roster-row, not utilities: this markup has a twin in the
<span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span> script below that re-renders the same list on every poll, and the two
<a href="/adventure/who/{{.Token}}" class="font-semibold hover:text-theme-adventure hover:underline">{{.Name}}</a> have to agree at every width. See the .roster-row block in input.css. */}}
<span class="text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span> <li class="roster-row" data-token="{{.Token}}" data-name="{{.Name}}">
<span class="ml-auto text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}"> <span class="roster-row-icon" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
<a href="/adventure/who/{{.Token}}" class="roster-row-name font-semibold truncate hover:text-theme-adventure hover:underline">{{.Name}}</a>
<span class="roster-row-meta text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span>
<span class="roster-row-where text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}">
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}} {{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
</span> </span>
{{if and $.User .OnRun}} {{if and $.User .OnRun}}
<button type="button" class="mischief-send shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="{{.Token}}" data-name="{{.Name}}">send trouble</button> <button type="button" class="mischief-send roster-row-act shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="{{.Token}}" data-name="{{.Name}}">send trouble</button>
{{end}} {{end}}
</li> </li>
{{else}} {{else}}
@@ -158,14 +161,16 @@
function row(a) { function row(a) {
var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : ''; var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : '';
// The server-rendered twin of this row is above, in the {{"{{range .Roster}}"}}
// block. Keep the class names identical: the layout is all in .roster-row.
var button = (signedIn && a.OnRun) var button = (signedIn && a.OnRun)
? '<button type="button" class="mischief-send shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">send trouble</button>' ? '<button type="button" class="mischief-send roster-row-act shrink-0 rounded-full bg-[color:var(--ink)]/5 hover:bg-red-500 hover:text-white border border-[color:var(--ink)]/15 px-3 py-1 text-xs font-semibold transition" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">send trouble</button>'
: ''; : '';
return '<li class="flex items-center gap-4 px-5 py-3" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">' + return '<li class="roster-row" data-token="' + esc(a.Token) + '" data-name="' + esc(a.Name) + '">' +
'<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' + '<span class="roster-row-icon" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
'<a href="/adventure/who/' + esc(a.Token) + '" class="font-semibold hover:text-theme-adventure hover:underline">' + esc(a.Name) + '</a>' + '<a href="/adventure/who/' + esc(a.Token) + '" class="roster-row-name font-semibold truncate hover:text-theme-adventure hover:underline">' + esc(a.Name) + '</a>' +
'<span class="text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' + '<span class="roster-row-meta text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
'<span class="ml-auto text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' + '<span class="roster-row-where text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' +
esc(a.Where) + idle + '</span>' + button + '</li>'; esc(a.Where) + idle + '</span>' + button + '</li>';
} }
+56 -4
View File
@@ -239,7 +239,17 @@
{{/* data-offer marks what stops being true once the extraction lands: there {{/* 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 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 verdict, restored on a refusal, which is the one case where the reader
still needs the retry. */}} still needs the retry.
W9: withheld from a party MEMBER, and only from them. W5a's rule is that
an idle-looking mark still gets this button, because the board is two
minutes stale and refusing a live extraction is worse than a no-op — but
a member is not a staleness question. Pete knows from the same seat it
just read that gogobee will answer rejected_not_leader, and offering a
certain refusal directly above "Turn back alone", which is the thing that
does work, is the page contradicting itself. CanLeave is only ever true
for a seat that says "member". */}}
{{if not .CanLeave}}
<div data-offer> <div data-offer>
<button type="button" <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" 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"
@@ -248,6 +258,38 @@
data-confirm-label="Yes, pull out" 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> 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> </div>
{{end}}
{{/* W9. The two ways out that are not an extraction, each in its own
data-offer wrapper rather than sharing the extract one above — an
extracted run is still abandonable, so hiding "Call it off" when the
extraction lands would take away the very button that case needs.
They are mutually exclusive by construction (offersToUndo reads the
viewer's own party seat: a leader is offered one, a member the other),
which is why neither mentions the other. */}}
{{if .CanAbandon}}
<div data-offer class="mt-3">
<button type="button"
class="adv-action-btn rounded-full border border-[color:var(--warn)]/40 text-[color:var(--warn)] hover:bg-[color:var(--warn)]/10 px-4 py-1.5 text-sm font-semibold transition-colors"
data-action="expedition_abandon"
data-label="Call the whole thing off"
data-confirm-tone="warn"
data-confirm-label="Yes, call it off"
data-confirm="End this expedition for good? Whatever is left of the supplies is forfeit and there is no way back into the run afterwards. If you have a party with you, it ends for all of them too, and they will be told. This is not the same as pulling out: pulling out keeps the way back open for seven days.">Call the whole thing off</button>
</div>
{{end}}
{{if .CanLeave}}
<div data-offer class="mt-3">
<button type="button"
class="adv-action-btn rounded-full border border-[color:var(--ink)]/25 text-[color:var(--ink)]/70 hover:bg-[color:var(--ink)]/10 px-4 py-1.5 text-sm font-semibold transition-colors"
data-action="expedition_leave"
data-label="Turn back alone"
data-confirm-label="Yes, turn back"
data-confirm="Walk out of this party and head for town on your own? The rest of them carry on without you, and the supplies you bought stay in their pool: they were spent on the expedition, not lent to it. Your leader will be told.">Turn back alone</button>
</div>
{{end}}
{{/* W5b. Everything below is server-rendered from the offer list gogobee {{/* W5b. Everything below is server-rendered from the offer list gogobee
pushed onto this owner's own private row, so the page can only ever pushed onto this owner's own private row, so the page can only ever
@@ -329,13 +371,23 @@
<div data-offer class="mt-5 pt-5 border-t border-[color:var(--ink)]/10"> <div data-offer class="mt-5 pt-5 border-t border-[color:var(--ink)]/10">
<h3 class="font-display text-base font-bold">The sitter</h3> <h3 class="font-display text-base font-bold">The sitter</h3>
{{if .Self.Babysit.Active}} {{if .Self.Babysit.Active}}
{{/* Engaged: say so and offer nothing. Buying a second one is refused by {{/* Engaged: say so, and offer only the way back out. Buying a second one
the game anyway, and a buy button under "already engaged" reads as a is refused by the game anyway, and a buy button under "already engaged"
page that has not noticed. */}} reads as a page that has not noticed. */}}
<p class="text-sm text-[color:var(--ink)]/60 mt-0.5"> <p class="text-sm text-[color:var(--ink)]/60 mt-0.5">
Somebody is looking after the camp{{with untilUnix .Self.Babysit.ExpiresAt}} — {{.}}{{end}}. Somebody is looking after the camp{{with untilUnix .Self.Babysit.ExpiresAt}} — {{.}}{{end}}.
Your pet is being tended daily and standard camps rest like fortified ones. Your pet is being tended daily and standard camps rest like fortified ones.
</p> </p>
{{if .CanCancelSitter}}
<div class="flex flex-wrap gap-1.5 mt-2.5">
<button type="button"
class="adv-action-btn rounded-full border border-[color:var(--ink)]/25 text-[color:var(--ink)]/70 hover:bg-[color:var(--ink)]/10 px-3.5 py-1.5 text-sm font-semibold transition-colors"
data-action="babysit_cancel"
data-label="Send them home"
data-confirm-label="Yes, send them home"
data-confirm="Send the sitter home now? There is no refund: you paid for the days you booked and the rest of them go with the sitter. Your pet stops being tended and standard camps go back to resting like standard camps.">Send them home</button>
</div>
{{end}}
{{else}} {{else}}
<p class="text-sm text-[color:var(--ink)]/60 mt-0.5 mb-2.5"> <p class="text-sm text-[color:var(--ink)]/60 mt-0.5 mb-2.5">
Pet tended daily, standard camps rest like fortified ones, rival duels declined for you. Pet tended daily, standard camps rest like fortified ones, rival duels declined for you.
+78
View File
@@ -110,6 +110,75 @@ func centiXP(centi int) string {
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", float64(centi)/100), "0"), ".") return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.2f", float64(centi)/100), "0"), ".")
} }
// rosterStatusExpedition is the one roster status that means "down there right
// now". Spelled out here because W9's offers turn on it and a typo would silently
// hide a button rather than fail.
const rosterStatusExpedition = "expedition"
// offersToUndo decides which of the three W9 verbs this owner's page proposes.
// Courtesy only, like every offer on this page: gogobee re-resolves all three and
// its refusal is the real answer. What this buys is a page that does not put
// "Leave the party" in front of somebody standing in town.
//
// Nothing new crosses the wire for it. Leadership is already legible in the party
// seats gogobee pushes (W7), and the sitter's standing is already in the babysit
// offer (W5b) — so the two facts the buttons need were both already here.
// haveParty says whether the public detail blob decoded at all, and it is
// load-bearing rather than defensive. An empty seat list means "solo" ONLY when
// we have actually read the sheet; a blob that did not decode — a gogobee too old
// to push seats, a truncated column, a shape change — produces the same empty
// slice, and treating that as solo would offer a party MEMBER the button that
// throws away everyone's day. Found by getting a fixture wrong: the page did
// exactly that, silently and convincingly.
func offersToUndo(token, status string, haveParty bool, party []partySeat, self storage.PlayerDetail) (abandon, leave, cancelSitter bool) {
// The sitter first, because it is the only one of the three whose fact does
// not go stale: an engagement is a property of the character, not of where
// they are standing, so a two-minute-old snapshot is still right about it.
cancelSitter = self.Babysit != nil && self.Babysit.Active
if status == rosterStatusExpedition && haveParty {
if len(party) == 0 {
// A SOLO run publishes no party at all — partySeatViews returns nil
// below two seats — so an empty list here is not "we don't know", it is
// "there is nobody else", which makes this player the leader.
abandon = true
} else {
// With a party, offer strictly on the viewer's own seat, and offer
// nothing at all if we cannot find it. Guessing in that case would mean
// showing a member the button that throws away everyone's day.
for _, seat := range party {
if seat.Token != token {
continue
}
switch seat.Kind {
case "leader":
abandon = true
case "member":
leave = true
}
break
}
}
}
// An extracted expedition is still the owner's to close, and it is the case
// the status check above cannot see: its owner reads as idle in town, with no
// party, and the resume offer is the only sign the run is still open. Without
// this, a leader who wanted out had to pay to walk back in first — the exact
// hole the game's own abandon path was widened to cover.
//
// Not while they are sitting in somebody ELSE'S party, though, and that is not
// a hypothetical: a player who extracted their own run and then took a seat
// has both facts true at once, about two different expeditions. Both buttons
// on one page would be asking the reader to work out which run each meant,
// and this page is about the one they are standing in. The abandon is still
// there from town the moment they walk out of the party.
if self.Resume != nil && !leave {
abandon = true
}
return abandon, leave, cancelSitter
}
// whoMap is the fog-of-war zone graph as gogobee cut it: visited rooms with // whoMap is the fog-of-war zone graph as gogobee cut it: visited rooms with
// their true kind, plus the one-hop frontier of doors whose rooms are withheld // their true kind, plus the one-hop frontier of doors whose rooms are withheld
// (kind "unknown"). Pete lays it out and draws it; it never receives node // (kind "unknown"). Pete lays it out and draws it; it never receives node
@@ -164,6 +233,13 @@ type whoPage struct {
RunLog RunLogView RunLog RunLogView
HasSelf bool HasSelf bool
Self storage.PlayerDetail Self storage.PlayerDetail
// The three W9 verbs that undo something. Unlike every offer above them these
// are derived on Pete rather than pushed: leadership is already legible in the
// party seats, and a sitter's standing is already in the babysit offer, so
// there was nothing to add to the wire. See offersToUndo.
CanAbandon bool
CanLeave bool
CanCancelSitter bool
// The private panels, wrapped so a row knows where it is sitting. Bond state // The private panels, wrapped so a row knows where it is sitting. Bond state
// only means something on a worn item — see itemRow. // only means something on a worn item — see itemRow.
Worn []itemRow Worn []itemRow
@@ -325,6 +401,8 @@ func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
page.BondsUsed++ page.BondsUsed++
} }
} }
page.CanAbandon, page.CanLeave, page.CanCancelSitter =
offersToUndo(token, entry.Status, page.HasDetail, page.Detail.Party, self)
} }
} }
} }