Combat: auto-play timed-out turn-based sessions to a real win/loss

A combat session abandoned past its 1h TTL is now resumed from persisted
mid-state and auto-played through the shared resolver to a real win or
loss, rather than flatly marked as a retreat. The bulk-UPDATE sweep is
replaced by listExpiredCombatSessions plus a reaper that locks the user,
auto-plays the fight, runs the normal close-out, and DMs the outcome.
markCombatSessionExpired remains the terminal fallback for sessions that
can't be reconstructed. Wired into eventTicker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-14 06:30:52 -07:00
parent 886eb5a75b
commit a0961fee8a
4 changed files with 161 additions and 22 deletions

View File

@@ -53,6 +53,9 @@ func (p *AdventurePlugin) eventTicker() {
// Expire stale pending events every tick // Expire stale pending events every tick
expireAdvPendingEvents() expireAdvPendingEvents()
// Auto-play any combat sessions past their 1h timeout.
p.reapExpiredCombatSessions()
advEventScheduleMu.Lock() advEventScheduleMu.Lock()
if advEventScheduleDay != dateKey { if advEventScheduleDay != dateKey {
advEventSchedule = make(map[string]int) advEventSchedule = make(map[string]int)

View File

@@ -0,0 +1,98 @@
package plugin
import (
"fmt"
"log/slog"
"maunium.net/go/mautrix/id"
)
// Phase 13 — turn-based combat timeout reaper.
//
// A CombatSession expires 1h after its last action (combatSessionTTL). Per the
// 2026-05-13 timeout decision, an abandoned fight is not flatly marked as a
// retreat — the reaper resumes it from persisted mid-state and auto-plays the
// rest of the fight through the same shared resolver a live !attack uses,
// landing on a real win or loss. The player is DM'd the outcome.
//
// Sessions that cannot be reconstructed (zone run deleted, enemy no longer in
// the bestiary) or that fail to converge within reaperRoundCap fall back to the
// terminal 'expired' status with no fatal-blow side effects — see
// markCombatSessionExpired.
// reaperRoundCap bounds the auto-play loop. A turn-based round always lands at
// least one damage roll eventually, so a real fight converges well within this;
// the cap only guards against a pathological non-terminating session.
const reaperRoundCap = 300
// reapExpiredCombatSessions is the timeout reaper, driven off eventTicker (one
// pass per minute). It auto-plays every stale session to a terminal status.
func (p *AdventurePlugin) reapExpiredCombatSessions() {
expired, err := listExpiredCombatSessions()
if err != nil {
slog.Error("combat: reaper failed to list expired sessions", "err", err)
return
}
for _, sess := range expired {
p.reapCombatSession(sess.UserID, sess.SessionID)
}
}
// reapCombatSession auto-plays a single stale session under the player's lock.
// It re-fetches the session after locking: the player may have finished the
// fight in the window between listing and locking, in which case there is
// nothing to do.
func (p *AdventurePlugin) reapCombatSession(userIDStr, sessionID string) {
userID := id.UserID(userIDStr)
userMu := p.advUserLock(userID)
userMu.Lock()
defer userMu.Unlock()
sess, err := getActiveCombatSession(userID)
if err != nil {
slog.Error("combat: reaper failed to reload session", "user", userID, "err", err)
return
}
// Gone, replaced, or no longer the stale one — the player acted first.
if sess == nil || sess.SessionID != sessionID {
return
}
player, enemy, err := p.combatantsForSession(sess)
if err != nil {
// Can't reconstruct the fight — park it terminal so it isn't retried
// every minute forever.
slog.Warn("combat: reaper cannot rebuild session, marking expired",
"user", userID, "session", sess.SessionID, "err", err)
if merr := markCombatSessionExpired(sess.SessionID); merr != nil {
slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr)
}
return
}
rounds := 0
for sess.IsActive() {
if rounds >= reaperRoundCap {
slog.Warn("combat: reaper hit round cap, marking expired",
"user", userID, "session", sess.SessionID)
if merr := markCombatSessionExpired(sess.SessionID); merr != nil {
slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr)
}
return
}
if _, rerr := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionAttack}); rerr != nil {
slog.Error("combat: reaper round failed", "user", userID, "session", sess.SessionID, "err", rerr)
if merr := markCombatSessionExpired(sess.SessionID); merr != nil {
slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr)
}
return
}
rounds++
}
outcome := p.finishCombatSession(userID, sess, enemy)
preamble := fmt.Sprintf("⏳ Your fight with **%s** timed out — TwinBee finished it for you.\n\n", enemy.Name)
if err := p.SendDM(userID, preamble+outcome); err != nil {
slog.Error("combat: reaper failed to DM outcome", "user", userID, "err", err)
}
}

View File

