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

@@ -88,35 +88,54 @@ func TestStartCombatSession_RejectsConcurrent(t *testing.T) {
}
}
func TestSweepExpiredCombatSessions(t *testing.T) {
func TestListExpiredCombatSessions(t *testing.T) {
setupZoneRunTestDB(t)
uid := id.UserID("@combat-sweep:example.org")
fresh := id.UserID("@combat-fresh:example.org")
defer cleanupCombatSessions(uid)
defer cleanupCombatSessions(fresh)
s, err := startCombatSession(uid, "r", "n", "boss", 60, 60, 200, 200)
if err != nil {
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(
`UPDATE combat_session SET expires_at = datetime('now', '-1 hour') WHERE session_id = ?`,
s.SessionID); err != nil {
t.Fatal(err)
}
n, err := sweepExpiredCombatSessions()
expired, err := listExpiredCombatSessions()
if err != nil {
t.Fatalf("sweep: %v", err)
t.Fatalf("list: %v", err)
}
if n < 1 {
t.Errorf("sweep count = %d, want >= 1", n)
if len(expired) != 1 {
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 {
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)
if reaped.Status != CombatStatusExpired || reaped.Phase != CombatPhaseOver {
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 ──────────────────────────────────────────────────────────