Files
gogobee/internal/plugin/combat_session.go
prosolis 886eb5a75b Combat: wire turn-based elite/boss fights to the command surface
Routes Elite/Boss rooms off the auto-resolve SimulateCombat path and onto
the persisted turn-based engine. !zone advance now stops at an Elite/Boss
doorway; the player engages with !fight, then resolves one full round per
!attack / !flee. A won CombatSession is the record that the room's combat
is done, so a fresh !zone advance clears the room and advances the graph.

- buildZoneCombatants: shared player/enemy Combatant builder extracted from
  runZoneCombat; combatantsForSession rebuilds the pair from a session row.
- runCombatRound loops the phase state machine through a whole round;
  finishCombatSession runs HP/XP/loot/kill/threat/mood close-out.
- getCombatSessionForEncounter lets the room resolver tell "already won"
  apart from "not yet fought".
- !zone advance/enter/go blocked while a session is active.
- resolveBossRoom deleted (dead after the reroute).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:16:45 -07:00

272 lines
9.0 KiB
Go

package plugin
import (
cryptorand "crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// Phase 13 — Turn-based combat session persistence.
//
// One persisted CombatSession per manual elite/boss fight. The schema
// (combat_session in db.go) was added in the prior commit; this layer adds
// the struct, accessors, and the timeout reaper, mirroring the dnd_expedition
// persistence shape. The intra-round phase state machine that drives a fight
// across these rows lives in combat_turn_engine.go.
// Combat session lifecycle status (combat_session.status).
const (
CombatStatusActive = "active"
CombatStatusWon = "won"
CombatStatusLost = "lost"
CombatStatusFled = "fled"
CombatStatusExpired = "expired"
)
// Intra-round phase state-machine positions (combat_session.phase).
const (
CombatPhasePlayerTurn = "player_turn"
CombatPhaseEnemyTurn = "enemy_turn"
CombatPhaseRoundEnd = "round_end"
CombatPhaseOver = "over"
)
// combatSessionTTL is how long a session may sit idle before the timeout
// reaper sweeps it. Matches the started_at + 1h note in the schema.
const combatSessionTTL = time.Hour
// CombatStatuses is the serialized between-round effect state for a fight —
// the subset of combatState fields that genuinely persist round-to-round
// (monster-ability effects). Fight-scoped one-shots (rage, Lucky reroll,
// ward/heal charges) are deliberately not carried here: they reset if a fight
// is resumed across a bot restart. Extending coverage to those one-shots is
// the command-wiring PR's job, once it reconstructs Combatants from a session.
type CombatStatuses struct {
PoisonTicks int `json:"poison_ticks,omitempty"`
PoisonDmg int `json:"poison_dmg,omitempty"`
StunPlayer bool `json:"stun_player,omitempty"`
Enraged bool `json:"enraged,omitempty"`
ArmorBroken bool `json:"armor_broken,omitempty"`
ArmorBreakAmt float64 `json:"armor_break_amt,omitempty"`
}
// CombatSession is the in-memory shape of a combat_session row.
type CombatSession struct {
SessionID string
UserID string
RunID string
EncounterID string
EnemyID string
Round int
Phase string
PlayerHP int
PlayerHPMax int
EnemyHP int
EnemyHPMax int
Statuses CombatStatuses
TurnLog []CombatEvent
Status string
StartedAt time.Time
LastActionAt time.Time
ExpiresAt time.Time
}
// IsActive reports whether the fight is still in flight.
func (s *CombatSession) IsActive() bool { return s.Status == CombatStatusActive }
// Errors returned by the combat session layer.
var (
ErrCombatSessionAlreadyActive = errors.New("combat session already active for player")
ErrNoActiveCombatSession = errors.New("no active combat session for player")
)
// combatSessionCols is the shared SELECT column list, kept in sync with
// scanCombatSession's Scan order.
const combatSessionCols = `
session_id, user_id, run_id, encounter_id, enemy_id,
round, phase, player_hp, player_hp_max, enemy_hp, enemy_hp_max,
statuses_json, turn_log_json, status,
started_at, last_action_at, expires_at`
// newCombatSessionID — 16-char hex token. Same scheme as zone runs / expeditions.
func newCombatSessionID() string {
var b [8]byte
if _, err := cryptorand.Read(b[:]); err != nil {
// Vanishingly unlikely; fall through with a zeroed prefix.
}
return hex.EncodeToString(b[:])
}
// startCombatSession opens a new manual fight for the player. The caller has
// already resolved the encounter and built the enemy/player HP pools. At most
// one row per user may be 'active' — enforced here, not by a DB constraint.
func startCombatSession(userID id.UserID, runID, encounterID, enemyID string, playerHP, playerHPMax, enemyHP, enemyHPMax int) (*CombatSession, error) {
existing, err := getActiveCombatSession(userID)
if err != nil {
return nil, err
}
if existing != nil {
return nil, ErrCombatSessionAlreadyActive
}
now := time.Now().UTC()
s := &CombatSession{
SessionID: newCombatSessionID(),
UserID: string(userID),
RunID: runID,
EncounterID: encounterID,
EnemyID: enemyID,
Round: 1,
Phase: CombatPhasePlayerTurn,
PlayerHP: playerHP,
PlayerHPMax: playerHPMax,
EnemyHP: enemyHP,
EnemyHPMax: enemyHPMax,
TurnLog: []CombatEvent{},
Status: CombatStatusActive,
StartedAt: now,
LastActionAt: now,
ExpiresAt: now.Add(combatSessionTTL),
}
statusesJSON, _ := json.Marshal(s.Statuses)
logJSON, _ := json.Marshal(s.TurnLog)
if _, err := db.Get().Exec(`
INSERT INTO combat_session
(session_id, user_id, run_id, encounter_id, enemy_id,
round, phase, player_hp, player_hp_max, enemy_hp, enemy_hp_max,
statuses_json, turn_log_json, status,
started_at, last_action_at, expires_at)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`,
s.SessionID, s.UserID, s.RunID, s.EncounterID, s.EnemyID,
s.Phase, s.PlayerHP, s.PlayerHPMax, s.EnemyHP, s.EnemyHPMax,
string(statusesJSON), string(logJSON), now, now, s.ExpiresAt,
); err != nil {
return nil, fmt.Errorf("insert combat session: %w", err)
}
return s, nil
}
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil).
func getActiveCombatSession(userID id.UserID) (*CombatSession, error) {
row := db.Get().QueryRow(`SELECT `+combatSessionCols+`
FROM combat_session
WHERE user_id = ? AND status = 'active'
ORDER BY started_at DESC
LIMIT 1`, string(userID))
s, err := scanCombatSession(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return s, err
}
// getCombatSession fetches by ID regardless of status. Test/admin/reaper use.
func getCombatSession(sessionID string) (*CombatSession, error) {
row := db.Get().QueryRow(`SELECT `+combatSessionCols+`
FROM combat_session WHERE session_id = ?`, sessionID)
s, err := scanCombatSession(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return s, err
}
// getCombatSessionForEncounter returns the most recent session for a
// (run, encounter) pair regardless of status, or (nil, nil) if none exists.
// The room-resolution path uses it to tell "elite not yet fought" (nil) apart
// from "elite already won" (a terminal session) without an active-only filter.
func getCombatSessionForEncounter(runID, encounterID string) (*CombatSession, error) {
row := db.Get().QueryRow(`SELECT `+combatSessionCols+`
FROM combat_session
WHERE run_id = ? AND encounter_id = ?
ORDER BY started_at DESC
LIMIT 1`, runID, encounterID)
s, err := scanCombatSession(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return s, err
}
func scanCombatSession(row scanner) (*CombatSession, error) {
var (
s CombatSession
statusesJSON string
logJSON string
)
if err := row.Scan(
&s.SessionID, &s.UserID, &s.RunID, &s.EncounterID, &s.EnemyID,
&s.Round, &s.Phase, &s.PlayerHP, &s.PlayerHPMax, &s.EnemyHP, &s.EnemyHPMax,
&statusesJSON, &logJSON, &s.Status,
&s.StartedAt, &s.LastActionAt, &s.ExpiresAt,
); err != nil {
return nil, err
}
if statusesJSON != "" {
if err := json.Unmarshal([]byte(statusesJSON), &s.Statuses); err != nil {
return nil, fmt.Errorf("decode statuses_json: %w", err)
}
}
if logJSON != "" {
if err := json.Unmarshal([]byte(logJSON), &s.TurnLog); err != nil {
return nil, fmt.Errorf("decode turn_log_json: %w", err)
}
}
if s.TurnLog == nil {
s.TurnLog = []CombatEvent{}
}
return &s, nil
}
// saveCombatSession persists the mutable fields after a state-machine step.
// session_id / user_id / run_id / encounter_id / enemy_id / *_hp_max are
// immutable for a fight's lifetime and are not rewritten.
func saveCombatSession(s *CombatSession) error {
statusesJSON, _ := json.Marshal(s.Statuses)
logJSON, _ := json.Marshal(s.TurnLog)
now := time.Now().UTC()
if _, err := db.Get().Exec(`
UPDATE combat_session
SET round = ?, phase = ?,
player_hp = ?, enemy_hp = ?,
statuses_json = ?, turn_log_json = ?,
status = ?, last_action_at = ?
WHERE session_id = ?`,
s.Round, s.Phase, s.PlayerHP, s.EnemyHP,
string(statusesJSON), string(logJSON), s.Status, now, s.SessionID,
); err != nil {
return err
}
s.LastActionAt = now
return nil
}
// sweepExpiredCombatSessions is the timeout reaper. It marks every active
// session past its expires_at as 'expired' (a terminal status alongside
// won/lost/fled) and parks it in the 'over' phase. Auto-playing an abandoned
// fight to a real win/loss needs Combatant reconstruction, which lands with
// the command-wiring PR; until then 'expired' is the safe terminal outcome —
// no fatal-blow side effects, treated like a retreat. Returns the sweep count.
func sweepExpiredCombatSessions() (int, error) {
res, err := db.Get().Exec(`
UPDATE combat_session
SET status = 'expired',
phase = 'over',
last_action_at = CURRENT_TIMESTAMP
WHERE status = 'active'
AND expires_at <= CURRENT_TIMESTAMP`)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return int(n), nil
}