@@ -249,23 +249,42 @@ func saveCombatSession(s *CombatSession) error {
return nil return nil
} }
// sweepExpiredCombatSessions is the timeout reaper. It marks every active // listExpiredCombatSessions returns every active session past its expires_at.
// session past its expires_at as 'expired' (a terminal status alongside // The timeout reaper (reapExpiredCombatSessions) auto-plays each of these to a
// won/lost/fled) and parks it in the 'over' phase. Auto-playing an abandoned // real win/loss from persisted mid-state — per the 2026-05-13 decision, an
// fight to a real win/loss needs Combatant reconstruction, which lands with // abandoned fight is finished by the engine, not flatly marked as a retreat.
// the command-wiring PR; until then 'expired' is the safe terminal outcome — func listExpiredCombatSessions() ([]*CombatSession, error) {
// no fatal-blow side effects, treated like a retreat. Returns the sweep count. rows, err := db.Get().Query(`SELECT `+combatSessionCols+`
func sweepExpiredCombatSessions() (int, error) { FROM combat_session
res, err := db.Get().Exec(` WHERE status = 'active'
AND expires_at <= CURRENT_TIMESTAMP
ORDER BY started_at ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*CombatSession
for rows.Next() {
s, err := scanCombatSession(rows)
if err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// markCombatSessionExpired is the fallback terminal outcome for a stale session
// the reaper cannot auto-play (zone run gone, enemy missing from the bestiary,
// or a runaway fight that won't converge). It parks the row in 'expired'/'over'
// with no fatal-blow side effects — treated like a retreat.
func markCombatSessionExpired(sessionID string) error {
_, err := db.Get().Exec(`
UPDATE combat_session UPDATE combat_session
SET status = 'expired', SET status = 'expired',
phase = 'over', phase = 'over',
last_action_at = CURRENT_TIMESTAMP last_action_at = CURRENT_TIMESTAMP
WHERE status = 'active' WHERE session_id = ? AND status = 'active'`, sessionID)
AND expires_at <= CURRENT_TIMESTAMP`) return err
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
} }

View File

@@ -88,35 +88,54 @@ func TestStartCombatSession_RejectsConcurrent(t *testing.T) {
} }
} }
func TestSweepExpiredCombatSessions(t *testing.T) { func TestListExpiredCombatSessions(t *testing.T) {
setupZoneRunTestDB(t) setupZoneRunTestDB(t)
uid := id.UserID("@combat-sweep:example.org") uid := id.UserID("@combat-sweep:example.org")
fresh := id.UserID("@combat-fresh:example.org")
defer cleanupCombatSessions(uid) defer cleanupCombatSessions(uid)
defer cleanupCombatSessions(fresh)
s, err := startCombatSession(uid, "r", "n", "boss", 60, 60, 200, 200) s, err := startCombatSession(uid, "r", "n", "boss", 60, 60, 200, 200)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Backdate expiry so the reaper considers it stale. // A second, non-stale session should never show up in the list.
if _, err := startCombatSession(fresh, "r2", "n2", "rat", 40, 40, 20, 20); err != nil {
t.Fatal(err)
}
// Backdate expiry so the reaper considers the first one stale.
if _, err := db.Get().Exec( if _, err := db.Get().Exec(
`UPDATE combat_session SET expires_at = datetime('now', '-1 hour') WHERE session_id = ?`, `UPDATE combat_session SET expires_at = datetime('now', '-1 hour') WHERE session_id = ?`,
s.SessionID); err != nil { s.SessionID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
n, err := sweepExpiredCombatSessions()
expired, err := listExpiredCombatSessions()
if err != nil { if err != nil {
t.Fatalf("sweep: %v", err) t.Fatalf("list: %v", err)
} }
if n < 1 { if len(expired) != 1 {
t.Errorf("sweep count = %d, want >= 1", n) t.Fatalf("expired count = %d, want 1", len(expired))
}
if expired[0].SessionID != s.SessionID {
t.Errorf("expired[0] = %q, want %q", expired[0].SessionID, s.SessionID)
}
// markCombatSessionExpired is the non-auto-play fallback path.
if err := markCombatSessionExpired(s.SessionID); err != nil {
t.Fatalf("mark expired: %v", err)
} }
if active, _ := getActiveCombatSession(uid); active != nil { if active, _ := getActiveCombatSession(uid); active != nil {
t.Errorf("expected no active session after sweep, got %+v", active) t.Errorf("expected no active session after mark, got %+v", active)
} }
reaped, _ := getCombatSession(s.SessionID) reaped, _ := getCombatSession(s.SessionID)
if reaped.Status != CombatStatusExpired || reaped.Phase != CombatPhaseOver { if reaped.Status != CombatStatusExpired || reaped.Phase != CombatPhaseOver {
t.Errorf("reaped session: status=%q phase=%q", reaped.Status, reaped.Phase) t.Errorf("reaped session: status=%q phase=%q", reaped.Status, reaped.Phase)
} }
// The non-stale session is untouched.
if again, _ := listExpiredCombatSessions(); len(again) != 0 {
t.Errorf("expected 0 expired after mark, got %d", len(again))
}
} }
// ── State machine ────────────────────────────────────────────────────────── // ── State machine ──────────────────────────────────────────────────────────