mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +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:
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