mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
Combat: add turn-based session accessors and round-loop state machine
combat_session.go: CombatSession persistence layer mirroring the dnd_expedition pattern — CRUD accessors, one-active-per-user enforcement, and the timeout reaper (sweeps stale sessions to 'expired'). combat_turn_engine.go: the player_turn -> enemy_turn -> round_end -> over state machine. advanceCombatSession seeds a deterministic per-(round,phase) RNG, resolves one phase via the shared attack primitives, commits, and persists. The deferred poison/status tick lands in round_end now that the round-loop shape exists. CombatStatuses persists only between-round monster-ability effects; the reaper marks sessions 'expired' rather than auto-playing them — both gaps depend on Combatant reconstruction, which lands with the command-wiring PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
254
internal/plugin/combat_session.go
Normal file
254
internal/plugin/combat_session.go
Normal file
@@ -0,0 +1,254 @@
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user