From 269d13ffe8c297b9f63abacdc114f2a5d7aa32ae Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 14 May 2026 05:55:32 -0700 Subject: [PATCH] Combat: add turn-based session accessors and round-loop state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/plugin/combat_session.go | 254 ++++++++++++++++++++++ internal/plugin/combat_session_test.go | 284 +++++++++++++++++++++++++ internal/plugin/combat_turn_engine.go | 234 ++++++++++++++++++++ 3 files changed, 772 insertions(+) create mode 100644 internal/plugin/combat_session.go create mode 100644 internal/plugin/combat_session_test.go create mode 100644 internal/plugin/combat_turn_engine.go diff --git a/internal/plugin/combat_session.go b/internal/plugin/combat_session.go new file mode 100644 index 0000000..4654913 --- /dev/null +++ b/internal/plugin/combat_session.go @@ -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 +} diff --git a/internal/plugin/combat_session_test.go b/internal/plugin/combat_session_test.go new file mode 100644 index 0000000..8e9b93c --- /dev/null +++ b/internal/plugin/combat_session_test.go @@ -0,0 +1,284 @@ +package plugin + +import ( + "math/rand/v2" + "testing" + + "gogobee/internal/db" + "maunium.net/go/mautrix/id" +) + +func cleanupCombatSessions(uid id.UserID) { + _, _ = db.Get().Exec(`DELETE FROM combat_session WHERE user_id = ?`, string(uid)) +} + +// ── Persistence layer ────────────────────────────────────────────────────── + +func TestStartCombatSession_RoundTrip(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@combat-roundtrip:example.org") + defer cleanupCombatSessions(uid) + + s, err := startCombatSession(uid, "run-1", "node-7", "owlbear", 80, 80, 120, 120) + if err != nil { + t.Fatalf("startCombatSession: %v", err) + } + if s.Status != CombatStatusActive || s.Phase != CombatPhasePlayerTurn { + t.Errorf("fresh session: status=%q phase=%q", s.Status, s.Phase) + } + if s.Round != 1 { + t.Errorf("round = %d, want 1", s.Round) + } + + got, err := getActiveCombatSession(uid) + if err != nil || got == nil { + t.Fatalf("getActiveCombatSession: %v / %v", got, err) + } + if got.SessionID != s.SessionID { + t.Errorf("id mismatch: %q vs %q", got.SessionID, s.SessionID) + } + if got.RunID != "run-1" || got.EncounterID != "node-7" || got.EnemyID != "owlbear" { + t.Errorf("identity round-trip wrong: %+v", got) + } + if got.PlayerHP != 80 || got.PlayerHPMax != 80 || got.EnemyHP != 120 || got.EnemyHPMax != 120 { + t.Errorf("hp round-trip wrong: %+v", got) + } + if len(got.TurnLog) != 0 { + t.Errorf("expected empty turn log, got %v", got.TurnLog) + } + + // Mutable-field persistence. + got.Round = 3 + got.Phase = CombatPhaseRoundEnd + got.PlayerHP = 40 + got.EnemyHP = 15 + got.Statuses = CombatStatuses{PoisonTicks: 2, PoisonDmg: 5, Enraged: true} + got.TurnLog = append(got.TurnLog, CombatEvent{Round: 3, Actor: "player", Action: "hit", Damage: 9}) + if err := saveCombatSession(got); err != nil { + t.Fatalf("saveCombatSession: %v", err) + } + reloaded, err := getCombatSession(s.SessionID) + if err != nil || reloaded == nil { + t.Fatalf("getCombatSession: %v / %v", reloaded, err) + } + if reloaded.Round != 3 || reloaded.Phase != CombatPhaseRoundEnd { + t.Errorf("round/phase not persisted: %+v", reloaded) + } + if reloaded.PlayerHP != 40 || reloaded.EnemyHP != 15 { + t.Errorf("hp not persisted: %+v", reloaded) + } + if reloaded.Statuses != (CombatStatuses{PoisonTicks: 2, PoisonDmg: 5, Enraged: true}) { + t.Errorf("statuses not persisted: %+v", reloaded.Statuses) + } + if len(reloaded.TurnLog) != 1 || reloaded.TurnLog[0].Action != "hit" { + t.Errorf("turn log not persisted: %+v", reloaded.TurnLog) + } +} + +func TestStartCombatSession_RejectsConcurrent(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@combat-concur:example.org") + defer cleanupCombatSessions(uid) + + if _, err := startCombatSession(uid, "r", "n", "rat", 50, 50, 30, 30); err != nil { + t.Fatal(err) + } + if _, err := startCombatSession(uid, "r", "n", "wolf", 50, 50, 30, 30); err != ErrCombatSessionAlreadyActive { + t.Errorf("err = %v, want ErrCombatSessionAlreadyActive", err) + } +} + +func TestSweepExpiredCombatSessions(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@combat-sweep:example.org") + defer cleanupCombatSessions(uid) + + 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. + 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() + if err != nil { + t.Fatalf("sweep: %v", err) + } + if n < 1 { + t.Errorf("sweep count = %d, want >= 1", n) + } + if active, _ := getActiveCombatSession(uid); active != nil { + t.Errorf("expected no active session after sweep, 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) + } +} + +// ── State machine ────────────────────────────────────────────────────────── + +// turnSession builds an in-memory CombatSession for state-machine tests that +// don't touch the DB. +func turnSession(phase string, playerHP, enemyHP int) *CombatSession { + return &CombatSession{ + SessionID: "test-session", UserID: "@t:x", Round: 1, Phase: phase, + PlayerHP: playerHP, PlayerHPMax: playerHP, + EnemyHP: enemyHP, EnemyHPMax: enemyHP, + TurnLog: []CombatEvent{}, Status: CombatStatusActive, + } +} + +func stepEngine(sess *CombatSession, player, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) { + rng := rand.New(rand.NewPCG(42, phaseOrdinal(sess.Phase))) + te := resumeTurnEngine(sess, player, enemy, rng) + events, err := te.step(action) + if err != nil { + return nil, err + } + te.commit() + return events, nil +} + +func TestTurnEngine_PhaseProgression(t *testing.T) { + // Pools large enough that no single phase can end the fight. + sess := turnSession(CombatPhasePlayerTurn, 10000, 10000) + player, enemy := basePlayer(), baseEnemy() + + if _, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack}); err != nil { + t.Fatal(err) + } + if sess.Phase != CombatPhaseEnemyTurn { + t.Fatalf("after player_turn: phase=%q, want enemy_turn", sess.Phase) + } + if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil { + t.Fatal(err) + } + if sess.Phase != CombatPhaseRoundEnd { + t.Fatalf("after enemy_turn: phase=%q, want round_end", sess.Phase) + } + if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil { + t.Fatal(err) + } + if sess.Phase != CombatPhasePlayerTurn { + t.Fatalf("after round_end: phase=%q, want player_turn", sess.Phase) + } + if sess.Round != 2 { + t.Errorf("round = %d after a full cycle, want 2", sess.Round) + } + if sess.Status != CombatStatusActive { + t.Errorf("status = %q, want active", sess.Status) + } +} + +func TestTurnEngine_PoisonTickAtRoundEnd(t *testing.T) { + sess := turnSession(CombatPhaseRoundEnd, 50, 80) + sess.Statuses = CombatStatuses{PoisonTicks: 2, PoisonDmg: 7} + player, enemy := basePlayer(), baseEnemy() + + events, err := stepEngine(sess, &player, &enemy, PlayerAction{}) + if err != nil { + t.Fatal(err) + } + if sess.PlayerHP != 43 { + t.Errorf("player_hp = %d, want 43 (50 - 7 poison)", sess.PlayerHP) + } + if sess.Statuses.PoisonTicks != 1 { + t.Errorf("poison_ticks = %d, want 1", sess.Statuses.PoisonTicks) + } + if sess.Round != 2 || sess.Phase != CombatPhasePlayerTurn { + t.Errorf("round=%d phase=%q, want 2/player_turn", sess.Round, sess.Phase) + } + if len(events) != 1 || events[0].Action != "poison_tick" || events[0].Damage != 7 { + t.Errorf("expected one poison_tick event, got %+v", events) + } +} + +func TestTurnEngine_PoisonTickCanBeLethal(t *testing.T) { + sess := turnSession(CombatPhaseRoundEnd, 4, 80) + sess.Statuses = CombatStatuses{PoisonTicks: 1, PoisonDmg: 9} + player, enemy := basePlayer(), baseEnemy() + + if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil { + t.Fatal(err) + } + if sess.Status != CombatStatusLost { + t.Errorf("status = %q, want lost (poison dropped player to 0)", sess.Status) + } + if sess.Phase != CombatPhaseOver { + t.Errorf("phase = %q, want over", sess.Phase) + } + if sess.PlayerHP != 0 { + t.Errorf("player_hp = %d, want 0", sess.PlayerHP) + } +} + +func TestTurnEngine_Flee(t *testing.T) { + sess := turnSession(CombatPhasePlayerTurn, 50, 50) + player, enemy := basePlayer(), baseEnemy() + + events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionFlee}) + if err != nil { + t.Fatal(err) + } + if sess.Status != CombatStatusFled || sess.Phase != CombatPhaseOver { + t.Errorf("after flee: status=%q phase=%q", sess.Status, sess.Phase) + } + if len(events) != 1 || events[0].Action != "flee" { + t.Errorf("expected one flee event, got %+v", events) + } +} + +func TestTurnEngine_PlayerWinsWhenEnemyDrops(t *testing.T) { + // Enemy on 1 HP: any connecting player hit ends it. Drive the fight to a + // terminal state and confirm the player wins (strong attacker vs. 1 HP). + sess := turnSession(CombatPhasePlayerTurn, 100, 1) + player, enemy := basePlayer(), baseEnemy() + + for i := 0; i < 12 && sess.Status == CombatStatusActive; i++ { + if _, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack}); err != nil { + t.Fatal(err) + } + } + if sess.Status != CombatStatusWon { + t.Fatalf("status = %q, want won", sess.Status) + } + if sess.Phase != CombatPhaseOver { + t.Errorf("phase = %q, want over", sess.Phase) + } + if sess.EnemyHP > 0 { + t.Errorf("enemy_hp = %d, want 0", sess.EnemyHP) + } +} + +func TestTurnEngine_StepRejectsTerminalSession(t *testing.T) { + sess := turnSession(CombatPhaseOver, 50, 50) + sess.Status = CombatStatusWon + player, enemy := basePlayer(), baseEnemy() + + rng := rand.New(rand.NewPCG(1, 1)) + te := resumeTurnEngine(sess, &player, &enemy, rng) + if _, err := te.step(PlayerAction{Kind: ActionAttack}); err != errCombatSessionOver { + t.Errorf("err = %v, want errCombatSessionOver", err) + } +} + +func TestCombatSessionRNG_DeterministicPerPhase(t *testing.T) { + sess := &CombatSession{SessionID: "abc123", Round: 2, Phase: CombatPhaseEnemyTurn} + a := combatSessionRNG(sess) + b := combatSessionRNG(sess) + if a.Uint64() != b.Uint64() { + t.Error("same session+round+phase should seed identical streams") + } + // A different phase of the same round must draw a distinct stream. + sess.Phase = CombatPhasePlayerTurn + c := combatSessionRNG(sess) + sess.Phase = CombatPhaseEnemyTurn + d := combatSessionRNG(sess) + if c.Uint64() == d.Uint64() { + t.Error("distinct phases of a round should seed distinct streams") + } +} diff --git a/internal/plugin/combat_turn_engine.go b/internal/plugin/combat_turn_engine.go new file mode 100644 index 0000000..0ee8338 --- /dev/null +++ b/internal/plugin/combat_turn_engine.go @@ -0,0 +1,234 @@ +package plugin + +import ( + "errors" + "fmt" + "math/rand/v2" +) + +// Phase 13 — Turn-based combat state machine. +// +// Where SimulateCombat runs a whole fight in one call, the turn-based engine +// advances a persisted CombatSession one phase at a time: +// +// player_turn -> enemy_turn -> round_end -> player_turn (next round) -> ... +// +// each `step` resolving exactly one phase so a manual elite/boss fight can be +// suspended and resumed (or reaped) from exact mid-state. Attack resolution +// reuses the shared primitives in combat_primitives.go / combat_engine.go, so +// a hit lands identically to auto-resolve. The round_end phase is where +// between-round status effects (poison, etc.) tick — the deferred poison tick +// from the schema commit lands here, now that the round-loop shape exists. + +// errCombatSessionOver is returned by step when the fight has already reached +// a terminal status — callers should not keep advancing it. +var errCombatSessionOver = errors.New("combat session already over") + +// turnCombatPhase is the single neutral phase the turn-based engine resolves +// attacks under. Auto-resolve sequences named phases with weight curves; a +// manual duel has no phase clock, so weights are flat 1.0 — neutral for the +// legacy penetration formula (calcDamage) and irrelevant on the weapon-dice +// path. The name surfaces on every CombatEvent the fight emits. +var turnCombatPhase = CombatPhase{ + Name: "Duel", + Rounds: 0, + AttackWeight: 1.0, + DefenseWeight: 1.0, + SpeedWeight: 1.0, +} + +// Player action kinds accepted by the state machine on a player_turn step. +const ( + ActionAttack = "attack" + ActionFlee = "flee" +) + +// PlayerAction is the player's choice for a player_turn step. It is ignored on +// enemy_turn / round_end steps (pass the zero value). +type PlayerAction struct { + Kind string +} + +// turnEngine wraps a combatState reconstructed from a persisted CombatSession +// so a single phase can be resolved and then written back. +type turnEngine struct { + sess *CombatSession + player *Combatant + enemy *Combatant + st *combatState + result *CombatResult +} + +// combatSessionRNG seeds a deterministic generator from the session id and the +// phase being resolved, so a fight replays reproducibly no matter how many bot +// restarts occur mid-fight. Each (round, phase) gets a distinct stream — a flat +// per-session seed would correlate the player's and enemy's d20s within a round. +func combatSessionRNG(sess *CombatSession) *rand.Rand { + var seed uint64 = 1469598103934665603 // FNV-ish offset basis + for _, c := range sess.SessionID { + seed = (seed ^ uint64(c)) * 1099511628211 + } + stream := uint64(sess.Round)*4 + phaseOrdinal(sess.Phase) + return rand.New(rand.NewPCG(seed, stream)) +} + +func phaseOrdinal(phase string) uint64 { + switch phase { + case CombatPhasePlayerTurn: + return 0 + case CombatPhaseEnemyTurn: + return 1 + case CombatPhaseRoundEnd: + return 2 + default: // CombatPhaseOver + return 3 + } +} + +// resumeTurnEngine rebuilds the in-memory combatState from a persisted session. +// rng is the deterministic source for this step (see combatSessionRNG). +func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.Rand) *turnEngine { + st := &combatState{ + playerHP: sess.PlayerHP, + enemyHP: sess.EnemyHP, + round: sess.Round, + poisonTicks: sess.Statuses.PoisonTicks, + poisonDmg: sess.Statuses.PoisonDmg, + stunPlayer: sess.Statuses.StunPlayer, + enraged: sess.Statuses.Enraged, + armorBroken: sess.Statuses.ArmorBroken, + armorBreakAmt: sess.Statuses.ArmorBreakAmt, + rng: rng, + } + return &turnEngine{ + sess: sess, + player: player, + enemy: enemy, + st: st, + result: &CombatResult{}, + } +} + +// step resolves exactly one phase of the fight and advances sess.Phase. The +// events generated this step are returned (also accumulated by commit into +// sess.TurnLog). It does not persist — call commit then saveCombatSession, or +// use advanceCombatSession which does both. +func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) { + if !te.sess.IsActive() { + return nil, errCombatSessionOver + } + te.st.events = nil + switch te.sess.Phase { + case CombatPhasePlayerTurn: + te.stepPlayerTurn(action) + case CombatPhaseEnemyTurn: + te.stepEnemyTurn() + case CombatPhaseRoundEnd: + te.stepRoundEnd() + case CombatPhaseOver: + return nil, errCombatSessionOver + default: + return nil, fmt.Errorf("combat session %s in unknown phase %q", te.sess.SessionID, te.sess.Phase) + } + return te.st.events, nil +} + +func (te *turnEngine) stepPlayerTurn(action PlayerAction) { + if action.Kind == ActionFlee { + te.st.events = append(te.st.events, CombatEvent{ + Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: "flee", + PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP, + }) + te.finish(CombatStatusFled) + return + } + // resolvePlayerAttack returns true once the enemy is down. + if resolvePlayerAttack(te.st, te.player, te.enemy, &turnCombatPhase, te.result) { + te.finish(CombatStatusWon) + return + } + te.sess.Phase = CombatPhaseEnemyTurn +} + +func (te *turnEngine) stepEnemyTurn() { + // resolveEnemyAttack returns true when the fight is decided — either the + // player went down without a death save, or a reflect consumable killed + // the enemy. Disambiguate by inspecting HP. + decided := resolveEnemyAttack(te.st, te.player, te.enemy, &turnCombatPhase, te.result, false, false, false) + if te.st.playerHP <= 0 { + te.finish(CombatStatusLost) + return + } + if decided { + te.finish(CombatStatusWon) + return + } + te.sess.Phase = CombatPhaseRoundEnd +} + +// stepRoundEnd applies between-round status effects, then opens the next round. +func (te *turnEngine) stepRoundEnd() { + st := te.st + if st.poisonTicks > 0 { + st.playerHP = max(0, st.playerHP-st.poisonDmg) + st.poisonTicks-- + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "poison_tick", + Damage: st.poisonDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + }) + if st.playerHP <= 0 { + if !trySave(st, te.player, CombatPhaseRoundEnd) { + te.finish(CombatStatusLost) + return + } + } + } + st.round++ + te.sess.Phase = CombatPhasePlayerTurn +} + +// finish parks the session in a terminal status + the 'over' phase. +func (te *turnEngine) finish(status string) { + te.sess.Status = status + te.sess.Phase = CombatPhaseOver +} + +// commit folds this step's combatState back into the session struct: HP, +// round, the between-round status snapshot, and the appended event log. +// Phase / Status were already set by step. saveCombatSession persists it. +func (te *turnEngine) commit() { + st := te.st + te.sess.Round = st.round + te.sess.PlayerHP = st.playerHP + te.sess.EnemyHP = st.enemyHP + te.sess.Statuses = CombatStatuses{ + PoisonTicks: st.poisonTicks, + PoisonDmg: st.poisonDmg, + StunPlayer: st.stunPlayer, + Enraged: st.enraged, + ArmorBroken: st.armorBroken, + ArmorBreakAmt: st.armorBreakAmt, + } + te.sess.TurnLog = append(te.sess.TurnLog, st.events...) +} + +// advanceCombatSession resolves one phase of the fight and persists the result. +// It is the single entry point callers (commands, reaper) should use: it seeds +// the deterministic RNG, resumes the engine, steps, commits, and saves. The +// passed sess is mutated in place. The events generated this step are returned. +func advanceCombatSession(sess *CombatSession, player, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) { + if sess == nil { + return nil, ErrNoActiveCombatSession + } + rng := combatSessionRNG(sess) + te := resumeTurnEngine(sess, player, enemy, rng) + events, err := te.step(action) + if err != nil { + return nil, err + } + te.commit() + if err := saveCombatSession(sess); err != nil { + return events, fmt.Errorf("persist combat session %s: %w", sess.SessionID, err) + } + return events, nil +}