Files
gogobee/internal/plugin/pete_orders.go
T
prosolis fff2aa79d0 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
2026-07-24 18:55:52 -07:00

223 lines
8.8 KiB
Go

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
}