mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
adventure: give the web the three doors it kept pointing at
The web's own refusals named commands it had no button for: "!expedition abandon first", "!expedition leave to walk out alone", "no refund if you cancel early". Three verbs, no arguments, no money. Each Matrix handler becomes framing over a headless twin the order path shares, so the web runs the game's real path: an abandon still disbands the roster, still retires the region runs, still DMs the party, still rolls the emergence pet. The lock asymmetry is per verb and is the thing to be careful about. performExpeditionAbandon and performExpeditionLeave cannot take advUserLock — their Matrix caller holds it across the whole !expedition switch — so their web wrappers do. performBabysitCancel takes it itself, because its caller does not, so its wrapper must not. Both directions are pinned by tests that hang rather than fail on regression. Also flips the two W5a extract tests off setupZoneRunTestDB: it copies data/gogobee.db and skips when it is missing, so they have been green-by-skipping since they were written.
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/peteclient"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// W9: the three web verbs that undo something — abandon an expedition, walk out
|
||||
// of somebody else's party, send the sitter home.
|
||||
//
|
||||
// The thing worth pinning here is not the verdict text, it is the LOCK. Each of
|
||||
// these three now has a headless twin shared between a Matrix command and the web
|
||||
// order path, and the two halves take advUserLock on opposite sides: the two
|
||||
// expedition twins cannot take it (their Matrix caller holds it across the whole
|
||||
// `!expedition` switch) and their web wrappers must, while the babysit twin takes
|
||||
// it itself and its web wrapper must not.
|
||||
//
|
||||
// Getting either of those backwards does not fail loudly. advUserLock is a plain
|
||||
// sync.Mutex, so a second acquire parks the goroutine forever with the deferred
|
||||
// Unlock never running — which wedges every later !adventure / !expedition /
|
||||
// !zone command from that player, not just the one that deadlocked. That is a
|
||||
// bug that shipped once already (see TestExpeditionAliasesDoNotWedgeTheUserLock),
|
||||
// so both directions are tested here.
|
||||
//
|
||||
// NOTE: these two lock tests HANG rather than fail on regression. The timeout is
|
||||
// the assertion.
|
||||
|
||||
// TestUndoCommandsDoNotWedgeTheUserLock is the Matrix half: `!expedition abandon`
|
||||
// and `!expedition leave` reach their twins with the lock already held, so a twin
|
||||
// that took it itself would park here.
|
||||
func TestUndoCommandsDoNotWedgeTheUserLock(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@w9-cmd-lock:example")
|
||||
t.Cleanup(func() { cleanupExpeditions(uid) })
|
||||
|
||||
for _, sub := range []string{"abandon", "leave"} {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
p := &AdventurePlugin{euro: &EuroPlugin{}}
|
||||
_ = p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, sub)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("!expedition %s never returned: its twin re-took advUserLock", sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUndoOrdersDoNotWedgeTheUserLock is the web half, and it checks the harder
|
||||
// half of the same property: not just that the order returns, but that the lock
|
||||
// is FREE afterwards. A wrapper that took the lock around a twin that also takes
|
||||
// it would park inside applyAdvOrder; a wrapper that forgot to release would let
|
||||
// the order finish and wedge the next command instead, which is the failure that
|
||||
// would have been missed by only timing the call.
|
||||
func TestUndoOrdersDoNotWedgeTheUserLock(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@w9-order-lock:example")
|
||||
t.Cleanup(func() { cleanupExpeditions(uid) })
|
||||
|
||||
for _, action := range []string{
|
||||
peteclient.AdvOrderAbandon,
|
||||
peteclient.AdvOrderLeave,
|
||||
peteclient.AdvOrderBabysitCancel,
|
||||
} {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
p := &AdventurePlugin{euro: &EuroPlugin{}}
|
||||
p.applyAdvOrder(uid, peteclient.AdvOrder{GUID: "g-" + action, Action: action})
|
||||
// The lock must be back. Taking it here is what catches a wrapper that
|
||||
// returned without unlocking.
|
||||
mu := p.advUserLock(uid)
|
||||
mu.Lock()
|
||||
mu.Unlock()
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("order %q never returned or never released advUserLock", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUndoOrderRefusalsAreTerminal: none of the three may come back as a retry.
|
||||
// A retried refusal never reaches a verdict, so the order sits pending forever
|
||||
// and the panel never stops saying "asked for…". Each also has to carry prose —
|
||||
// the strip prefers gogobee's own sentence over its canned fallback, and an empty
|
||||
// detail on a refusal is the one case where the page has nothing to show.
|
||||
func TestUndoOrderRefusalsAreTerminal(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@w9-refusals:example")
|
||||
t.Cleanup(func() { cleanupExpeditions(uid) })
|
||||
// A real character with nothing going on. Without one, babysit_cancel refuses
|
||||
// with "no adventurer" and the sitter branch this is meant to cover is never
|
||||
// reached — the first cut of this test passed the wrong assertion for that
|
||||
// reason.
|
||||
if err := createAdvCharacter(uid, "w9refusals"); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
p := &AdventurePlugin{euro: &EuroPlugin{}}
|
||||
|
||||
cases := []struct {
|
||||
action string
|
||||
want string
|
||||
}{
|
||||
// Nothing to abandon and nothing to leave are the same fact from two
|
||||
// doors, and both are the plain "you aren't on one" answer.
|
||||
{peteclient.AdvOrderAbandon, "rejected_not_running"},
|
||||
{peteclient.AdvOrderLeave, "rejected_not_running"},
|
||||
// No sitter is its own verdict rather than rejected_unavailable: the page
|
||||
// says something specific about it, and "unavailable" reads as a fault.
|
||||
{peteclient.AdvOrderBabysitCancel, "rejected_nothing_to_cancel"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
status, detail, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{
|
||||
GUID: "g-" + tc.action, Action: tc.action,
|
||||
})
|
||||
if retry {
|
||||
t.Fatalf("%s asked for a retry on a refusal; it must be terminal", tc.action)
|
||||
}
|
||||
if status != tc.want {
|
||||
t.Fatalf("%s status = %q, want %q", tc.action, status, tc.want)
|
||||
}
|
||||
if strings.TrimSpace(detail) == "" {
|
||||
t.Fatalf("%s refused with no prose; the panel would have nothing to say", tc.action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUndoVerdictsCarryNoMarkdown: every detail line these file is reused from a
|
||||
// Matrix sentence, and Pete renders a verdict as text. An asterisk or a backtick
|
||||
// left in it shows up literally under the button.
|
||||
//
|
||||
// The backticks matter more than they look: the leave refusal names `!extract`
|
||||
// and `!expedition abandon`, which is the correct thing to say (the web now has a
|
||||
// button for one of them and not the other), but it must not say it in markup.
|
||||
func TestUndoVerdictsCarryNoMarkdown(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@w9-markdown:example")
|
||||
t.Cleanup(func() { cleanupExpeditions(uid) })
|
||||
p := &AdventurePlugin{euro: &EuroPlugin{}}
|
||||
|
||||
for _, action := range []string{
|
||||
peteclient.AdvOrderAbandon,
|
||||
peteclient.AdvOrderLeave,
|
||||
peteclient.AdvOrderBabysitCancel,
|
||||
} {
|
||||
_, detail, _ := p.applyAdvOrder(uid, peteclient.AdvOrder{GUID: "g-md-" + action, Action: action})
|
||||
if strings.ContainsAny(detail, "*`\n") {
|
||||
t.Fatalf("%s verdict carries markdown or a newline: %q", action, detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebAbandonIsTheGamesOwnAbandon: the web verb must run the real path, not a
|
||||
// lookalike. The proof is the state the row lands in — no expedition left at all,
|
||||
// as opposed to the 'extracting' limbo an extraction leaves behind — and that the
|
||||
// verdict says what became of the supplies, which is the one thing a player who
|
||||
// clicked the wrong button needs to be told.
|
||||
func TestWebAbandonIsTheGamesOwnAbandon(t *testing.T) {
|
||||
// setupEmptyTestDB, NOT setupZoneRunTestDB: the latter copies data/gogobee.db
|
||||
// and t.Skip()s when it is missing, and that file is deleted after every local
|
||||
// run — so a test written on it is green-by-skipping on any clean checkout.
|
||||
// See the Decisions note in the plan's progress file; W5a's order tests were
|
||||
// silently skipping for exactly this reason.
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@w9-abandon-live:example.org")
|
||||
t.Cleanup(func() { cleanupExpeditions(uid) })
|
||||
if err := createAdvCharacter(uid, "w9abandon"); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
p := &AdventurePlugin{euro: &EuroPlugin{}}
|
||||
|
||||
if _, err := startExpedition(uid, ZoneGoblinWarrens, "", ExpeditionSupplies{
|
||||
Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1, PacksStandard: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("startExpedition: %v", err)
|
||||
}
|
||||
|
||||
status, detail, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{
|
||||
GUID: "g-abandon", Action: peteclient.AdvOrderAbandon,
|
||||
})
|
||||
if retry || status != "applied" {
|
||||
t.Fatalf("abandon = %q retry=%v detail=%q, want applied", status, retry, detail)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(detail), "supplies") {
|
||||
t.Fatalf("verdict %q never says the supplies are gone, which is the difference from pulling out", detail)
|
||||
}
|
||||
if exp, _, err := activeExpeditionFor(uid); err != nil {
|
||||
t.Fatalf("read expedition: %v", err)
|
||||
} else if exp != nil {
|
||||
t.Fatalf("expedition survived a web abandon with status %q", exp.Status)
|
||||
}
|
||||
|
||||
// And the second click, which is what a stale page produces: a terminal
|
||||
// refusal, never a retry and never a second abandon.
|
||||
status, _, retry = p.applyAdvOrder(uid, peteclient.AdvOrder{
|
||||
GUID: "g-abandon-2", Action: peteclient.AdvOrderAbandon,
|
||||
})
|
||||
if retry || status != "rejected_not_running" {
|
||||
t.Fatalf("re-abandon = %q retry=%v, want a terminal rejected_not_running", status, retry)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user