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) + } +}