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:
@@ -72,12 +72,29 @@ type AdvOrderParams struct {
|
||||
// verdict tells people to type !resume, and this is the door.
|
||||
// babysit engage the pet sitter for a week or a month — `!adventure
|
||||
// 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 (
|
||||
AdvActionExtract = "extract"
|
||||
AdvActionSiegeJoin = "siege_join"
|
||||
AdvActionExpedition = "expedition_start"
|
||||
AdvActionResume = "expedition_resume"
|
||||
AdvActionBabysit = "babysit"
|
||||
|
||||
AdvActionAbandon = "expedition_abandon"
|
||||
AdvActionLeave = "expedition_leave"
|
||||
AdvActionBabysitCancel = "babysit_cancel"
|
||||
)
|
||||
|
||||
// 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
|
||||
AdvRejectedZoneLocked = "rejected_zone_locked" // that zone is not open at this level
|
||||
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 {
|
||||
switch action {
|
||||
case AdvActionExtract, AdvActionSiegeJoin,
|
||||
AdvActionExpedition, AdvActionResume, AdvActionBabysit:
|
||||
AdvActionExpedition, AdvActionResume, AdvActionBabysit,
|
||||
AdvActionAbandon, AdvActionLeave, AdvActionBabysitCancel:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -115,7 +140,7 @@ func validAdvVerdict(status string) bool {
|
||||
case AdvOrderApplied, AdvRejectedNotRunning, AdvRejectedNotLeader,
|
||||
AdvRejectedNoSiege, AdvRejectedAlreadyFought, AdvRejectedUnavailable,
|
||||
AdvRejectedBusy, AdvRejectedInsufficientFunds, AdvRejectedZoneLocked,
|
||||
AdvRejectedNothingToResume:
|
||||
AdvRejectedNothingToResume, AdvRejectedIsLeader, AdvRejectedNothingToCancel:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -45,6 +45,33 @@ func AddPushSubscription(sub, localpart, endpoint, p256dh, auth string) error {
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -403,12 +403,18 @@ CREATE INDEX IF NOT EXISTS idx_equip_orders_owner ON equip_orders(owner_sub, cre
|
||||
--
|
||||
-- The status ladder:
|
||||
--
|
||||
-- pending -> applied (it happened; detail says what)
|
||||
-- -> rejected_not_running (extract: no expedition to leave)
|
||||
-- -> rejected_not_leader (extract: a party member can't call it)
|
||||
-- -> rejected_no_siege (siege_join: nothing camped outside town)
|
||||
-- -> rejected_already_fought (siege_join: today's bout is already spent)
|
||||
-- -> rejected_unavailable (no character, or dead)
|
||||
-- pending -> applied (it happened; detail says what)
|
||||
-- -> rejected_not_running (extract/abandon/leave: no expedition)
|
||||
-- -> 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_already_fought (siege_join: today's bout is already spent)
|
||||
-- -> 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
|
||||
-- ends an expedition and a bout spends a day — so gogobee short-circuits on the
|
||||
|
||||
Reference in New Issue
Block a user