From fff2aa79d0e61ff18cd722116ff2df5006107da3 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:55:52 -0700 Subject: [PATCH] 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 --- internal/db/db.go | 14 ++ internal/peteclient/client.go | 58 ++++++ internal/plugin/adventure.go | 1 + internal/plugin/adventure_worldboss.go | 82 ++++++-- internal/plugin/dnd_expedition_extract.go | 60 ++++-- internal/plugin/pete_orders.go | 222 ++++++++++++++++++++++ internal/plugin/pete_orders_test.go | 174 +++++++++++++++++ 7 files changed, 581 insertions(+), 30 deletions(-) create mode 100644 internal/plugin/pete_orders.go create mode 100644 internal/plugin/pete_orders_test.go diff --git a/internal/db/db.go b/internal/db/db.go index edf7740..22d919f 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1845,6 +1845,20 @@ CREATE TABLE IF NOT EXISTS equip_applied_orders ( applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); +-- The web ACTION queue's idempotency ledger — the same job as the table above, +-- for the verbs that play the game rather than dress the character (extract, a +-- Siege bout). Its own table because the two queues have their own guid spaces +-- and their own poll loops, and a shared ledger would make a bug in one able to +-- silence the other. The stakes are higher here than for equip: a replayed +-- extraction ends a run the player resumed, and a replayed bout spends a day the +-- player has not been given back. +CREATE TABLE IF NOT EXISTS adv_applied_orders ( + guid TEXT PRIMARY KEY, + status TEXT NOT NULL, -- the terminal verdict we filed, replayed on re-offer + detail TEXT NOT NULL DEFAULT '', + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + -- Babysitting Service CREATE TABLE IF NOT EXISTS adventure_babysit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go index b95ee37..af8c5a7 100644 --- a/internal/peteclient/client.go +++ b/internal/peteclient/client.go @@ -983,6 +983,64 @@ func VerdictEquip(ctx context.Context, guid, status, detail string) error { return std.post(ctx, "/api/equip/verdict", payload) } +// --------------------------------------------------------------------------- +// The action queue +// +// The equip queue's sibling, and the first one that plays the game rather than +// dressing the character. An owner clicks "Pull out" on their own adventurer +// page or "Take your bout" on the war room; Pete records the intent and we drain +// it here. Same non-idempotent problem, same answer: the poller guards on the +// order guid before it runs anything, because an extraction ends a run and a +// bout spends the day's only swing, and neither converges on a replay. +// +// Nothing in an order names a character. Pete resolves that from the session +// (one account, one localpart, one adventurer) so there is no id on the wire for +// a client to forge — the contrast with EquipOrder, which has to carry an item +// id and a slot, is deliberate. +// --------------------------------------------------------------------------- + +// AdvOrder is one requested action as Pete describes it. owner_localpart is the +// Matrix localpart whose adventurer acts; token and character_name are display +// copy Pete froze at order time and we ignore both. +type AdvOrder struct { + GUID string `json:"guid"` + OwnerLocalpart string `json:"owner_localpart"` + Token string `json:"token"` + CharacterName string `json:"character_name"` + Action string `json:"action"` // extract / siege_join + Status string `json:"status"` + CreatedAt int64 `json:"created_at"` +} + +// Action names, the wire contract's half of storage.AdvAction* on Pete. +const ( + AdvOrderExtract = "extract" + AdvOrderSiegeJoin = "siege_join" +) + +// PendingOrders asks Pete for web actions waiting on us. A Pete predating the +// queue answers 404, surfaced here as an error the poll loop logs quietly. +func PendingOrders(ctx context.Context) ([]AdvOrder, error) { + if !Enabled() { + return nil, nil + } + var out []AdvOrder + if err := std.getJSON(ctx, "/api/adventure/orders/pending", &out); err != nil { + return nil, err + } + return out, nil +} + +// VerdictOrder files our verdict on a web action. Idempotent on Pete, so a +// retried verdict is safe. +func VerdictOrder(ctx context.Context, guid, status, detail string) error { + payload, err := json.Marshal(map[string]string{"guid": guid, "status": status, "detail": detail}) + if err != nil { + return err + } + return std.post(ctx, "/api/adventure/orders/verdict", payload) +} + // getJSON does a bearer-authed GET and decodes the body. func (c *Client) getJSON(ctx context.Context, path string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.IngestURL+path, nil) diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index bfeca23..91952d3 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -292,6 +292,7 @@ func (p *AdventurePlugin) Init() error { go p.mischiefTicker() go p.peteMischiefTicker() go p.peteEquipTicker() + go p.peteAdvOrderTicker() // Auto-cashout any arena runs left in 'awaiting' from a prior restart p.arenaCleanupStaleRuns() diff --git a/internal/plugin/adventure_worldboss.go b/internal/plugin/adventure_worldboss.go index 93c74f0..b634126 100644 --- a/internal/plugin/adventure_worldboss.go +++ b/internal/plugin/adventure_worldboss.go @@ -2,6 +2,7 @@ package plugin import ( "database/sql" + "errors" "fmt" "hash/fnv" "log/slog" @@ -607,42 +608,58 @@ func (p *AdventurePlugin) worldBossOperatorSpawn(ctx MessageContext) error { boss.Name, boss.Tier, groupInt(boss.HPMax))) } -// fightWorldBoss runs one player's daily bout against the Siege: an arena-style +// Sentinels for the four ways a bout can be refused, so a headless caller (the +// web action queue, pete_orders.go) can turn each into its own verdict instead of +// parsing a DM. `!adventure worldboss fight` maps them back to the prose it +// always sent. +var ( + errSiegeNoBoss = errors.New("siege: nothing camped outside town") + errSiegeNoCharacter = errors.New("siege: no adventurer") + errSiegeDead = errors.New("siege: adventurer is dead") + errSiegeAlreadyFought = errors.New("siege: today's bout already spent") +) + +// takeSiegeBout runs one player's daily bout against the Siege: an arena-style // solo fight whose damage is subtracted from the shared pool win or lose. Real // HP cost, no death — a loss leaves the fighter battered (floored at 1 HP) but // standing. The per-user lock serialises a player's own repeat submits, so the -// once-per-day gate can't be raced by a double-tap. -func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error { - userMu := p.advUserLock(ctx.Sender) +// once-per-day gate can't be raced by a double-tap — and that same lock is what +// makes it safe for the web queue and a Matrix command to reach for the bout at +// the same moment. +// +// The combat narration is DM'd from here whichever door the bout came through: a +// fight is thirty lines of blow-by-blow and belongs in Matrix, not in a one-line +// verdict on a web page. The caller gets the boss and the result to describe. +func (p *AdventurePlugin) takeSiegeBout(uid id.UserID) (worldBossBoutResult, *worldBossState, error) { + userMu := p.advUserLock(uid) userMu.Lock() defer userMu.Unlock() boss, err := loadActiveWorldBoss() if err != nil { - return p.SendDM(ctx.Sender, "Something went wrong reaching the Siege. Try again in a moment.") + return worldBossBoutResult{}, nil, fmt.Errorf("reaching the Siege: %w", err) } if boss == nil { - return p.SendDM(ctx.Sender, "No Siege is camped outside town right now.") + return worldBossBoutResult{}, nil, errSiegeNoBoss } - char, err := loadAdvCharacter(ctx.Sender) + char, err := loadAdvCharacter(uid) if err != nil || char == nil { - return p.SendDM(ctx.Sender, "You need an adventurer first — type `!adventure` to begin.") + return worldBossBoutResult{}, boss, errSiegeNoCharacter } if !char.Alive { - return p.SendDM(ctx.Sender, "You're dead. The Siege will have to wait until you're back on your feet.") + return worldBossBoutResult{}, boss, errSiegeDead } today := time.Now().UTC().Format("2006-01-02") - if worldBossBoutUsedToday(boss.ID, ctx.Sender, today) { - return p.SendDM(ctx.Sender, fmt.Sprintf( - "You've already taken your bout against **%s** today. Come back tomorrow — one fight per day.", boss.Name)) + if worldBossBoutUsedToday(boss.ID, uid, today) { + return worldBossBoutResult{}, boss, errSiegeAlreadyFought } - bout, err := p.resolveWorldBossBout(ctx.Sender, boss, today) + bout, err := p.resolveWorldBossBout(uid, boss, today) if err != nil { - slog.Error("worldboss: bout failed", "user", ctx.Sender, "err", err) - return p.SendDM(ctx.Sender, "The Siege combat hit an error. Try again in a moment.") + slog.Error("worldboss: bout failed", "user", uid, "err", err) + return worldBossBoutResult{}, boss, fmt.Errorf("running the bout: %w", err) } // Resolve a defeat BEFORE streaming the (multi-second) narration. The pool is @@ -653,7 +670,7 @@ func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error { p.resolveWorldBossDefeated(boss) } - playerName, _ := loadDisplayName(ctx.Sender) + playerName, _ := loadDisplayName(uid) if playerName == "" { playerName = "You" } @@ -661,18 +678,45 @@ func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error { fmt.Sprintf("⚔️ **The Siege — %s** (Tier %d)", boss.Name, boss.Tier), }, RenderCombatLog(bout.Combat, playerName, boss.Name)...) + <-p.sendZoneCombatMessages(uid, phaseMessages, siegeBoutFooter(bout, boss)) + return bout, boss, nil +} + +// siegeBoutFooter is the one-line result of a bout — the damage dealt and what +// the pool looks like now. It closes the Matrix narration and doubles as the web +// verdict, so the two doors can't drift into describing the same fight +// differently. +func siegeBoutFooter(bout worldBossBoutResult, boss *worldBossState) string { var footer string if bout.Killed { - footer = fmt.Sprintf("💥 You deal **%d** damage — the killing blow! **%s** falls!", bout.Damage, boss.Name) + footer = fmt.Sprintf("💥 You deal **%d** damage: the killing blow! **%s** falls!", bout.Damage, boss.Name) } else { footer = fmt.Sprintf("💥 You deal **%d** damage. **%s** has **%s / %s HP** left.", bout.Damage, boss.Name, groupInt(bout.Remaining), groupInt(boss.HPMax)) } if bout.Battered { - footer += "\nYou stagger out of the fight at 1 HP — rest up before your next outing." + footer += "\nYou stagger out of the fight at 1 HP. Rest up before your next outing." } + return footer +} - <-p.sendZoneCombatMessages(ctx.Sender, phaseMessages, footer) +// fightWorldBoss is `!adventure worldboss fight`: the command framing around +// takeSiegeBout, which does the fight and the narration. +func (p *AdventurePlugin) fightWorldBoss(ctx MessageContext) error { + _, boss, err := p.takeSiegeBout(ctx.Sender) + switch { + case errors.Is(err, errSiegeNoBoss): + return p.SendDM(ctx.Sender, "No Siege is camped outside town right now.") + case errors.Is(err, errSiegeNoCharacter): + return p.SendDM(ctx.Sender, "You need an adventurer first — type `!adventure` to begin.") + case errors.Is(err, errSiegeDead): + return p.SendDM(ctx.Sender, "You're dead. The Siege will have to wait until you're back on your feet.") + case errors.Is(err, errSiegeAlreadyFought): + return p.SendDM(ctx.Sender, fmt.Sprintf( + "You've already taken your bout against **%s** today. Come back tomorrow — one fight per day.", boss.Name)) + case err != nil: + return p.SendDM(ctx.Sender, "Something went wrong reaching the Siege. Try again in a moment.") + } return nil } diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index 55a101e..0c2bcb7 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -352,32 +352,54 @@ func resumeExpedition(expID string, supplies ExpeditionSupplies) error { // ── !extract command ──────────────────────────────────────────────────────── -func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error { - userMu := p.advUserLock(ctx.Sender) +// Sentinels for the two ways an extraction can be refused. They exist so the +// headless caller (the web action queue, pete_orders.go) can turn a refusal into +// its own verdict without parsing a DM. `!extract` maps them straight back to the +// prose it always sent. +var ( + errExtractNoRun = errors.New("extract: no active expedition") + errExtractNotLeader = errors.New("extract: not the party leader") +) + +// extractOutcome is what an extraction did, for a caller that has to describe it +// somewhere other than a DM. +type extractOutcome struct { + Zone string // display name + Day int +} + +// performExtraction is the whole of `!extract` minus the command framing: the +// per-user lock, the leader check, the state flip, the log line, the party +// fan-out and the emergence pet roll. It is shared with the web action queue so +// that pulling out from a phone is the *same* extraction, not a second +// implementation of one — the party still gets DM'd, the log still gets its +// line, and the resume window is the same window. +func (p *AdventurePlugin) performExtraction(uid id.UserID) (extractOutcome, error) { + userMu := p.advUserLock(uid) userMu.Lock() defer userMu.Unlock() - exp, isLeader, err := activeExpeditionFor(ctx.Sender) + exp, isLeader, err := activeExpeditionFor(uid) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error()) + return extractOutcome{}, fmt.Errorf("reading expedition state: %w", err) } if exp == nil { - return p.SendDM(ctx.Sender, "No active expedition to extract from.") + return extractOutcome{}, errExtractNoRun } if !isLeader { // Extraction ends the expedition for the whole roster, so it is the // leader's call — the same reasoning that makes `!flee` leader-only. - return p.SendDM(ctx.Sender, "Only your party leader can call the extraction. Ask them to `!extract`, or `!expedition leave` to walk out alone.") + return extractOutcome{}, errExtractNotLeader } zone, _ := getZone(exp.ZoneID) - updated, err := voluntaryExtractExpedition(ctx.Sender) + updated, err := voluntaryExtractExpedition(uid) if err != nil { - return p.SendDM(ctx.Sender, "Couldn't extract: "+err.Error()) + return extractOutcome{}, err } line := flavor.Pick(flavor.ExtractionVoluntary) _ = appendExpeditionLog(updated.ID, updated.CurrentDay, "narrative", "voluntary extraction", line) - markActedToday(ctx.Sender) + markActedToday(uid) var b strings.Builder b.WriteString(fmt.Sprintf("🚪 **Extraction — %s, Day %d**\n\n", @@ -401,8 +423,24 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error { // Emergence seam: surfacing from a run is when an animal may have moved // into the empty house. Every member surfaced, so every member rolls. - for _, uid := range expeditionAudience(updated) { - p.maybeRollPetArrivalOnEmerge(uid) + for _, member := range expeditionAudience(updated) { + p.maybeRollPetArrivalOnEmerge(member) + } + return extractOutcome{Zone: zone.Display, Day: updated.CurrentDay}, nil +} + +// handleExtractCmd is `!extract`: the command framing around performExtraction. +// The extraction itself, including the DM everyone in the party gets, happens in +// there — so this only has to turn a refusal back into the prose it always sent. +func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error { + _, err := p.performExtraction(ctx.Sender) + switch { + case errors.Is(err, errExtractNoRun): + return p.SendDM(ctx.Sender, "No active expedition to extract from.") + case errors.Is(err, errExtractNotLeader): + return p.SendDM(ctx.Sender, "Only your party leader can call the extraction. Ask them to `!extract`, or `!expedition leave` to walk out alone.") + case err != nil: + return p.SendDM(ctx.Sender, "Couldn't extract: "+err.Error()) } return nil } diff --git a/internal/plugin/pete_orders.go b/internal/plugin/pete_orders.go new file mode 100644 index 0000000..5eef51a --- /dev/null +++ b/internal/plugin/pete_orders.go @@ -0,0 +1,222 @@ +package plugin + +// The web action queue's game-side loop — the equip queue's sibling, and the +// first one where the web plays the game rather than dressing the character. +// +// An owner, signed in on Pete, asks to pull out of a run or to take today's swing +// at the Siege. Pete records the intent; we poll for it, run the real command +// path (the same one `!extract` and `!adventure worldboss fight` run — not a +// second implementation of it), and file a verdict Pete shows them. +// +// Same non-idempotency problem as equip, with higher stakes: replaying an +// extraction would end a run the player had already resumed, and replaying a bout +// would spend a day's swing they never got back. So before touching anything we +// check the adv_applied_orders ledger — if this order's guid is there, the +// mutation landed on an earlier tick and we only lost the verdict-ack, so we +// re-file the stored verdict and mutate nothing. +// +// The poll is faster than equip's (15s vs 30s) for one reason: an extraction is +// the answer to something the player is *watching* go wrong on the who page. A +// minute of silence there reads as a button that didn't work. + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "time" + + "gogobee/internal/db" + "gogobee/internal/peteclient" + "maunium.net/go/mautrix/id" +) + +const ( + advOrderPollInterval = 15 * time.Second + // A Siege bout runs a full combat and streams its narration to Matrix before + // takeSiegeBout returns, so this budget is minutes, not seconds — the equip + // path's 20s would abandon a fight that was going fine. + advOrderPollTimeout = 5 * time.Minute +) + +// peteAdvOrderTicker polls Pete for web actions and fulfils them. Started +// alongside the other adventure tickers; exits on stopCh. +func (p *AdventurePlugin) peteAdvOrderTicker() { + if !peteclient.Enabled() { + return // no Pete wire configured; the action queue is simply off + } + ticker := time.NewTicker(advOrderPollInterval) + defer ticker.Stop() + for { + select { + case <-p.stopCh: + return + case <-ticker.C: + p.pollAdvOrders() + } + } +} + +func (p *AdventurePlugin) pollAdvOrders() { + ctx, cancel := context.WithTimeout(context.Background(), advOrderPollTimeout) + defer cancel() + + orders, err := peteclient.PendingOrders(ctx) + if err != nil { + // A Pete predating the queue answers 404; a wire blip looks the same. Quiet + // on purpose — this must not spam while Pete hasn't shipped the endpoint. + slog.Debug("orders: poll failed", "err", err) + return + } + for _, order := range orders { + p.fulfilAdvOrder(ctx, order) + } +} + +// fulfilAdvOrder applies one action and files its verdict. A transient failure is +// left pending for the next poll (no verdict); a permanent one gets a specific +// rejection. The guid ledger makes a re-offer after a lost ack a no-op that +// simply re-files the verdict. +func (p *AdventurePlugin) fulfilAdvOrder(ctx context.Context, order peteclient.AdvOrder) { + // Already applied on an earlier tick? Re-file the stored verdict, mutate nothing. + if status, detail, ok := advOrderAlreadyApplied(order.GUID); ok { + if err := peteclient.VerdictOrder(ctx, order.GUID, status, detail); err != nil { + slog.Warn("orders: re-file verdict push failed, will retry next poll", + "order", order.GUID, "status", status, "err", err) + } + return + } + + owner, ok := p.equipOwnerMXID(order.OwnerLocalpart) + if !ok { + // The client isn't up (tests) or the localpart is empty. Not our order to + // fail permanently — leave it pending and try again once we can name the owner. + slog.Debug("orders: cannot resolve owner, leaving pending", "order", order.GUID, "owner", order.OwnerLocalpart) + return + } + + status, detail, retry := p.applyAdvOrder(owner, order) + if retry { + return // transient; leave pending for the next tick + } + + // Record the verdict BEFORE pushing it, so a crash after the mutation still + // short-circuits next tick and re-files rather than re-applying. Same ordering + // and the same reasoning as the equip poller. + if err := recordAdvApplied(order.GUID, status, detail); err != nil { + slog.Warn("orders: failed to record applied order, leaving pending", + "order", order.GUID, "status", status, "err", err) + return + } + if err := peteclient.VerdictOrder(ctx, order.GUID, status, detail); err != nil { + slog.Warn("orders: verdict push failed, will re-file next poll", + "order", order.GUID, "status", status, "err", err) + return + } + slog.Info("orders: web action fulfilled", "order", order.GUID, "action", order.Action, "status", status) +} + +// applyAdvOrder runs the real action. It returns the terminal status and a human +// note for Pete, or retry=true for a transient fault that should leave the order +// pending. It records nothing and pushes nothing — the caller does both. +// +// Note what is NOT here: a per-user lock. Both verbs take it inside their own +// shared helper (performExtraction, takeSiegeBout), which is what serialises them +// against the Matrix commands running the very same code. Taking it here too +// would deadlock on a non-reentrant mutex. +func (p *AdventurePlugin) applyAdvOrder(owner id.UserID, order peteclient.AdvOrder) (status, detail string, retry bool) { + switch order.Action { + case peteclient.AdvOrderExtract: + out, err := p.performExtraction(owner) + switch { + case errors.Is(err, errExtractNoRun): + return "rejected_not_running", "You weren't on an expedition.", false + case errors.Is(err, errExtractNotLeader): + return "rejected_not_leader", "Only the party leader can call the extraction.", false + case err != nil: + // Every remaining failure here is a DB fault. Leave it pending: nothing + // has been written, so the next tick retries cleanly. + slog.Warn("orders: extraction failed", "order", order.GUID, "user", owner, "err", err) + return "", "", true + } + return "applied", fmt.Sprintf( + "Out of %s on day %d. Loot, XP and coins kept. Say !resume within 7 days to go back in.", + out.Zone, out.Day), false + + case peteclient.AdvOrderSiegeJoin: + bout, boss, err := p.takeSiegeBout(owner) + switch { + case errors.Is(err, errSiegeNoBoss): + return "rejected_no_siege", "No Siege is camped outside town right now.", false + case errors.Is(err, errSiegeNoCharacter): + return "rejected_unavailable", "You don't have an adventurer yet.", false + case errors.Is(err, errSiegeDead): + return "rejected_unavailable", "You're dead. The Siege will have to wait.", false + case errors.Is(err, errSiegeAlreadyFought): + return "rejected_already_fought", "You've already taken your bout today. One fight per day.", false + case err != nil: + // A combat that errored persisted nothing terminal, but it may have + // written HP. Retry is still right: the once-per-day gate is stamped by + // the contribution row, which only lands on a bout that completed. + slog.Warn("orders: siege bout failed", "order", order.GUID, "user", owner, "err", err) + return "", "", true + } + // The blow-by-blow went to Matrix; the web gets the same one-line result the + // narration closed with, minus its markdown. + return "applied", advOrderPlainText(siegeBoutFooter(bout, boss)), false + + default: + // Pete validates the action before it ever queues an order, so this is a + // contract breach, not a user mistake. Reject permanently rather than spin. + return "rejected_unavailable", "Unknown action.", false + } +} + +// advOrderPlainText strips the Matrix markdown out of a line reused as a web +// verdict. Pete renders the detail as text, so asterisks would show up literally. +func advOrderPlainText(s string) string { + out := make([]rune, 0, len(s)) + for _, r := range s { + switch r { + case '*': + continue + case '\n': + out = append(out, ' ') + default: + out = append(out, r) + } + } + return string(out) +} + +// ---- the applied-order ledger -------------------------------------------------- + +// advOrderAlreadyApplied reports the verdict we filed for an order, if we have +// already applied it. This is the short-circuit that keeps a re-offered order from +// re-running its non-idempotent mutation. +func advOrderAlreadyApplied(guid string) (status, detail string, ok bool) { + err := db.Get().QueryRow( + `SELECT status, detail FROM adv_applied_orders WHERE guid = ?`, guid, + ).Scan(&status, &detail) + if errors.Is(err, sql.ErrNoRows) { + return "", "", false + } + if err != nil { + // A read failure sends us down the mutation path and risks re-running an + // extraction or a bout, so it is logged loudly. A transient one self-heals: + // the mutation is guarded by this same table, so the next poll reads it. + slog.Error("orders: applied-ledger read failed", "order", guid, "err", err) + return "", "", false + } + return status, detail, true +} + +// recordAdvApplied stamps an order as applied with the verdict we're about to +// file. OR IGNORE so a re-file that somehow reaches here can't error on the guid. +func recordAdvApplied(guid, status, detail string) error { + _, err := db.Get().Exec( + `INSERT OR IGNORE INTO adv_applied_orders (guid, status, detail) VALUES (?, ?, ?)`, + guid, status, detail) + return err +} diff --git a/internal/plugin/pete_orders_test.go b/internal/plugin/pete_orders_test.go new file mode 100644 index 0000000..91bd49e --- /dev/null +++ b/internal/plugin/pete_orders_test.go @@ -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) + } +}