Files
prosolis c327cfc5f1 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.
2026-07-24 21:42:40 -07:00

386 lines
15 KiB
Go

package plugin
import (
"strings"
"testing"
"time"
"gogobee/internal/db"
"gogobee/internal/peteclient"
"maunium.net/go/mautrix/id"
)
// W5: the web action queue's game-side half. These pin the two things that would
// actually hurt in prod — a replayed order re-running a non-idempotent action,
// and a refusal being reported as a transient fault (which parks the order and
// leaves the player staring at "asked for…" forever).
// TestAdvOrderLedgerShortCircuitsAReoffer is the regression for the whole class
// of bug this ledger exists for. A verdict-ack lost on the wire means Pete
// re-offers an order we have already applied; if that re-offer reached
// applyAdvOrder it would extract a run the player had resumed, or spend a bout
// they were saving.
func TestAdvOrderLedgerShortCircuitsAReoffer(t *testing.T) {
newMischiefTestDB(t)
if _, _, ok := advOrderAlreadyApplied("guid-never-seen"); ok {
t.Fatal("an unknown guid reported as already applied")
}
if err := recordAdvApplied("guid-1", "applied", "Out of the Goblin Warrens on day 3."); err != nil {
t.Fatalf("recordAdvApplied: %v", err)
}
status, detail, ok := advOrderAlreadyApplied("guid-1")
if !ok || status != "applied" || !strings.Contains(detail, "Goblin Warrens") {
t.Fatalf("ledger read = %q/%q ok=%v, want the stored verdict back", status, detail, ok)
}
// A second stamp on the same guid must not error or overwrite — the re-file
// path can reach it.
if err := recordAdvApplied("guid-1", "rejected_not_running", "nonsense"); err != nil {
t.Fatalf("re-record: %v", err)
}
if status, _, _ := advOrderAlreadyApplied("guid-1"); status != "applied" {
t.Fatalf("verdict changed under a re-record: %q", status)
}
}
// TestExtractOrderRefusalsAreTerminal: neither refusal may come back as retry.
// A retried refusal never reaches a verdict, so the order sits pending forever
// and Pete's strip never stops saying "asked for…".
func TestExtractOrderRefusalsAreTerminal(t *testing.T) {
// W9: was setupZoneRunTestDB, which copies data/gogobee.db and t.Skip()s when
// it is missing — and that file is deleted after every local run, so this and
// the extraction test below have been green-by-skipping since W5a. Neither
// needs a prod row; startExpedition builds everything they touch.
setupEmptyTestDB(t)
uid := id.UserID("@web-extract-none:example.org")
defer cleanupExpeditions(uid)
p := &AdventurePlugin{}
status, detail, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{
GUID: "g", Action: peteclient.AdvOrderExtract,
})
if retry {
t.Fatal("no-expedition extract asked for a retry; it must be terminal")
}
if status != "rejected_not_running" || detail == "" {
t.Fatalf("status = %q detail = %q, want rejected_not_running with prose", status, detail)
}
}
// TestExtractOrderIsTheSameExtraction: the web verb must run the game's own
// extraction, not a lookalike. The proof is the state the row lands in —
// 'extracting' (a resumable limbo) rather than 'abandoned' — plus the day burn
// and the log line the DM path writes.
func TestExtractOrderIsTheSameExtraction(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@web-extract-live:example.org")
defer cleanupExpeditions(uid)
p := &AdventurePlugin{}
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-extract", Action: peteclient.AdvOrderExtract,
})
if retry || status != "applied" {
t.Fatalf("extract = %q retry=%v, want applied", status, retry)
}
if !strings.Contains(detail, "resume") {
t.Fatalf("verdict %q never mentions the resume window, which is the whole point of an extraction", detail)
}
var dbStatus string
var day int
if err := db.Get().QueryRow(
`SELECT status, current_day FROM dnd_expedition WHERE user_id = ?`, string(uid),
).Scan(&dbStatus, &day); err != nil {
t.Fatalf("read expedition: %v", err)
}
if dbStatus != ExpeditionStatusExtracting {
t.Fatalf("expedition status = %q, want %q — a web extract must be resumable like the command's",
dbStatus, ExpeditionStatusExtracting)
}
if day != 2 {
t.Fatalf("current_day = %d, want 2 — extraction burns the day", day)
}
}
// TestSiegeJoinRefusalsAreTerminal covers the two refusals a bout can hit without
// running any combat: nothing camped, and a bout already spent today. Both must
// be terminal for the same reason as the extract refusals, and "already fought"
// especially — it is the one a double-click produces.
func TestSiegeJoinRefusalsAreTerminal(t *testing.T) {
newMischiefTestDB(t)
uid := id.UserID("@web-siege:example.org")
p := &AdventurePlugin{}
status, _, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{
GUID: "g1", Action: peteclient.AdvOrderSiegeJoin,
})
if retry || status != "rejected_no_siege" {
t.Fatalf("no-boss bout = %q retry=%v, want rejected_no_siege", status, retry)
}
// Camp a boss and spend the day's bout, then ask again.
now := time.Now().UTC()
bossID, err := insertWorldBoss("Grelloth", 3, 18000, now.Add(-time.Hour), now.Add(48*time.Hour))
if err != nil {
t.Fatalf("insertWorldBoss: %v", err)
}
if err := createAdvCharacter(uid, "Rurina"); err != nil {
t.Fatalf("createAdvCharacter: %v", err)
}
today := now.Format("2006-01-02")
if err := upsertWorldBossContrib(bossID, uid, 250, today); err != nil {
t.Fatalf("upsertWorldBossContrib: %v", err)
}
status, detail, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{
GUID: "g2", Action: peteclient.AdvOrderSiegeJoin,
})
if retry || status != "rejected_already_fought" {
t.Fatalf("second bout = %q retry=%v, want rejected_already_fought", status, retry)
}
if detail == "" {
t.Fatal("a refusal with no prose leaves the strip saying nothing useful")
}
}
// TestUnknownActionIsRejectedNotRetried: Pete validates the action before it ever
// queues one, so an unknown verb is a contract breach. Spinning on it would poll
// the same dead order every 15 seconds forever.
func TestUnknownActionIsRejectedNotRetried(t *testing.T) {
newMischiefTestDB(t)
p := &AdventurePlugin{}
status, _, retry := p.applyAdvOrder("@x:example.org", peteclient.AdvOrder{
GUID: "g", Action: "sell_house",
})
if retry || !strings.HasPrefix(status, "rejected_") {
t.Fatalf("unknown action = %q retry=%v, want a terminal rejection", status, retry)
}
}
// TestAdvOrderPlainText: the Siege verdict is the Matrix footer reused, and Pete
// renders a verdict as text — so the markdown has to come off or the player reads
// literal asterisks.
func TestAdvOrderPlainText(t *testing.T) {
got := advOrderPlainText("💥 You deal **412** damage. **Grelloth** has **5,800 / 18,000 HP** left.\nYou stagger out at 1 HP.")
if strings.Contains(got, "*") || strings.Contains(got, "\n") {
t.Fatalf("plain text still carries markup or a newline: %q", got)
}
if !strings.Contains(got, "412") || !strings.Contains(got, "Grelloth") {
t.Fatalf("plain text lost the facts: %q", got)
}
}
// ── W5b: the three verbs that take arguments and spend coins ───────────────────
// webOrderTestChar builds a character solvent enough to outfit an expedition.
func webOrderTestChar(t *testing.T, uid id.UserID, level int, coins float64) *AdventurePlugin {
t.Helper()
if err := createAdvCharacter(uid, "weborder"); err != nil {
t.Fatal(err)
}
c := &DnDCharacter{
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level,
STR: 14, DEX: 12, CON: 14, INT: 10, WIS: 10, CHA: 10,
HPMax: 30, HPCurrent: 30, ArmorClass: 14,
}
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
euro := &EuroPlugin{}
euro.ensureBalance(uid)
if coins > 0 {
euro.Credit(uid, coins, "test bankroll")
}
return &AdventurePlugin{euro: euro}
}
// A forged zone must buy nothing. Pete only ever offers what gogobee quoted it,
// but a quote is a stale snapshot and not a permission — so the order path
// re-resolves against availableZonesFor and refuses anything that isn't there.
func TestWebExpeditionStartReResolvesTheZone(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@web-start-forged:example.org")
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
p := webOrderTestChar(t, uid, 2, 100000)
before := p.euro.GetBalance(uid)
status, _, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{
GUID: "forged-zone", Action: peteclient.AdvOrderExpedition,
Params: &peteclient.AdvOrderParams{Zone: "dragons_lair", Loadout: "lean"},
})
if retry {
t.Fatal("a locked zone asked for a retry; it must be terminal")
}
if status != "rejected_zone_locked" {
t.Fatalf("status = %q, want rejected_zone_locked", status)
}
if after := p.euro.GetBalance(uid); after != before {
t.Fatalf("a refused departure moved money: %.0f -> %.0f", before, after)
}
if exp, _ := getActiveExpedition(uid); exp != nil {
t.Fatal("a refused departure started an expedition anyway")
}
}
// An unknown loadout is refused, never defaulted. Defaulting would spend coins on
// a pack size the player never picked.
func TestWebExpeditionStartRefusesAnUnknownLoadout(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@web-start-loadout:example.org")
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
p := webOrderTestChar(t, uid, 2, 100000)
before := p.euro.GetBalance(uid)
status, _, _ := p.applyAdvOrder(uid, peteclient.AdvOrder{
GUID: "bad-loadout", Action: peteclient.AdvOrderExpedition,
Params: &peteclient.AdvOrderParams{Zone: string(ZoneGoblinWarrens), Loadout: "enormous"},
})
if status != "rejected_unavailable" {
t.Fatalf("status = %q, want rejected_unavailable", status)
}
if after := p.euro.GetBalance(uid); after != before {
t.Fatalf("a refused loadout moved money: %.0f -> %.0f", before, after)
}
}
// The money test that matters: a re-offered order (verdict-ack lost before the
// ledger stamped it) must not charge twice, and must not answer "you're already
// on an expedition" for the expedition it just started.
func TestWebExpeditionStartChargesOnceOnAReoffer(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@web-start-idem:example.org")
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
p := webOrderTestChar(t, uid, 2, 100000)
order := peteclient.AdvOrder{
GUID: "start-once", Action: peteclient.AdvOrderExpedition,
Params: &peteclient.AdvOrderParams{Zone: string(ZoneGoblinWarrens), Loadout: "lean"},
}
before := p.euro.GetBalance(uid)
status, _, retry := p.applyAdvOrder(uid, order)
if retry || status != "applied" {
t.Fatalf("first apply = %q retry=%v, want applied", status, retry)
}
afterFirst := p.euro.GetBalance(uid)
if afterFirst >= before {
t.Fatalf("outfitting cost nothing: %.0f -> %.0f", before, afterFirst)
}
// The re-offer. applyAdvOrder is reached directly here on purpose: the
// adv_applied_orders ledger would normally short-circuit it, and this asserts
// the layer *underneath* that guard is safe too.
status, _, retry = p.applyAdvOrder(uid, order)
if retry {
t.Fatal("the re-offer asked for a retry")
}
if status != "applied" {
t.Fatalf("re-offer = %q, want applied — the settled debit is what tells a "+
"replay apart from a player who really is already out", status)
}
if after := p.euro.GetBalance(uid); after != afterFirst {
t.Fatalf("the re-offer charged again: %.0f -> %.0f", afterFirst, after)
}
}
// Babysit: same replay contract, plus the two durations are the only two sold.
func TestWebBabysitChargesOnceAndSellsTwoDurations(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@web-sitter:example.org")
p := webOrderTestChar(t, uid, 2, 100000)
status, _, _ := p.applyAdvOrder(uid, peteclient.AdvOrder{
GUID: "sitter-odd", Action: peteclient.AdvOrderBabysit,
Params: &peteclient.AdvOrderParams{Days: 3},
})
if status != "rejected_unavailable" {
t.Fatalf("3-day sitter = %q, want rejected_unavailable", status)
}
order := peteclient.AdvOrder{
GUID: "sitter-once", Action: peteclient.AdvOrderBabysit,
Params: &peteclient.AdvOrderParams{Days: 7},
}
before := p.euro.GetBalance(uid)
if status, _, _ := p.applyAdvOrder(uid, order); status != "applied" {
t.Fatalf("hire = %q, want applied", status)
}
afterFirst := p.euro.GetBalance(uid)
if afterFirst >= before {
t.Fatalf("the sitter worked for free: %.0f -> %.0f", before, afterFirst)
}
if status, _, _ := p.applyAdvOrder(uid, order); status != "applied" {
t.Fatalf("re-offer = %q, want applied", status)
}
if after := p.euro.GetBalance(uid); after != afterFirst {
t.Fatalf("the re-offer charged again: %.0f -> %.0f", afterFirst, after)
}
}
// A refusal must never come back as retry: a retried refusal never reaches a
// verdict, so the order parks and the strip says "asked for…" forever.
func TestWebMoneyVerbRefusalsAreTerminal(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@web-broke:example.org")
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
p := webOrderTestChar(t, uid, 2, 0)
for _, tc := range []struct {
name string
order peteclient.AdvOrder
want string
}{
{"broke departure", peteclient.AdvOrder{
GUID: "broke-1", Action: peteclient.AdvOrderExpedition,
Params: &peteclient.AdvOrderParams{Zone: string(ZoneGoblinWarrens), Loadout: "heavy"},
}, "rejected_insufficient_funds"},
{"nothing to resume", peteclient.AdvOrder{
GUID: "resume-1", Action: peteclient.AdvOrderResume,
Params: &peteclient.AdvOrderParams{Loadout: "lean"},
}, "rejected_nothing_to_resume"},
{"broke sitter", peteclient.AdvOrder{
GUID: "broke-2", Action: peteclient.AdvOrderBabysit,
Params: &peteclient.AdvOrderParams{Days: 30},
}, "rejected_insufficient_funds"},
} {
status, detail, retry := p.applyAdvOrder(uid, tc.order)
if retry {
t.Fatalf("%s asked for a retry; it must be terminal", tc.name)
}
if status != tc.want {
t.Fatalf("%s = %q, want %q (detail %q)", tc.name, status, tc.want, detail)
}
if strings.ContainsAny(detail, "*`") {
t.Fatalf("%s verdict still carries Matrix markdown: %q", tc.name, detail)
}
}
}
// An order with no params at all is a contract breach, not a user mistake, and
// must be refused rather than defaulted into spending money.
func TestWebMoneyVerbsRefuseMissingParams(t *testing.T) {
setupEmptyTestDB(t)
uid := id.UserID("@web-noparams:example.org")
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
p := webOrderTestChar(t, uid, 2, 100000)
before := p.euro.GetBalance(uid)
for _, action := range []string{
peteclient.AdvOrderExpedition, peteclient.AdvOrderResume, peteclient.AdvOrderBabysit,
} {
status, _, retry := p.applyAdvOrder(uid, peteclient.AdvOrder{GUID: "np-" + action, Action: action})
if retry || status != "rejected_unavailable" {
t.Fatalf("%s with no params = %q retry=%v, want rejected_unavailable", action, status, retry)
}
}
if after := p.euro.GetBalance(uid); after != before {
t.Fatalf("a paramless order moved money: %.0f -> %.0f", before, after)
}
}