mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +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
|
||||
}
|
||||
284
internal/plugin/combat_session_test.go
Normal file
284
internal/plugin/combat_session_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
234
internal/plugin/combat_turn_engine.go
Normal file
234
internal/plugin/combat_turn_engine.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user