mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
N3/P3: initiative, and a turn engine that seats a party
The turn engine ran a fixed player -> enemy -> round_end phase machine over one player and one monster. It now runs a round as a sequence of seats. turnOrder derives that sequence per round. A solo roster short-circuits to the historical [player, enemy] and rolls nothing -- the duel has never had initiative, and handing the monster a coin flip on who swings first would be a live balance change. A party rolls it with the auto-resolve engine's own formula (speed + d10 + InitiativeBias). Every seat in a round shares a (round, phase) pair, so the acting seat is mixed into the RNG *seed* rather than the stream. Seat 0 and the enemy sentinel mix to nothing, which is what keeps a solo fight drawing exactly the pre-roster stream across a suspend/resume. The round cursor persists as Statuses.TurnIdx, omitempty so no solo row carries it. A fight that was in flight when the field landed decodes it as 0; turnIdxForPhase reconciles that against Phase, which is the older and load-bearing field. Without it, a suspended enemy_turn would resume, step, and land back on enemy_turn forever. The enemy now picks a target uniformly among the standing roster (solo draws nothing), a downed seat forfeits its turn silently, and the fight is lost only when anyAlive() goes false -- not when the acting seat drops. That last one fixes a latent solo bug on the way past: resolvePlayerSwings returns false when a retaliate aura kills the swinger between extra attacks, and the old code walked that corpse into the enemy's turn. commit() reads seat 0 explicitly instead of the cursor, which the enemy turn parks on its target and round_end walks across the roster. TestCombatCharacterization is byte-identical: solo balance did not move.
This commit is contained in:
@@ -309,6 +309,11 @@ type actor struct {
|
||||
c *Combatant
|
||||
|
||||
playerHP int
|
||||
// hpMax is this actor's HP ceiling for in-fight healing. It tracks
|
||||
// c.Stats.MaxHP except in the turn-based engine, where seat 0 restores the
|
||||
// session's persisted player_hp_max — that snapshot comes from
|
||||
// dndHPSnapshot and can differ from the rebuilt combatant's MaxHP.
|
||||
hpMax int
|
||||
|
||||
// Consumable one-shots
|
||||
healChargesLeft int // remaining heal-at-<50% triggers
|
||||
@@ -418,6 +423,7 @@ func newActor(c *Combatant) *actor {
|
||||
a := &actor{
|
||||
c: c,
|
||||
playerHP: startHP,
|
||||
hpMax: c.Stats.MaxHP,
|
||||
wardCharges: c.Mods.WardCharges,
|
||||
sporeRounds: c.Mods.SporeCloud,
|
||||
reflectFrac: c.Mods.ReflectNext,
|
||||
|
||||
@@ -65,6 +65,14 @@ type CombatStatuses struct {
|
||||
// combatState, so the flag must survive the commit between them.
|
||||
EnemySkipNext bool `json:"enemy_skip_next,omitempty"`
|
||||
|
||||
// TurnIdx is the position within the round's initiative order (see
|
||||
// turnOrder) of whoever acts next. A solo fight's order is the fixed
|
||||
// [player, enemy], so TurnIdx mirrors the phase and the field is dead
|
||||
// weight — omitempty keeps it out of every solo row. Rows written before
|
||||
// this field existed decode as 0 and are reconciled against Phase on
|
||||
// resume, so a mid-fight upgrade lands on the right slot.
|
||||
TurnIdx int `json:"turn_idx,omitempty"`
|
||||
|
||||
// Fight-scoped depleting resources — mirror the combatState charges that
|
||||
// genuinely carry round-to-round. Seeded at fight start (Arcane Ward) or by
|
||||
// a mid-fight !cast / !consume, restored into combatState on every resume,
|
||||
|
||||
@@ -181,7 +181,7 @@ func turnSession(phase string, playerHP, enemyHP int) *CombatSession {
|
||||
|
||||
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)
|
||||
te := resumeTurnEngine(sess, []*Combatant{player}, enemy, rng)
|
||||
events, err := te.step(action)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -375,7 +375,7 @@ func TestTurnEngine_StepRejectsTerminalSession(t *testing.T) {
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
|
||||
rng := rand.New(rand.NewPCG(1, 1))
|
||||
te := resumeTurnEngine(sess, &player, &enemy, rng)
|
||||
te := resumeTurnEngine(sess, []*Combatant{&player}, &enemy, rng)
|
||||
if _, err := te.step(PlayerAction{Kind: ActionAttack}); err != errCombatSessionOver {
|
||||
t.Errorf("err = %v, want errCombatSessionOver", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Phase 13 — Turn-based combat state machine.
|
||||
@@ -72,29 +73,68 @@ type turnActionEffect struct {
|
||||
ConcentrationDmg int
|
||||
}
|
||||
|
||||
// enemySeat is the sentinel roster index the monster occupies in a turn order.
|
||||
// Player seats are the roster indices 0..len(actors)-1.
|
||||
const enemySeat = -1
|
||||
|
||||
// turnEngine wraps a combatState reconstructed from a persisted CombatSession
|
||||
// so a single phase can be resolved and then written back.
|
||||
//
|
||||
// players is the seated roster in roster order; order is this round's seat
|
||||
// sequence, which interleaves the roster with enemySeat. A solo fight's order
|
||||
// is always [0, enemySeat] — the fixed player-then-enemy sequence the engine
|
||||
// has always run — so no initiative is rolled and its RNG stream is untouched.
|
||||
type turnEngine struct {
|
||||
sess *CombatSession
|
||||
player *Combatant
|
||||
enemy *Combatant
|
||||
st *combatState
|
||||
result *CombatResult
|
||||
sess *CombatSession
|
||||
players []*Combatant
|
||||
enemy *Combatant
|
||||
order []int
|
||||
st *combatState
|
||||
result *CombatResult
|
||||
}
|
||||
|
||||
// combatSessionSeed hashes the session id into the base PCG seed.
|
||||
func combatSessionSeed(sess *CombatSession) uint64 {
|
||||
var seed uint64 = 1469598103934665603 // FNV-ish offset basis
|
||||
for _, c := range sess.SessionID {
|
||||
seed = (seed ^ uint64(c)) * 1099511628211
|
||||
}
|
||||
return seed
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// This is the solo form of combatSessionStepRNG, kept as the name the engine's
|
||||
// single-seat callers and tests use.
|
||||
func combatSessionRNG(sess *CombatSession) *rand.Rand {
|
||||
var seed uint64 = 1469598103934665603 // FNV-ish offset basis
|
||||
for _, c := range sess.SessionID {
|
||||
seed = (seed ^ uint64(c)) * 1099511628211
|
||||
return combatSessionStepRNG(sess, 0)
|
||||
}
|
||||
|
||||
// combatSessionStepRNG seeds the generator for one step resolved by one seat.
|
||||
// A round holds one player_turn step per party member, and they all share a
|
||||
// (round, phase) pair — so the acting seat is mixed into the *seed* to keep
|
||||
// their d20s independent. Seat 0 and the enemy sentinel mix to nothing, which
|
||||
// is what makes a solo fight draw exactly the pre-roster stream.
|
||||
func combatSessionStepRNG(sess *CombatSession, seat int) *rand.Rand {
|
||||
seed := combatSessionSeed(sess)
|
||||
if seat > 0 {
|
||||
seed ^= uint64(seat) * 0x9E3779B97F4A7C15 // golden-ratio odd multiplier
|
||||
}
|
||||
stream := uint64(sess.Round)*4 + phaseOrdinal(sess.Phase)
|
||||
return rand.New(rand.NewPCG(seed, stream))
|
||||
}
|
||||
|
||||
// combatInitiativeRNG seeds the once-per-round initiative roll. It takes the
|
||||
// round explicitly because stepRoundEnd must order the round it is opening,
|
||||
// not the one it is closing, and sess.Round is only advanced by commit.
|
||||
// A distinct seed mix keeps it clear of every step stream.
|
||||
func combatInitiativeRNG(sess *CombatSession, round int) *rand.Rand {
|
||||
return rand.New(rand.NewPCG(combatSessionSeed(sess)^0xD1B54A32D192ED03, uint64(round)))
|
||||
}
|
||||
|
||||
func phaseOrdinal(phase string) uint64 {
|
||||
switch phase {
|
||||
case CombatPhasePlayerTurn:
|
||||
@@ -108,15 +148,82 @@ func phaseOrdinal(phase string) uint64 {
|
||||
}
|
||||
}
|
||||
|
||||
// turnOrder returns the seat sequence for one round: the player roster and the
|
||||
// enemy sentinel, highest initiative first. Initiative mirrors the auto-resolve
|
||||
// engine's formula (speed + d10 + InitiativeBias); ties break toward the lower
|
||||
// seat, and the enemy loses every tie.
|
||||
//
|
||||
// A solo roster short-circuits to the engine's historical fixed order and rolls
|
||||
// nothing — the turn-based engine has never had initiative, and giving one seat
|
||||
// a coin-flip on who swings first would be a live balance change.
|
||||
func turnOrder(sess *CombatSession, round int, players []*Combatant, enemy *Combatant) []int {
|
||||
if len(players) <= 1 {
|
||||
return []int{0, enemySeat}
|
||||
}
|
||||
type entry struct {
|
||||
seat int
|
||||
init float64
|
||||
}
|
||||
rng := combatInitiativeRNG(sess, round)
|
||||
entries := make([]entry, 0, len(players)+1)
|
||||
for i, p := range players {
|
||||
entries = append(entries, entry{i, float64(p.Stats.Speed) + rngFloat(rng)*10 + p.Mods.InitiativeBias})
|
||||
}
|
||||
entries = append(entries, entry{enemySeat, float64(enemy.Stats.Speed) + rngFloat(rng)*10})
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
a, b := entries[i], entries[j]
|
||||
if a.init != b.init {
|
||||
return a.init > b.init
|
||||
}
|
||||
if (a.seat == enemySeat) != (b.seat == enemySeat) {
|
||||
return b.seat == enemySeat
|
||||
}
|
||||
return a.seat < b.seat
|
||||
})
|
||||
order := make([]int, len(entries))
|
||||
for i, e := range entries {
|
||||
order[i] = e.seat
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// phaseForSeat names the phase under which a seat resolves its turn.
|
||||
func phaseForSeat(seat int) string {
|
||||
if seat == enemySeat {
|
||||
return CombatPhaseEnemyTurn
|
||||
}
|
||||
return CombatPhasePlayerTurn
|
||||
}
|
||||
|
||||
// turnIdxForPhase reconciles a persisted cursor against the persisted phase.
|
||||
// They can disagree for exactly one reason: a fight that was in flight when
|
||||
// TurnIdx was introduced decodes as 0 regardless of whose turn it is. Phase is
|
||||
// the older, load-bearing field, so it wins; the cursor snaps to the first slot
|
||||
// of the matching kind. On a solo order ([player, enemy]) that is exact.
|
||||
func turnIdxForPhase(order []int, idx int, phase string) int {
|
||||
if idx >= 0 && idx < len(order) && phaseForSeat(order[idx]) == phase {
|
||||
return idx
|
||||
}
|
||||
for i, seat := range order {
|
||||
if phaseForSeat(seat) == phase {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// The seated character's half of the persisted statuses. A single-seat
|
||||
// roster today; P4 gives combat_session per-participant rows and this
|
||||
// becomes one actor per member.
|
||||
// rng is the deterministic source for this step (see combatSessionStepRNG).
|
||||
//
|
||||
// Seat 0 restores the session's persisted per-character statuses. Seats 1+ open
|
||||
// fresh from their combatant's modifiers: a party's per-member statuses need the
|
||||
// combat_participant rows that P4 adds, and until then no persisted session ever
|
||||
// carries more than one seat.
|
||||
func resumeTurnEngine(sess *CombatSession, players []*Combatant, enemy *Combatant, rng *rand.Rand) *turnEngine {
|
||||
seat0 := &actor{
|
||||
c: player,
|
||||
c: players[0],
|
||||
playerHP: sess.PlayerHP,
|
||||
hpMax: sess.PlayerHPMax,
|
||||
poisonTicks: sess.Statuses.PoisonTicks,
|
||||
poisonDmg: sess.Statuses.PoisonDmg,
|
||||
stunPlayer: sess.Statuses.StunPlayer,
|
||||
@@ -142,9 +249,14 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
|
||||
playerACDebuff: sess.Statuses.PlayerACDebuff,
|
||||
maxHPDrain: sess.Statuses.MaxHPDrain,
|
||||
}
|
||||
actors := make([]*actor, len(players))
|
||||
actors[0] = seat0
|
||||
for i := 1; i < len(players); i++ {
|
||||
actors[i] = newActor(players[i])
|
||||
}
|
||||
st := &combatState{
|
||||
actor: seat0,
|
||||
actors: []*actor{seat0},
|
||||
actors: actors,
|
||||
enemyHP: sess.EnemyHP,
|
||||
round: sess.Round,
|
||||
enraged: sess.Statuses.Enraged,
|
||||
@@ -166,13 +278,30 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
|
||||
enemyAtkBuff: sess.Statuses.EnemyAtkBuff,
|
||||
rng: rng,
|
||||
}
|
||||
return &turnEngine{
|
||||
sess: sess,
|
||||
player: player,
|
||||
enemy: enemy,
|
||||
st: st,
|
||||
result: &CombatResult{},
|
||||
order := turnOrder(sess, sess.Round, players, enemy)
|
||||
sess.Statuses.TurnIdx = turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)
|
||||
if sess.Phase == CombatPhasePlayerTurn {
|
||||
st.seat(order[sess.Statuses.TurnIdx])
|
||||
}
|
||||
return &turnEngine{
|
||||
sess: sess,
|
||||
players: players,
|
||||
enemy: enemy,
|
||||
order: order,
|
||||
st: st,
|
||||
result: &CombatResult{},
|
||||
}
|
||||
}
|
||||
|
||||
// advance moves the round cursor to the next seat, or to round_end once every
|
||||
// seat has acted.
|
||||
func (te *turnEngine) advance() {
|
||||
te.sess.Statuses.TurnIdx++
|
||||
if te.sess.Statuses.TurnIdx >= len(te.order) {
|
||||
te.sess.Phase = CombatPhaseRoundEnd
|
||||
return
|
||||
}
|
||||
te.sess.Phase = phaseForSeat(te.order[te.sess.Statuses.TurnIdx])
|
||||
}
|
||||
|
||||
// step resolves exactly one phase of the fight and advances sess.Phase. The
|
||||
@@ -200,6 +329,13 @@ func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
|
||||
}
|
||||
|
||||
func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
|
||||
// A seat that went down earlier this round forfeits its turn silently.
|
||||
// Unreachable while the roster is solo — a downed solo player has already
|
||||
// ended the fight.
|
||||
if te.st.playerHP <= 0 {
|
||||
te.advance()
|
||||
return
|
||||
}
|
||||
switch action.Kind {
|
||||
case ActionFlee:
|
||||
te.st.events = append(te.st.events, CombatEvent{
|
||||
@@ -220,26 +356,34 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
|
||||
// Extra Attack only fired in the auto-resolve SimulateCombat path; that
|
||||
// left every elite/boss !fight at L11+ short two swings/turn, which the
|
||||
// J1 boss-trace surfaced as Fighter's T3+ wall.
|
||||
// Returns true once the fight is decided — usually the enemy is down,
|
||||
// but a retaliate aura can drop the player on their own swing, so
|
||||
// disambiguate the outcome by HP.
|
||||
if resolvePlayerSwings(te.st, te.player, te.enemy, &turnCombatPhase, te.result) {
|
||||
if te.st.playerHP <= 0 {
|
||||
te.finish(CombatStatusLost)
|
||||
} else {
|
||||
// Returns true once the swing decided something — usually the enemy is
|
||||
// down, but a retaliate aura can drop the swinger on their own attack. A
|
||||
// downed swinger only ends the fight if nobody else is standing.
|
||||
// The outcome is read off HP rather than the bool: resolvePlayerSwings also
|
||||
// returns false when a retaliate aura kills the swinger between extra
|
||||
// attacks, and the old code walked that corpse into the enemy's turn.
|
||||
resolvePlayerSwings(te.st, te.st.c, te.enemy, &turnCombatPhase, te.result)
|
||||
if te.st.enemyHP <= 0 {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
if !te.st.anyAlive() {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
// A swinger who went down to retaliate gets no bonus-action follow-ups.
|
||||
// Solo never reaches here dead — anyAlive above would have ended the fight.
|
||||
if te.st.playerHP > 0 {
|
||||
if te.petStrike() {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
if te.spiritWeaponStrike() {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
if te.petStrike() {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
if te.spiritWeaponStrike() {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
te.sess.Phase = CombatPhaseEnemyTurn
|
||||
te.advance()
|
||||
}
|
||||
|
||||
// petStrike resolves the player's pet attack for a turn-based fight. The pet
|
||||
@@ -252,10 +396,10 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
|
||||
// the enemy.
|
||||
func (te *turnEngine) petStrike() bool {
|
||||
st := te.st
|
||||
if te.player.Mods.PetAttackProc <= 0 || st.randFloat() >= te.player.Mods.PetAttackProc {
|
||||
if st.c.Mods.PetAttackProc <= 0 || st.randFloat() >= st.c.Mods.PetAttackProc {
|
||||
return false
|
||||
}
|
||||
petDmg := te.player.Mods.PetAttackDmg + st.roll(5)
|
||||
petDmg := st.c.Mods.PetAttackDmg + st.roll(5)
|
||||
st.enemyHP = max(0, st.enemyHP-petDmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: turnCombatPhase.Name, Actor: "pet", Action: "pet_attack",
|
||||
@@ -270,10 +414,10 @@ func (te *turnEngine) petStrike() bool {
|
||||
// pet flavor on a petless caster. Returns true if the strike dropped the enemy.
|
||||
func (te *turnEngine) spiritWeaponStrike() bool {
|
||||
st := te.st
|
||||
if te.player.Mods.SpiritWeaponProc <= 0 || st.randFloat() >= te.player.Mods.SpiritWeaponProc {
|
||||
if st.c.Mods.SpiritWeaponProc <= 0 || st.randFloat() >= st.c.Mods.SpiritWeaponProc {
|
||||
return false
|
||||
}
|
||||
dmg := te.player.Mods.SpiritWeaponDmg + st.roll(5)
|
||||
dmg := st.c.Mods.SpiritWeaponDmg + st.roll(5)
|
||||
st.enemyHP = max(0, st.enemyHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: turnCombatPhase.Name, Actor: "spirit_weapon", Action: "spirit_weapon_strike",
|
||||
@@ -291,7 +435,7 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
||||
st := te.st
|
||||
if eff == nil {
|
||||
// Defensive: a kind with no payload just passes the turn.
|
||||
te.sess.Phase = CombatPhaseEnemyTurn
|
||||
te.advance()
|
||||
return
|
||||
}
|
||||
action := eff.Action
|
||||
@@ -312,7 +456,7 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
||||
if eff.PlayerHeal > 0 {
|
||||
// Respect any max_hp_drain monster ability — a drained player can't be
|
||||
// healed back past the lowered ceiling.
|
||||
hpCap := max(1, te.sess.PlayerHPMax-st.maxHPDrain)
|
||||
hpCap := max(1, st.hpMax-st.maxHPDrain)
|
||||
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
|
||||
}
|
||||
// Arm / replace the concentration aura. A new concentration cast overwrites
|
||||
@@ -354,10 +498,37 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
||||
st.enemySkipFirst = true
|
||||
}
|
||||
}
|
||||
te.sess.Phase = CombatPhaseEnemyTurn
|
||||
te.advance()
|
||||
}
|
||||
|
||||
// enemyTarget picks the seat the monster swings at this turn: uniformly among
|
||||
// the standing roster. A solo roster draws nothing, so its RNG stream keeps the
|
||||
// pre-roster shape. Returns false when the whole roster is down.
|
||||
func (te *turnEngine) enemyTarget() (int, bool) {
|
||||
if len(te.st.actors) == 1 {
|
||||
return 0, te.st.actors[0].playerHP > 0
|
||||
}
|
||||
standing := make([]int, 0, len(te.st.actors))
|
||||
for i, a := range te.st.actors {
|
||||
if a.playerHP > 0 {
|
||||
standing = append(standing, i)
|
||||
}
|
||||
}
|
||||
if len(standing) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return standing[te.st.roll(len(standing))], true
|
||||
}
|
||||
|
||||
func (te *turnEngine) stepEnemyTurn() {
|
||||
// Seat the target before anything reads the cursor: the ability path, the
|
||||
// pet procs, and the swing loop all resolve against whoever is targeted.
|
||||
target, ok := te.enemyTarget()
|
||||
if !ok {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
te.st.seat(target)
|
||||
// A control spell cast last phase forfeits the enemy's attack this round —
|
||||
// unless the enemy is fear_immune, in which case the control fizzled and it
|
||||
// acts as normal.
|
||||
@@ -373,7 +544,7 @@ func (te *turnEngine) stepEnemyTurn() {
|
||||
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held",
|
||||
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
|
||||
})
|
||||
te.sess.Phase = CombatPhaseRoundEnd
|
||||
te.advance()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -385,8 +556,15 @@ func (te *turnEngine) stepEnemyTurn() {
|
||||
// player went down without a save.
|
||||
abilityDealtDamage := false
|
||||
if te.enemy.Ability != nil && turnAbilityFires(te.enemy.Ability, te.st, te.enemy) {
|
||||
if applyAbility(te.st, te.player, te.enemy, &turnCombatPhase, te.result) {
|
||||
te.finish(CombatStatusLost)
|
||||
if applyAbility(te.st, te.st.c, te.enemy, &turnCombatPhase, te.result) {
|
||||
// The target went down without a save. The fight only ends if it
|
||||
// took the last member with it; otherwise the enemy has spent its
|
||||
// turn on that kill.
|
||||
if !te.st.anyAlive() {
|
||||
te.finish(CombatStatusLost)
|
||||
} else {
|
||||
te.advance()
|
||||
}
|
||||
return
|
||||
}
|
||||
switch te.enemy.Ability.Effect {
|
||||
@@ -402,8 +580,8 @@ func (te *turnEngine) stepEnemyTurn() {
|
||||
// remaining swings resolve normally — a single proc shouldn't nullify a
|
||||
// boss's whole multiattack round. (This deliberately diverges from the
|
||||
// auto-resolve engine's apply-to-all model.)
|
||||
petWhiff := te.player.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.player.Mods.PetWhiffProc
|
||||
petDeflect := te.player.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.player.Mods.PetDeflectProc
|
||||
petWhiff := te.st.c.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.st.c.Mods.PetWhiffProc
|
||||
petDeflect := te.st.c.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.st.c.Mods.PetDeflectProc
|
||||
if petDeflect {
|
||||
te.result.PetDeflected = true
|
||||
}
|
||||
@@ -421,10 +599,15 @@ func (te *turnEngine) stepEnemyTurn() {
|
||||
// Spend the proc on the first swing only; later swings see false.
|
||||
swingWhiff := petWhiff && i == 0
|
||||
swingDeflect := petDeflect && i == 0
|
||||
decided := resolveEnemyAttack(te.st, te.player, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false)
|
||||
decided := resolveEnemyAttack(te.st, te.st.c, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false)
|
||||
if te.st.playerHP <= 0 {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
// The target is down. The enemy stops swinging at a corpse; the
|
||||
// fight ends only if the roster is empty.
|
||||
if !te.st.anyAlive() {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
if decided || te.st.enemyHP <= 0 {
|
||||
te.finish(CombatStatusWon)
|
||||
@@ -432,7 +615,7 @@ func (te *turnEngine) stepEnemyTurn() {
|
||||
}
|
||||
}
|
||||
}
|
||||
te.sess.Phase = CombatPhaseRoundEnd
|
||||
te.advance()
|
||||
}
|
||||
|
||||
// turnAbilityFires decides whether a monster ability triggers this enemy turn.
|
||||
@@ -460,26 +643,36 @@ func turnAbilityFires(ability *MonsterAbility, st *combatState, enemy *Combatant
|
||||
}
|
||||
|
||||
// stepRoundEnd applies between-round status effects, then opens the next round.
|
||||
// Per-actor effects (poison, concentration) tick once per seat, in seating
|
||||
// order; the enemy's regen is fight-scoped and ticks once. A solo roster walks
|
||||
// each loop exactly once, so it draws the same RNG the pre-roster engine did.
|
||||
func (te *turnEngine) stepRoundEnd() {
|
||||
st := te.st
|
||||
if st.poisonTicks > 0 {
|
||||
for i := range st.actors {
|
||||
st.seat(i)
|
||||
if st.poisonTicks <= 0 {
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
if st.playerHP <= 0 && !trySave(st, st.c, CombatPhaseRoundEnd) && !st.anyAlive() {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Concentration aura (Spirit Guardians et al.): the lingering spell bites
|
||||
// the enemy each round it stays up. Ticks before enemy regen so a lethal
|
||||
// pulse settles the fight before the enemy knits its wounds back.
|
||||
if st.concentrationDmg > 0 && st.enemyHP > 0 {
|
||||
// the enemy each round it stays up. Concentration is per-caster, so every
|
||||
// seat holding one pulses. Ticks before enemy regen so a lethal pulse
|
||||
// settles the fight before the enemy knits its wounds back.
|
||||
for i := range st.actors {
|
||||
st.seat(i)
|
||||
if st.concentrationDmg <= 0 || st.enemyHP <= 0 || st.playerHP <= 0 {
|
||||
continue
|
||||
}
|
||||
st.enemyHP = max(0, st.enemyHP-st.concentrationDmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "player", Action: "concentration_tick",
|
||||
@@ -490,6 +683,7 @@ func (te *turnEngine) stepRoundEnd() {
|
||||
return
|
||||
}
|
||||
}
|
||||
st.seat(0)
|
||||
// Regenerate (monster ability): the enemy knits its wounds at round end.
|
||||
if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP {
|
||||
st.enemyHP = min(te.enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen)
|
||||
@@ -499,7 +693,11 @@ func (te *turnEngine) stepRoundEnd() {
|
||||
})
|
||||
}
|
||||
st.round++
|
||||
te.sess.Phase = CombatPhasePlayerTurn
|
||||
// Initiative is re-rolled each round, so the next round's order is derived
|
||||
// here — off st.round, since commit has not yet pushed it onto the session.
|
||||
te.order = turnOrder(te.sess, st.round, te.players, te.enemy)
|
||||
te.sess.Statuses.TurnIdx = 0
|
||||
te.sess.Phase = phaseForSeat(te.order[0])
|
||||
}
|
||||
|
||||
// finish parks the session in a terminal status + the 'over' phase.
|
||||
@@ -516,42 +714,47 @@ func (te *turnEngine) finish(status string) {
|
||||
// by the command layer (a !cast / !consume folds them in) and applied to the
|
||||
// rebuilt combatant by applySessionBuffs — so commit mutates Statuses in place
|
||||
// rather than replacing it, leaving those deltas untouched.
|
||||
// Per-actor state is read off seat 0 rather than off the cursor, which the
|
||||
// enemy turn parks on its target and round_end walks across the roster. Seat 0
|
||||
// is the only member a session row can hold; P4's participant rows carry the
|
||||
// rest.
|
||||
func (te *turnEngine) commit() {
|
||||
st := te.st
|
||||
a := st.actors[0]
|
||||
te.sess.Round = st.round
|
||||
te.sess.PlayerHP = st.playerHP
|
||||
te.sess.PlayerHP = a.playerHP
|
||||
te.sess.EnemyHP = st.enemyHP
|
||||
s := &te.sess.Statuses
|
||||
s.PoisonTicks = st.poisonTicks
|
||||
s.PoisonDmg = st.poisonDmg
|
||||
s.StunPlayer = st.stunPlayer
|
||||
s.PoisonTicks = a.poisonTicks
|
||||
s.PoisonDmg = a.poisonDmg
|
||||
s.StunPlayer = a.stunPlayer
|
||||
s.Enraged = st.enraged
|
||||
s.ArmorBroken = st.armorBroken
|
||||
s.ArmorBreakAmt = st.armorBreakAmt
|
||||
s.EnemySkipNext = st.enemySkipFirst
|
||||
s.WardCharges = st.wardCharges
|
||||
s.SporeRounds = st.sporeRounds
|
||||
s.ReflectFrac = st.reflectFrac
|
||||
s.AutoCritFirst = st.autoCrit
|
||||
s.ArcaneWardHP = st.arcaneWardHP
|
||||
s.HealChargesLeft = st.healChargesLeft
|
||||
s.ConcentrationDmg = st.concentrationDmg
|
||||
s.DeathSaveUsed = st.deathSaveUsed
|
||||
s.LuckyUsed = st.luckyUsed
|
||||
s.Raged = st.raged
|
||||
s.PendingRage = st.pendingRageAttack
|
||||
s.FirstAtkBonusUsed = st.firstAttackBonusUsed
|
||||
s.AssassinateReroll = st.assassinateRerollUsed
|
||||
s.AssassinateBonus = st.assassinateBonusUsed
|
||||
s.WardCharges = a.wardCharges
|
||||
s.SporeRounds = a.sporeRounds
|
||||
s.ReflectFrac = a.reflectFrac
|
||||
s.AutoCritFirst = a.autoCrit
|
||||
s.ArcaneWardHP = a.arcaneWardHP
|
||||
s.HealChargesLeft = a.healChargesLeft
|
||||
s.ConcentrationDmg = a.concentrationDmg
|
||||
s.DeathSaveUsed = a.deathSaveUsed
|
||||
s.LuckyUsed = a.luckyUsed
|
||||
s.Raged = a.raged
|
||||
s.PendingRage = a.pendingRageAttack
|
||||
s.FirstAtkBonusUsed = a.firstAttackBonusUsed
|
||||
s.AssassinateReroll = a.assassinateRerollUsed
|
||||
s.AssassinateBonus = a.assassinateBonusUsed
|
||||
s.EnemyEvadeNext = st.enemyEvadeNext
|
||||
s.EnemyBlockUp = st.enemyBlockUp
|
||||
s.EnemyAdvantage = st.enemyAdvantage
|
||||
s.EnemyRetaliateFrac = st.enemyRetaliateFrac
|
||||
s.EnemyRegen = st.enemyRegen
|
||||
s.EnemySurviveArmed = st.enemySurviveArmed
|
||||
s.PlayerAtkDrain = st.playerAtkDrain
|
||||
s.PlayerACDebuff = st.playerACDebuff
|
||||
s.MaxHPDrain = st.maxHPDrain
|
||||
s.PlayerAtkDrain = a.playerAtkDrain
|
||||
s.PlayerACDebuff = a.playerACDebuff
|
||||
s.MaxHPDrain = a.maxHPDrain
|
||||
s.EnemySpellResist = st.enemySpellResist
|
||||
s.EnemyRevealNext = st.enemyRevealNext
|
||||
s.EnemyFearImmune = st.enemyFearImmune
|
||||
@@ -559,16 +762,35 @@ func (te *turnEngine) commit() {
|
||||
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.
|
||||
// advanceCombatSession resolves one phase of a solo fight and persists the
|
||||
// result. It is the single entry point callers (commands, reaper) should use.
|
||||
func advanceCombatSession(sess *CombatSession, player, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
|
||||
return advancePartyCombatSession(sess, []*Combatant{player}, enemy, action)
|
||||
}
|
||||
|
||||
// advancePartyCombatSession resolves one phase of the fight for the seated
|
||||
// roster and persists the result: it derives the acting seat from this round's
|
||||
// turn order, seeds that seat's deterministic RNG, resumes the engine, steps,
|
||||
// commits, and saves. The passed sess is mutated in place. The events generated
|
||||
// this step are returned.
|
||||
func advancePartyCombatSession(sess *CombatSession, players []*Combatant, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
|
||||
if sess == nil {
|
||||
return nil, ErrNoActiveCombatSession
|
||||
}
|
||||
rng := combatSessionRNG(sess)
|
||||
te := resumeTurnEngine(sess, player, enemy, rng)
|
||||
if len(players) == 0 {
|
||||
return nil, fmt.Errorf("combat session %s: empty roster", sess.SessionID)
|
||||
}
|
||||
// The seat is needed before the engine is resumed, because it seeds the
|
||||
// step's RNG — so the order is derived once here and once inside
|
||||
// resumeTurnEngine. Both derivations read only (sessionID, round, roster),
|
||||
// so they agree by construction.
|
||||
seat := enemySeat
|
||||
if sess.Phase == CombatPhasePlayerTurn {
|
||||
order := turnOrder(sess, sess.Round, players, enemy)
|
||||
seat = order[turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)]
|
||||
}
|
||||
rng := combatSessionStepRNG(sess, seat)
|
||||
te := resumeTurnEngine(sess, players, enemy, rng)
|
||||
events, err := te.step(action)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
277
internal/plugin/combat_turn_party_test.go
Normal file
277
internal/plugin/combat_turn_party_test.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package plugin
|
||||
|
||||
// Initiative + N-body turn-engine tests (N3/P3).
|
||||
//
|
||||
// The solo path is pinned bit-for-bit by TestCombatCharacterization for
|
||||
// auto-resolve, and by the fixed-order assertions below for the turn engine.
|
||||
// Everything else here exercises seats the production code cannot reach until
|
||||
// P4 gives a session its participant rows.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// partyStep drives one engine step over a roster without touching the DB.
|
||||
func partyStep(sess *CombatSession, players []*Combatant, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
|
||||
seat := enemySeat
|
||||
if sess.Phase == CombatPhasePlayerTurn {
|
||||
order := turnOrder(sess, sess.Round, players, enemy)
|
||||
seat = order[turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)]
|
||||
}
|
||||
te := resumeTurnEngine(sess, players, enemy, combatSessionStepRNG(sess, seat))
|
||||
events, err := te.step(action)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
te.commit()
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func TestTurnOrder_SoloIsAlwaysPlayerThenEnemy(t *testing.T) {
|
||||
sess := turnSession(CombatPhasePlayerTurn, 100, 100)
|
||||
p, e := basePlayer(), baseEnemy()
|
||||
// A blisteringly fast monster still swings second: the turn engine has
|
||||
// never rolled initiative for a duel, and P3 must not start.
|
||||
e.Stats.Speed = 999
|
||||
p.Mods.InitiativeBias = -50
|
||||
|
||||
for round := 1; round <= 5; round++ {
|
||||
got := turnOrder(sess, round, []*Combatant{&p}, &e)
|
||||
if len(got) != 2 || got[0] != 0 || got[1] != enemySeat {
|
||||
t.Fatalf("round %d solo order = %v, want [0 %d]", round, got, enemySeat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnOrder_PartySeatsEveryoneOnceAndIsDeterministic(t *testing.T) {
|
||||
sess := turnSession(CombatPhasePlayerTurn, 100, 100)
|
||||
a, b, c := basePlayer(), basePlayer(), basePlayer()
|
||||
e := baseEnemy()
|
||||
players := []*Combatant{&a, &b, &c}
|
||||
|
||||
order := turnOrder(sess, 3, players, &e)
|
||||
if len(order) != 4 {
|
||||
t.Fatalf("order = %v, want 4 slots (3 players + enemy)", order)
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
for _, seat := range order {
|
||||
if seen[seat] {
|
||||
t.Fatalf("seat %d appears twice in %v", seat, order)
|
||||
}
|
||||
seen[seat] = true
|
||||
}
|
||||
if !seen[0] || !seen[1] || !seen[2] || !seen[enemySeat] {
|
||||
t.Fatalf("order %v does not seat every combatant", order)
|
||||
}
|
||||
|
||||
// Same (session, round) must rebuild the same order — that is what lets a
|
||||
// resumed fight land on the seat it left off on.
|
||||
again := turnOrder(sess, 3, players, &e)
|
||||
for i := range order {
|
||||
if order[i] != again[i] {
|
||||
t.Fatalf("order not stable across rebuilds: %v vs %v", order, again)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnOrder_InitiativeBiasWins(t *testing.T) {
|
||||
sess := turnSession(CombatPhasePlayerTurn, 100, 100)
|
||||
slow, fast := basePlayer(), basePlayer()
|
||||
fast.Mods.InitiativeBias = 100 // dwarfs the d10 spread
|
||||
e := baseEnemy()
|
||||
|
||||
order := turnOrder(sess, 1, []*Combatant{&slow, &fast}, &e)
|
||||
if order[0] != 1 {
|
||||
t.Errorf("order = %v, want the biased seat 1 first", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombatSessionStepRNG_SeatZeroMatchesLegacyStream(t *testing.T) {
|
||||
sess := &CombatSession{SessionID: "abc123", Round: 2, Phase: CombatPhasePlayerTurn}
|
||||
if combatSessionStepRNG(sess, 0).Uint64() != combatSessionRNG(sess).Uint64() {
|
||||
t.Error("seat 0 must draw the pre-roster stream — the solo turn engine depends on it")
|
||||
}
|
||||
// The enemy sentinel shares seat 0's seed; its phase already separates it.
|
||||
if combatSessionStepRNG(sess, enemySeat).Uint64() != combatSessionRNG(sess).Uint64() {
|
||||
t.Error("the enemy sentinel must not perturb the seed")
|
||||
}
|
||||
// Party members share a (round, phase) pair, so only the seed keeps their
|
||||
// d20s apart.
|
||||
if combatSessionStepRNG(sess, 1).Uint64() == combatSessionStepRNG(sess, 0).Uint64() {
|
||||
t.Error("seats 0 and 1 drew the same stream in the same phase")
|
||||
}
|
||||
if combatSessionStepRNG(sess, 1).Uint64() == combatSessionStepRNG(sess, 2).Uint64() {
|
||||
t.Error("seats 1 and 2 drew the same stream in the same phase")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnIdxForPhase_ReconcilesARowWrittenBeforeTheCursorExisted(t *testing.T) {
|
||||
solo := []int{0, enemySeat}
|
||||
// A fight suspended mid-enemy-turn decodes TurnIdx as 0. Phase is the older
|
||||
// field and wins, so the cursor must snap to the enemy's slot — otherwise
|
||||
// the engine re-runs the enemy turn forever.
|
||||
if got := turnIdxForPhase(solo, 0, CombatPhaseEnemyTurn); got != 1 {
|
||||
t.Errorf("legacy enemy_turn row reconciled to idx %d, want 1", got)
|
||||
}
|
||||
if got := turnIdxForPhase(solo, 0, CombatPhasePlayerTurn); got != 0 {
|
||||
t.Errorf("player_turn row = idx %d, want 0", got)
|
||||
}
|
||||
// An out-of-range cursor (roster shrank) falls back to the phase.
|
||||
if got := turnIdxForPhase(solo, 7, CombatPhaseEnemyTurn); got != 1 {
|
||||
t.Errorf("out-of-range cursor = idx %d, want 1", got)
|
||||
}
|
||||
// A party order keeps an already-consistent cursor exactly where it is.
|
||||
party := []int{1, enemySeat, 0}
|
||||
if got := turnIdxForPhase(party, 2, CombatPhasePlayerTurn); got != 2 {
|
||||
t.Errorf("consistent cursor moved to idx %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_SoloRoundIsPlayerEnemyRoundEnd(t *testing.T) {
|
||||
sess := turnSession(CombatPhasePlayerTurn, 10000, 10000)
|
||||
p, e := basePlayer(), baseEnemy()
|
||||
players := []*Combatant{&p}
|
||||
|
||||
for _, want := range []string{CombatPhaseEnemyTurn, CombatPhaseRoundEnd, CombatPhasePlayerTurn} {
|
||||
if _, err := partyStep(sess, players, &e, PlayerAction{Kind: ActionAttack}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.Phase != want {
|
||||
t.Fatalf("phase = %q, want %q", sess.Phase, want)
|
||||
}
|
||||
}
|
||||
if sess.Round != 2 {
|
||||
t.Errorf("round = %d, want 2 after one full round", sess.Round)
|
||||
}
|
||||
if sess.Statuses.TurnIdx != 0 {
|
||||
t.Errorf("solo cursor = %d, want 0 — it should never leave the first slot at a player turn", sess.Statuses.TurnIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_PartyRoundGivesEverySeatATurn(t *testing.T) {
|
||||
sess := turnSession(CombatPhasePlayerTurn, 10000, 10000)
|
||||
a, b := basePlayer(), basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 100000
|
||||
players := []*Combatant{&a, &b}
|
||||
order := turnOrder(sess, 1, players, &e)
|
||||
|
||||
// Walk the round slot by slot, asserting each phase matches the order.
|
||||
for i, seat := range order {
|
||||
if sess.Phase != phaseForSeat(seat) {
|
||||
t.Fatalf("slot %d: phase = %q, want %q for seat %d", i, sess.Phase, phaseForSeat(seat), seat)
|
||||
}
|
||||
if sess.Statuses.TurnIdx != i {
|
||||
t.Fatalf("slot %d: cursor = %d", i, sess.Statuses.TurnIdx)
|
||||
}
|
||||
if _, err := partyStep(sess, players, &e, PlayerAction{Kind: ActionAttack}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if sess.Phase != CombatPhaseRoundEnd {
|
||||
t.Fatalf("phase after every seat acted = %q, want round_end", sess.Phase)
|
||||
}
|
||||
if _, err := partyStep(sess, players, &e, PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.Round != 2 || sess.Statuses.TurnIdx != 0 {
|
||||
t.Errorf("round_end left round=%d cursor=%d, want 2 and 0", sess.Round, sess.Statuses.TurnIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_DownedSeatForfeitsItsTurnSilently(t *testing.T) {
|
||||
// Force a party order whose first slot is a player, then kill that player.
|
||||
sess := turnSession(CombatPhasePlayerTurn, 10000, 10000)
|
||||
a, b := basePlayer(), basePlayer()
|
||||
e := baseEnemy()
|
||||
players := []*Combatant{&a, &b}
|
||||
order := turnOrder(sess, 1, players, &e)
|
||||
first := order[0]
|
||||
if first == enemySeat {
|
||||
t.Skip("this round's initiative put the enemy first; the seat-skip path is covered by the next slot")
|
||||
}
|
||||
|
||||
te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, first))
|
||||
te.st.actors[first].playerHP = 0
|
||||
events, err := te.step(PlayerAction{Kind: ActionAttack})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Errorf("a downed seat emitted %d events, want none", len(events))
|
||||
}
|
||||
if !sess.IsActive() {
|
||||
t.Error("one downed member ended the fight while another still stands")
|
||||
}
|
||||
if sess.Statuses.TurnIdx != 1 {
|
||||
t.Errorf("cursor = %d, want the next slot", sess.Statuses.TurnIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_EnemyOnlySwingsAtStandingMembers(t *testing.T) {
|
||||
sess := turnSession(CombatPhaseEnemyTurn, 10000, 10000)
|
||||
a, b := basePlayer(), basePlayer()
|
||||
a.Stats.MaxHP, b.Stats.MaxHP = 10000, 10000
|
||||
e := baseEnemy()
|
||||
players := []*Combatant{&a, &b}
|
||||
|
||||
// Seat 0 is down. Every enemy turn must land on seat 1, never on the corpse,
|
||||
// and never end the fight.
|
||||
for round := 1; round <= 25; round++ {
|
||||
sess.Round = round
|
||||
sess.Phase = CombatPhaseEnemyTurn
|
||||
sess.Statuses.TurnIdx = 1
|
||||
te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, enemySeat))
|
||||
te.st.actors[0].playerHP = 0
|
||||
before := te.st.actors[1].playerHP
|
||||
te.step(PlayerAction{})
|
||||
if te.st.actors[0].playerHP != 0 {
|
||||
t.Fatal("the enemy healed a corpse")
|
||||
}
|
||||
if !sess.IsActive() {
|
||||
t.Fatalf("round %d: fight ended with a member still standing (%d HP)", round, before)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_RosterWipeEndsTheFight(t *testing.T) {
|
||||
sess := turnSession(CombatPhaseEnemyTurn, 1, 10000)
|
||||
a, b := basePlayer(), basePlayer()
|
||||
e := baseEnemy()
|
||||
players := []*Combatant{&a, &b}
|
||||
|
||||
te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, enemySeat))
|
||||
te.st.actors[0].playerHP = 0
|
||||
te.st.actors[1].playerHP = 0
|
||||
if _, err := te.step(PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.Status != CombatStatusLost {
|
||||
t.Errorf("status = %q, want %q once the whole roster is down", sess.Status, CombatStatusLost)
|
||||
}
|
||||
}
|
||||
|
||||
// commit persists seat 0, not whoever the cursor happens to be parked on. The
|
||||
// enemy turn parks it on its target; round_end walks it across the roster.
|
||||
func TestTurnEngine_CommitPersistsSeatZeroNotTheCursor(t *testing.T) {
|
||||
sess := turnSession(CombatPhaseRoundEnd, 500, 10000)
|
||||
a, b := basePlayer(), basePlayer()
|
||||
e := baseEnemy()
|
||||
players := []*Combatant{&a, &b}
|
||||
|
||||
te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, enemySeat))
|
||||
te.st.actors[0].playerHP = 300
|
||||
te.st.actors[1].playerHP = 77
|
||||
te.st.actors[1].luckyUsed = true
|
||||
if _, err := te.step(PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
te.commit()
|
||||
|
||||
if sess.PlayerHP != 300 {
|
||||
t.Errorf("persisted PlayerHP = %d, want seat 0's 300", sess.PlayerHP)
|
||||
}
|
||||
if sess.Statuses.LuckyUsed {
|
||||
t.Error("seat 1's consumed Lucky reroll leaked onto the session row")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user