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:
prosolis
2026-05-14 05:55:32 -07:00
parent b0987eb229
commit 269d13ffe8
3 changed files with 772 additions and 0 deletions

View 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
}

View File

@@ -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")
}
}

View File

@@ -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
}