adventure: drain the actions a player asked for from the web

Pete now records two verbs an owner can ask for: pull out of a run, and
take today's bout against the Siege. This is the game-side loop that
answers them, modelled on the equip poller down to the applied-order
ledger, because neither action is idempotent: replaying an extraction
would end a run they had resumed, and replaying a bout would spend a day
they never got back.

The web verbs run the game's own paths, not lookalikes. !extract and
!adventure worldboss fight are now thin command framing over
performExtraction and takeSiegeBout, which both take the per-user lock,
so a click and a command race each other exactly as two commands do. A
web extraction still DMs the whole party, still writes the log line, and
still resumes for seven days.

Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
This commit is contained in:
prosolis
2026-07-24 18:55:52 -07:00
parent d6136d39d9
commit fff2aa79d0
7 changed files with 581 additions and 30 deletions
+174
View File
@@ -0,0 +1,174 @@
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) {
setupZoneRunTestDB(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) {
setupZoneRunTestDB(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)
}
}