adventure: run the web's three new verbs through the game's own paths

The game-side half of setting out, going back in, and hiring the sitter from
the web. Each is the existing command minus its framing: performExpeditionStart,
performResume and performBabysitPurchase now hold the guards and the money, and
!expedition start, !resume and !adventure babysit are what is left over. So a
departure booked from a phone is the same departure - same eligibility chain,
same supply freebies, same opening log line - rather than a second one that
drifts.

Refusals travel as advRefusal, which wraps a sentinel AND carries the finished
sentence. That is what lets the commands keep the exact copy they always sent
while the web gets a machine-readable verdict.

All three spend coins on a retrying wire, so the debit is keyed to the order
guid and a re-offer cannot charge twice. The subtle half is what a re-offer
should ANSWER: a settled debit plus an already-started expedition means the
order worked and lost its ack, not that the player is busy, so it reports
applied instead of refusing the thing it did. Nothing refunds-then-retries -
after a refund the keyed debit will not charge again, so a retry would hand over
the goods for free, and every failure past the debit is therefore permanent.

Also fixes a deadlock that predates all of this: !expedition extract and
!expedition resume are aliases for two commands that take the per-user lock
themselves, and the alias dispatcher already held it. Since it is a plain
sync.Mutex the handler blocked forever and, because the deferred unlock never
ran, every later adventure command from that player wedged too. It does not
fail loudly on regression - it hangs - so the new test asserts with a timeout.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
prosolis
2026-07-24 19:47:45 -07:00
parent fff2aa79d0
commit f73ab56ac8
9 changed files with 1050 additions and 162 deletions
@@ -175,3 +175,28 @@ func TestResume_WindowExpired(t *testing.T) {
time.Since(*got.CompletedAt), extractResumeWindow)
}
}
// `!expedition extract` and `!expedition resume` are aliases for two top-level
// commands that take the per-user lock themselves. If the alias dispatcher takes
// that lock first the handler blocks on it forever and, because the deferred
// unlock never runs, every later adventure command from that player wedges too.
// This does not fail on regression — it hangs — so the timeout is the assertion.
func TestExpeditionAliasesDoNotWedgeTheUserLock(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@exp-alias-lock:example")
t.Cleanup(func() { cleanupExpeditions(uid) })
for _, sub := range []string{"extract", "resume"} {
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: the alias re-took advUserLock", sub)
}
}
}