package plugin import ( "errors" "fmt" "math/rand/v2" "sort" ) // 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" ActionCast = "cast" ActionConsume = "consume" ) // 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 // Effect is set for ActionCast / ActionConsume: a pre-resolved outcome // handed in by the command handler. The handler owns spell/item lookup, // dice rolls, and the resource spend; the engine just applies the HP // deltas and emits the event so the round flows on into the enemy turn. Effect *turnActionEffect } // turnActionEffect is the resolved payload of a !cast / !consume player turn. // Damage, heal, and the one-round enemy-skip all land within the casting round. type turnActionEffect struct { Label string // "Fireball — crit!", "Berry Poultice" Action string // CombatEvent.Action: "spell_cast" or "use_consumable" EnemyDamage int PlayerHeal int EnemySkip bool // control spell: enemy forfeits its attack this round // ConcentrationDmg arms a per-round aura tick when a concentration damage // spell is cast: EnemyDamage is the burst that lands this round, this is // what re-ticks at every round_end after. Zero for one-shot spells; a // non-zero value overwrites any aura already running (5e: one // concentration at a time). 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 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 { 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: return 0 case CombatPhaseEnemyTurn: return 1 case CombatPhaseRoundEnd: return 2 default: // CombatPhaseOver return 3 } } // 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 } // stepSeat is the seat that resolves the session's current phase: the acting // player on a player_turn, the enemy sentinel on anything else. // // It is the *single* derivation of "whose step is this". advancePartyCombatSession // needs it before the engine is resumed, because the seat seeds that step's RNG; // the command layer needs it to tell a member it is not their turn. Both read // only (sessionID, round, roster), so they agree by construction. func stepSeat(sess *CombatSession, players []*Combatant, enemy *Combatant) int { if sess.Phase != CombatPhasePlayerTurn { return enemySeat } order := turnOrder(sess, sess.Round, players, enemy) return order[turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)] } // actingSeat names the player seat currently on the clock. The false return // means the fight is not waiting on anybody: it is mid enemy-turn, mid // round-end, or over. func actingSeat(sess *CombatSession, players []*Combatant, enemy *Combatant) (int, bool) { if !sess.IsActive() || sess.Phase != CombatPhasePlayerTurn { return 0, false } return stepSeat(sess, players, enemy), true } // 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 combatSessionStepRNG). // // restoreActor rebuilds one seat from its persisted state: live HP, the pool // ceiling, and the per-character effect set. Depleting resources and the // once-per-fight "already used" flags are restored rather than rearmed, so a // charge can't silently reset across a suspend/resume or a reaper auto-play. // snapshotActor is its exact inverse — keep the two field lists in step. func restoreActor(c *Combatant, hp, hpMax int, s ActorStatuses) *actor { return &actor{ c: c, playerHP: hp, hpMax: hpMax, poisonTicks: s.PoisonTicks, poisonDmg: s.PoisonDmg, stunPlayer: s.StunPlayer, wardCharges: s.WardCharges, sporeRounds: s.SporeRounds, reflectFrac: s.ReflectFrac, autoCrit: s.AutoCritFirst, arcaneWardHP: s.ArcaneWardHP, healChargesLeft: s.HealChargesLeft, concentrationDmg: s.ConcentrationDmg, deathSaveUsed: s.DeathSaveUsed, luckyUsed: s.LuckyUsed, raged: s.Raged, pendingRageAttack: s.PendingRage, firstAttackBonusUsed: s.FirstAtkBonusUsed, assassinateRerollUsed: s.AssassinateReroll, assassinateBonusUsed: s.AssassinateBonus, // Enemy debuffs stacked onto this character specifically. playerAtkDrain: s.PlayerAtkDrain, playerACDebuff: s.PlayerACDebuff, maxHPDrain: s.MaxHPDrain, } } // snapshotActor folds one seat's live state back into its persisted form. The // Buff* deltas are owned by the command layer (a !cast / !consume folds them in // via applyBuffDelta) and are not combatState fields, so they are carried over // from the prior snapshot rather than re-derived. func snapshotActor(a *actor, prior ActorStatuses) ActorStatuses { s := prior s.PoisonTicks = a.poisonTicks s.PoisonDmg = a.poisonDmg s.StunPlayer = a.stunPlayer 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.PlayerAtkDrain = a.playerAtkDrain s.PlayerACDebuff = a.playerACDebuff s.MaxHPDrain = a.maxHPDrain return s } // Every seat restores its own persisted per-character statuses: seat 0 from the // session row's embedded ActorStatuses, seats 1+ from their combat_participant // row. Without that, a party member's once-per-fight one-shots would rearm on // every engine step. players[i] must be the combatant for seat i — the caller // owns that ordering, and it must match sess.Participants[i-1].UserID. func resumeTurnEngine(sess *CombatSession, players []*Combatant, enemy *Combatant, rng *rand.Rand) *turnEngine { seat0 := restoreActor(players[0], sess.PlayerHP, sess.PlayerHPMax, sess.Statuses.ActorStatuses) actors := make([]*actor, len(players)) actors[0] = seat0 for i := 1; i < len(players); i++ { // A roster longer than the persisted seats can only happen if a caller // hands us combatants the session never seated. Open those fresh rather // than panicking mid-fight; hydrateCombatParticipants already rejects // the reverse (a session claiming more seats than it persisted). if i-1 >= len(sess.Participants) { actors[i] = newActor(players[i]) continue } p := sess.Participants[i-1] actors[i] = restoreActor(players[i], p.HP, p.HPMax, p.Statuses) } st := &combatState{ actor: seat0, actors: actors, enemyHP: sess.EnemyHP, round: sess.Round, enraged: sess.Statuses.Enraged, armorBroken: sess.Statuses.ArmorBroken, armorBreakAmt: sess.Statuses.ArmorBreakAmt, enemySkipFirst: sess.Statuses.EnemySkipNext, // Slice-3 stateful monster-ability effects — armed by applyAbility, // round-tripped here so they survive a suspend/resume or reaper auto-play. enemyEvadeNext: sess.Statuses.EnemyEvadeNext, enemyBlockUp: sess.Statuses.EnemyBlockUp, enemyAdvantage: sess.Statuses.EnemyAdvantage, enemyRetaliateFrac: sess.Statuses.EnemyRetaliateFrac, enemyRegen: sess.Statuses.EnemyRegen, enemySurviveArmed: sess.Statuses.EnemySurviveArmed, // Slice-4 monster-ability effects — the former flavor-only placeholders. enemySpellResist: sess.Statuses.EnemySpellResist, enemyRevealNext: sess.Statuses.EnemyRevealNext, enemyFearImmune: sess.Statuses.EnemyFearImmune, enemyAtkBuff: sess.Statuses.EnemyAtkBuff, rng: rng, } 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 // 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. // stampSeat tags every event appended since mark with the roster seat it is // about, so a party's play-by-play can name the right character. The primitives // in combat_primitives.go emit against the cursor and know nothing of seats, so // the tag is applied here, once per phase step, rather than at ~20 append sites. // // Solo stamps seat 0 onto seat-0 events — a no-op that omitempty erases. func (te *turnEngine) stampSeat(mark, seat int) { for i := mark; i < len(te.st.events); i++ { te.st.events[i].Seat = seat } } 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: // The cursor was seated on the acting player by resumeTurnEngine, and // stepPlayerTurn never moves it, so every event this phase emits — the // swings, the pet, the spirit weapon, the retaliate that kills them — // belongs to that seat. acting := te.st.seatIdx te.stepPlayerTurn(action) te.stampSeat(0, acting) case CombatPhaseEnemyTurn: te.stepEnemyTurn() // stepEnemyTurn seats its target before anything resolves, and the whole // phase lands on that one character. te.stampSeat(0, te.st.seatIdx) 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) { // 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{ Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: "flee", PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP, }) te.finish(CombatStatusFled) return case ActionCast, ActionConsume: te.stepPlayerActionEffect(action.Effect) return } // Default: weapon attack. resolvePlayerSwings rolls the base swing plus // Mods.ExtraAttacks follow-ups (5e Extra Attack at Fighter L5/L11/L20, // Ranger/Paladin L5, Bard College of Valor L7). One !attack press in the // turn-based engine == one 5e Attack action == all swings in sequence. // Before this, the turn path called single-swing resolvePlayerAttack and // 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 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 } } te.advance() } // petStrike resolves the player's pet attack for a turn-based fight. The pet // rolls fresh on every player-acting turn (PetAttackProc), mirroring the // auto-resolve engine's per-round chance rather than a once-per-fight strike. // The roll rides the per-(round,phase) step RNG, so a suspend/resume or reaper // auto-play of the same turn reproduces the same outcome. Damage reuses the // auto-resolve formula (PetAttackDmg + d5), and PetAttackDmg already carries any // mid-fight buff delta via applySessionBuffs. Returns true if the strike dropped // the enemy. func (te *turnEngine) petStrike() bool { st := te.st if st.c.Mods.PetAttackProc <= 0 || st.randFloat() >= st.c.Mods.PetAttackProc { return false } 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", Damage: petDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, }) return enemyDown(st, turnCombatPhase.Name) } // spiritWeaponStrike resolves the spell's bonus-action attack each round when // the spiritual_weapon buff is active. Same per-turn cadence as petStrike, but // rolls and narrates on its own channel so the spectral mace doesn't borrow // pet flavor on a petless caster. Returns true if the strike dropped the enemy. func (te *turnEngine) spiritWeaponStrike() bool { st := te.st if st.c.Mods.SpiritWeaponProc <= 0 || st.randFloat() >= st.c.Mods.SpiritWeaponProc { return false } 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", Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, }) return enemyDown(st, turnCombatPhase.Name) } // stepPlayerActionEffect resolves a !cast / !consume turn: the command handler // has already rolled the spell / picked the item and spent the resource, so the // engine only applies the HP deltas and emits the event before handing off to // the enemy turn. A control-spell skip is parked in st.enemySkipFirst, which // commit() persists as Statuses.EnemySkipNext for the enemy_turn step to read. func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) { st := te.st if eff == nil { // Defensive: a kind with no payload just passes the turn. te.advance() return } action := eff.Action if action == "" { action = "spell_cast" } // spell_resist (Spell Immunity): a spell's damage against the enemy is // halved. Consumables that happen to deal damage are not spells, so the // resist is gated on the spell_cast action. enemyDmg := eff.EnemyDamage spellFizzled := enemyDmg > 0 && action == "spell_cast" && enemyResistsSpells(te.enemy, st) if spellFizzled { enemyDmg = max(1, enemyDmg/2) } if enemyDmg > 0 { st.enemyHP = max(0, st.enemyHP-enemyDmg) } 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, st.hpMax-st.maxHPDrain) st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal) } // Arm / replace the concentration aura. A new concentration cast overwrites // the old one (5e: one concentration at a time); non-concentration casts // leave any running aura alone. if eff.ConcentrationDmg > 0 { st.concentrationDmg = eff.ConcentrationDmg } st.events = append(st.events, CombatEvent{ Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: action, Damage: enemyDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: eff.Label, }) if spellFizzled { st.events = append(st.events, CombatEvent{ Round: st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_fizzle", Damage: enemyDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, }) } if enemyDown(st, turnCombatPhase.Name) { te.finish(CombatStatusWon) return } if te.petStrike() { te.finish(CombatStatusWon) return } if te.spiritWeaponStrike() { te.finish(CombatStatusWon) return } if eff.EnemySkip { // fear_immune enemies shrug off control spells — the skip never arms. if enemyImmuneToControl(te.enemy, st) { st.events = append(st.events, CombatEvent{ Round: st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "fear_resist", PlayerHP: st.playerHP, EnemyHP: st.enemyHP, }) } else { st.enemySkipFirst = true } } 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) { return enemyTargetSeat(te.st) } 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. if te.st.enemySkipFirst { te.st.enemySkipFirst = false if enemyImmuneToControl(te.enemy, te.st) { te.st.events = append(te.st.events, CombatEvent{ Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "fear_resist", PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP, }) } else { te.st.events = append(te.st.events, CombatEvent{ Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held", PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP, }) te.advance() return } } // Monster ability fires at the top of the enemy turn. cleave / lifesteal // resolve their own damage and stand in for the attack action this round; // every other effect (poison / stun / enrage / armor_break) is a rider that // leaves the multiattack below intact. applyAbility returns true when the // 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.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 { case "cleave", "lifesteal": abilityDealtDamage = true } } if !abilityDealtDamage { // Pet defensive procs are a single proc per enemy turn: roll once, then // spend it on the first swing only. Whiff makes that one swing a // guaranteed miss; deflect halves its damage. Against a multiattack the // 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.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 } // SRD multiattack: each profile entry is one attack roll resolved // through the shared primitive. A registered elite/boss swings its full // profile; everyone else gets a single attack from the template stats. // 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. for i, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) { swing := *te.enemy swing.Stats.Attack = atk.Damage swing.Stats.AttackBonus = atk.AttackBonus // 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.st.c, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false) if te.st.playerHP <= 0 { // 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) return } } } te.advance() } // turnAbilityFires decides whether a monster ability triggers this enemy turn. // The auto-resolve engine gates abilities on its named phase clock (Opening / // Clash / Decisive); the turn-based duel has no phase clock, so the phase tag // is remapped to fight progress: "opening" is round-1 only, "decisive" unlocks // once the enemy is bloodied (<40% HP), "clash" covers the rounds in between, // and "any" is always eligible. ProcChance is rolled once eligible. func turnAbilityFires(ability *MonsterAbility, st *combatState, enemy *Combatant) bool { eligible := false switch ability.Phase { case "any": eligible = true case "opening": eligible = st.round <= 1 case "clash": eligible = st.round > 1 case "decisive": eligible = st.enemyHP < int(float64(enemy.Stats.MaxHP)*0.4) } if !eligible { return false } return st.randFloat() < ability.ProcChance } // 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 for i := range st.actors { st.seat(i) if st.poisonTicks <= 0 { continue } mark := len(st.events) 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 && !trySave(st, st.c, CombatPhaseRoundEnd) && !st.anyAlive() { te.stampSeat(mark, i) te.finish(CombatStatusLost) return } te.stampSeat(mark, i) } // Concentration aura (Spirit Guardians et al.): the lingering spell bites // 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 — and before // Misty's crowd swings, so a caster whose aura would end the round is not // robbed of the win by an end-of-round debuff. 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", Damage: st.concentrationDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i, }) if st.enemyHP <= 0 { te.finish(CombatStatusWon) return } } // Misty's crowd, then Misty's heal — per seat, after the round's other // damage (including the concentration pulse above) has landed so the heal // can answer it, which is the order endOfRoundForSeat uses. Both no-op (and // draw no RNG) for a character with no Misty history, which is every // simulated one. for i := range st.actors { st.seat(i) if st.playerHP <= 0 { continue } mark := len(st.events) over := seatEndOfRound(st, st.c, CombatPhaseRoundEnd, te.result) te.stampSeat(mark, i) if over { te.finish(CombatStatusLost) 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) st.events = append(st.events, CombatEvent{ Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "regen_tick", Damage: st.enemyRegen, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, }) } st.round++ // 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. 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. // // The Buff* stat deltas on Statuses are NOT combatState fields — they're owned // by the command layer (a !cast / !consume folds them in) and applied to the // rebuilt combatant by applySessionBuffs — so snapshotActor carries them over // from the prior state rather than re-deriving them. // // Per-actor state is read off each seat by index, never off the cursor: the // enemy turn parks the cursor on its target and round_end walks it across the // roster, so whoever it points at when commit runs is an accident of the phase. // Seat 0 lands on the session row; seats 1+ on their participant rows. func (te *turnEngine) commit() { st := te.st te.sess.Round = st.round te.sess.EnemyHP = st.enemyHP seat0 := st.actors[0] te.sess.PlayerHP = seat0.playerHP s := &te.sess.Statuses s.ActorStatuses = snapshotActor(seat0, s.ActorStatuses) for i := 1; i < len(st.actors) && i-1 < len(te.sess.Participants); i++ { p := &te.sess.Participants[i-1] p.HP = st.actors[i].playerHP p.Statuses = snapshotActor(st.actors[i], p.Statuses) } // Fight-scoped: the enemy's own stance, and the debuffs it wears. s.Enraged = st.enraged s.ArmorBroken = st.armorBroken s.ArmorBreakAmt = st.armorBreakAmt s.EnemySkipNext = st.enemySkipFirst 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.EnemySpellResist = st.enemySpellResist s.EnemyRevealNext = st.enemyRevealNext s.EnemyFearImmune = st.enemyFearImmune s.EnemyAtkBuff = st.enemyAtkBuff te.sess.TurnLog = append(te.sess.TurnLog, st.events...) } // 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 } 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. rng := combatSessionStepRNG(sess, stepSeat(sess, players, enemy)) te := resumeTurnEngine(sess, players, 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 }