Files
gogobee/internal/plugin/dnd_expedition_extract_test.go
T
prosolis f73ab56ac8 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
2026-07-24 19:47:45 -07:00

203 lines
6.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"testing"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// E5a: voluntary extraction burns one day's supplies, advances the day,
// flips status to 'extracting', and stamps completed_at.
func TestVoluntaryExtract_FlipsToExtracting(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-extract-voluntary:example")
defer cleanupExpeditions(uid)
supplies := ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}
exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies)
if err != nil {
t.Fatal(err)
}
startDay := exp.CurrentDay
updated, err := voluntaryExtractExpedition(uid)
if err != nil {
t.Fatalf("voluntaryExtractExpedition: %v", err)
}
if updated.Status != ExpeditionStatusExtracting {
t.Errorf("status = %q, want extracting", updated.Status)
}
if updated.CurrentDay != startDay+1 {
t.Errorf("current_day = %d, want %d", updated.CurrentDay, startDay+1)
}
got, _ := getExpedition(exp.ID)
// Phase 5-B: applyDailyBurn × phase5B 50%; 1 base × 0.5 = 0.5 burned.
if got.Supplies.Current != 9.5 {
t.Errorf("supplies after extract = %.1f, want 9.5", got.Supplies.Current)
}
if got.CompletedAt == nil {
t.Error("expected completed_at to be set")
}
// Active query should now return nothing — extracting is post-extract.
if active, _ := getActiveExpedition(uid); active != nil {
t.Errorf("getActiveExpedition still returns row: %s", active.Status)
}
}
func TestVoluntaryExtract_NoActive(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-extract-noactive:example")
defer cleanupExpeditions(uid)
if _, err := voluntaryExtractExpedition(uid); err != ErrNoActiveExpedition {
t.Errorf("err = %v, want ErrNoActiveExpedition", err)
}
}
// E5b: forced extraction flips to 'abandoned' and reports the 20% coin tax.
func TestForcedExtract_AbandonedAndTax(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-extract-forced:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET coins_earned = 100 WHERE expedition_id = ?`,
exp.ID); err != nil {
t.Fatal(err)
}
got, tax, err := forcedExtractExpedition(exp.ID, "supplies depleted")
if err != nil {
t.Fatalf("forcedExtractExpedition: %v", err)
}
if tax != 20 {
t.Errorf("tax = %d, want 20 (20%% of 100)", tax)
}
if got.Status != ExpeditionStatusAbandoned {
t.Errorf("status = %q, want abandoned", got.Status)
}
persisted, _ := getExpedition(exp.ID)
if persisted.Status != ExpeditionStatusAbandoned {
t.Errorf("persisted status = %q", persisted.Status)
}
if persisted.CompletedAt == nil {
t.Error("expected completed_at after forced extract")
}
}
// E5c: resume restores 'active' status, fresh supplies, preserves threat.
func TestResume_FreshSuppliesPreservesThreat(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-resume-ok:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 5, Max: 10, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
if err := applyThreatDelta(exp.ID, 35, "test"); err != nil {
t.Fatal(err)
}
if err := updateTemporalStack(exp.ID, 12); err != nil {
t.Fatal(err)
}
if _, err := voluntaryExtractExpedition(uid); err != nil {
t.Fatal(err)
}
resumable, err := getResumableExpedition(uid)
if err != nil || resumable == nil {
t.Fatalf("getResumableExpedition: %v / %v", resumable, err)
}
if resumable.ID != exp.ID {
t.Error("wrong row")
}
freshSupplies := ExpeditionSupplies{Current: 20, Max: 20, DailyBurn: 1, HarshMod: 1}
if err := resumeExpedition(resumable.ID, freshSupplies); err != nil {
t.Fatalf("resumeExpedition: %v", err)
}
got, _ := getExpedition(exp.ID)
if got.Status != ExpeditionStatusActive {
t.Errorf("status = %q, want active", got.Status)
}
if got.Supplies.Current != 20 {
t.Errorf("supplies.Current = %.1f, want 20", got.Supplies.Current)
}
if got.ThreatLevel != 35 {
t.Errorf("threat = %d, want 35 (preserved)", got.ThreatLevel)
}
if got.TemporalStack != 12 {
t.Errorf("temporal = %d, want 12 (preserved)", got.TemporalStack)
}
if got.CompletedAt != nil {
t.Error("completed_at should be cleared on resume")
}
}
// E5c: resume window expires after 7 real days; getResumableExpedition still
// returns the row (caller decides), but the command path should reject.
func TestResume_WindowExpired(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@exp-resume-expired:example")
defer cleanupExpeditions(uid)
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1})
if err != nil {
t.Fatal(err)
}
if _, err := voluntaryExtractExpedition(uid); err != nil {
t.Fatal(err)
}
// Backdate completed_at well past the 7-day window.
stale := time.Now().UTC().Add(-8 * 24 * time.Hour)
if _, err := db.Get().Exec(
`UPDATE dnd_expedition SET completed_at = ? WHERE expedition_id = ?`,
stale, exp.ID); err != nil {
t.Fatal(err)
}
got, _ := getResumableExpedition(uid)
if got == nil || got.CompletedAt == nil {
t.Fatal("expected resumable row with completed_at set")
}
if time.Since(*got.CompletedAt) <= extractResumeWindow {
t.Errorf("expected window to be expired (since=%v, window=%v)",
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)
}
}
}