mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Pets: per-round attack + wire deflect/whiff into the turn engine
The live turn engine only struck once per fight and never rolled pet deflect or whiff, so pet armor (deflect-only) bought nothing in real runs. Roll pet attack each player turn and roll deflect/whiff per enemy turn, mirroring the auto-resolve engine; retire the one-shot pet-proc machinery (rollCombatSessionPetProc / PetProcReady). (cherry picked from commit a0e41c97801e500efad13c7e9a06be4c345e464e)
This commit is contained in:
@@ -91,11 +91,9 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto
|
// Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto
|
||||||
// the session so they survive the turn engine's resume/commit cycle, and
|
// the session so they survive the turn engine's resume/commit cycle. The
|
||||||
// make the one-and-only per-fight pet-attack roll.
|
// pet now rolls per-turn inside the engine, so there's no fight-start roll.
|
||||||
seeded := seedCombatSessionOneShots(sess, player.Mods)
|
if seedCombatSessionOneShots(sess, player.Mods) {
|
||||||
pet := rollCombatSessionPetProc(sess, player.Mods)
|
|
||||||
if seeded || pet {
|
|
||||||
if err := saveCombatSession(sess); err != nil {
|
if err := saveCombatSession(sess); err != nil {
|
||||||
slog.Error("combat: seed session one-shots", "user", ctx.Sender, "err", err)
|
slog.Error("combat: seed session one-shots", "user", ctx.Sender, "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,10 +319,6 @@ type combatState struct {
|
|||||||
// the enemy would otherwise attack).
|
// the enemy would otherwise attack).
|
||||||
enemySkipFirst bool
|
enemySkipFirst bool
|
||||||
|
|
||||||
// Phase 13 turn-based — pet attack decided once at fight start; the pet
|
|
||||||
// strikes once on the player's first acting turn, which clears this.
|
|
||||||
petProcReady bool
|
|
||||||
|
|
||||||
// Phase 10 SUB2a-ii first-attack one-shots.
|
// Phase 10 SUB2a-ii first-attack one-shots.
|
||||||
firstAttackBonusUsed bool
|
firstAttackBonusUsed bool
|
||||||
assassinateRerollUsed bool
|
assassinateRerollUsed bool
|
||||||
|
|||||||
@@ -65,14 +65,6 @@ type CombatStatuses struct {
|
|||||||
// combatState, so the flag must survive the commit between them.
|
// combatState, so the flag must survive the commit between them.
|
||||||
EnemySkipNext bool `json:"enemy_skip_next,omitempty"`
|
EnemySkipNext bool `json:"enemy_skip_next,omitempty"`
|
||||||
|
|
||||||
// PetProcReady is the per-fight pet-attack outcome. Auto-resolve rolls the
|
|
||||||
// pet proc every round; a manual fight can run many rounds, so the roll is
|
|
||||||
// decided once at fight start (rollCombatSessionPetProc) and parked here.
|
|
||||||
// The pet then lands a single hit on the player's first acting turn, which
|
|
||||||
// clears the flag — persisted so a suspend/resume or reaper auto-play sees
|
|
||||||
// the same outcome.
|
|
||||||
PetProcReady bool `json:"pet_proc_ready,omitempty"`
|
|
||||||
|
|
||||||
// Fight-scoped depleting resources — mirror the combatState charges that
|
// Fight-scoped depleting resources — mirror the combatState charges that
|
||||||
// genuinely carry round-to-round. Seeded at fight start (Arcane Ward) or by
|
// genuinely carry round-to-round. Seeded at fight start (Arcane Ward) or by
|
||||||
// a mid-fight !cast / !consume, restored into combatState on every resume,
|
// a mid-fight !cast / !consume, restored into combatState on every resume,
|
||||||
|
|||||||
@@ -425,72 +425,120 @@ func TestTurnEngine_OneShotsRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pet proc (per-fight) ───────────────────────────────────────────────────
|
// ── Pet proc (per-round) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestRollCombatSessionPetProc(t *testing.T) {
|
// TestTurnEngine_PetStrike confirms a pet with a guaranteed proc strikes on
|
||||||
// No pet proc on the player → never rolls true, never touches statuses.
|
// every player-acting turn — the per-round roll model, matching auto-resolve.
|
||||||
none := &CombatSession{SessionID: "no-pet"}
|
|
||||||
if rollCombatSessionPetProc(none, CombatModifiers{}) || none.Statuses.PetProcReady {
|
|
||||||
t.Error("zero PetAttackProc should not arm a pet strike")
|
|
||||||
}
|
|
||||||
// A guaranteed proc arms the flag; the draw is deterministic per session id.
|
|
||||||
sure := &CombatSession{SessionID: "pet-fight"}
|
|
||||||
if !rollCombatSessionPetProc(sure, CombatModifiers{PetAttackProc: 1.0}) || !sure.Statuses.PetProcReady {
|
|
||||||
t.Error("PetAttackProc 1.0 should always arm a pet strike")
|
|
||||||
}
|
|
||||||
again := &CombatSession{SessionID: "pet-fight"}
|
|
||||||
rollCombatSessionPetProc(again, CombatModifiers{PetAttackProc: 1.0})
|
|
||||||
if again.Statuses.PetProcReady != sure.Statuses.PetProcReady {
|
|
||||||
t.Error("same session id should roll the pet proc deterministically")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTurnEngine_PetStrike confirms an armed pet lands exactly one hit on the
|
|
||||||
// player's first acting turn, then never again — the per-fight roll model.
|
|
||||||
func TestTurnEngine_PetStrike(t *testing.T) {
|
func TestTurnEngine_PetStrike(t *testing.T) {
|
||||||
// Pools huge so no phase can end the fight; isolate the pet behaviour.
|
// Pools huge so no phase can end the fight; isolate the pet behaviour.
|
||||||
sess := turnSession(CombatPhasePlayerTurn, 100000, 100000)
|
sess := turnSession(CombatPhasePlayerTurn, 100000, 100000)
|
||||||
sess.Statuses.PetProcReady = true
|
|
||||||
player, enemy := basePlayer(), baseEnemy()
|
player, enemy := basePlayer(), baseEnemy()
|
||||||
|
player.Mods.PetAttackProc = 1.0
|
||||||
player.Mods.PetAttackDmg = 8
|
player.Mods.PetAttackDmg = 8
|
||||||
|
|
||||||
|
petHitsOnPlayerTurn := func() int {
|
||||||
events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
|
events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
petHits := 0
|
hits := 0
|
||||||
for _, e := range events {
|
for _, e := range events {
|
||||||
if e.Action == "pet_attack" {
|
if e.Action == "pet_attack" {
|
||||||
petHits++
|
hits++
|
||||||
if e.Damage <= 0 {
|
if e.Damage <= 0 {
|
||||||
t.Errorf("pet_attack damage = %d, want > 0", e.Damage)
|
t.Errorf("pet_attack damage = %d, want > 0", e.Damage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if petHits != 1 {
|
return hits
|
||||||
t.Fatalf("pet_attack events = %d on first player turn, want 1", petHits)
|
|
||||||
}
|
|
||||||
if sess.Statuses.PetProcReady {
|
|
||||||
t.Error("PetProcReady should clear after the pet strikes")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cycle back to the next player turn — the pet must not strike again.
|
if got := petHitsOnPlayerTurn(); got != 1 {
|
||||||
|
t.Fatalf("pet_attack events = %d on first player turn, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycle back to the next player turn — the pet must strike again.
|
||||||
for sess.Phase != CombatPhasePlayerTurn {
|
for sess.Phase != CombatPhasePlayerTurn {
|
||||||
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
|
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
events, err = stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
|
if got := petHitsOnPlayerTurn(); got != 1 {
|
||||||
|
t.Errorf("pet_attack events = %d on second player turn, want 1 (per-round)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTurnEngine_PetNoProc confirms a player without the pet proc never sees a
|
||||||
|
// pet strike.
|
||||||
|
func TestTurnEngine_PetNoProc(t *testing.T) {
|
||||||
|
sess := turnSession(CombatPhasePlayerTurn, 100000, 100000)
|
||||||
|
player, enemy := basePlayer(), baseEnemy()
|
||||||
|
player.Mods.PetAttackProc = 0
|
||||||
|
|
||||||
|
events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, e := range events {
|
for _, e := range events {
|
||||||
if e.Action == "pet_attack" {
|
if e.Action == "pet_attack" {
|
||||||
t.Error("pet struck twice — the roll is per fight, not per round")
|
t.Error("pet struck with zero PetAttackProc")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTurnEngine_PetWhiff confirms a guaranteed whiff makes the enemy's turn a
|
||||||
|
// total miss — a pet_whiff event fires and the player takes no damage.
|
||||||
|
func TestTurnEngine_PetWhiff(t *testing.T) {
|
||||||
|
sess := turnSession(CombatPhaseEnemyTurn, 100000, 100000)
|
||||||
|
player, enemy := basePlayer(), baseEnemy()
|
||||||
|
player.Mods.PetWhiffProc = 1.0
|
||||||
|
enemy.Stats.AttackBonus = 50 // would connect easily if not whiffed
|
||||||
|
startHP := sess.PlayerHP
|
||||||
|
|
||||||
|
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
whiffs := 0
|
||||||
|
for _, e := range events {
|
||||||
|
if e.Action == "pet_whiff" {
|
||||||
|
whiffs++
|
||||||
|
}
|
||||||
|
if e.Actor == "enemy" && e.Action == "hit" {
|
||||||
|
t.Error("enemy landed a hit despite a guaranteed pet whiff")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if whiffs == 0 {
|
||||||
|
t.Error("expected a pet_whiff event with PetWhiffProc 1.0")
|
||||||
|
}
|
||||||
|
if sess.PlayerHP != startHP {
|
||||||
|
t.Errorf("player took %d damage on a whiffed turn, want 0", startHP-sess.PlayerHP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTurnEngine_PetDeflect confirms a guaranteed deflect emits a pet_deflect
|
||||||
|
// event on a connecting enemy attack.
|
||||||
|
func TestTurnEngine_PetDeflect(t *testing.T) {
|
||||||
|
sess := turnSession(CombatPhaseEnemyTurn, 100000, 100000)
|
||||||
|
player, enemy := basePlayer(), baseEnemy()
|
||||||
|
player.Mods.PetDeflectProc = 1.0
|
||||||
|
enemy.Stats.AttackBonus = 50 // guarantee the swing connects
|
||||||
|
|
||||||
|
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
deflects := 0
|
||||||
|
for _, e := range events {
|
||||||
|
if e.Action == "pet_deflect" {
|
||||||
|
deflects++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if deflects == 0 {
|
||||||
|
t.Error("expected a pet_deflect event with PetDeflectProc 1.0 on a connecting hit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCombatSessionRNG_DeterministicPerPhase(t *testing.T) {
|
func TestCombatSessionRNG_DeterministicPerPhase(t *testing.T) {
|
||||||
sess := &CombatSession{SessionID: "abc123", Round: 2, Phase: CombatPhaseEnemyTurn}
|
sess := &CombatSession{SessionID: "abc123", Round: 2, Phase: CombatPhaseEnemyTurn}
|
||||||
a := combatSessionRNG(sess)
|
a := combatSessionRNG(sess)
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
|
|||||||
armorBroken: sess.Statuses.ArmorBroken,
|
armorBroken: sess.Statuses.ArmorBroken,
|
||||||
armorBreakAmt: sess.Statuses.ArmorBreakAmt,
|
armorBreakAmt: sess.Statuses.ArmorBreakAmt,
|
||||||
enemySkipFirst: sess.Statuses.EnemySkipNext,
|
enemySkipFirst: sess.Statuses.EnemySkipNext,
|
||||||
petProcReady: sess.Statuses.PetProcReady,
|
|
||||||
// Fight-scoped depleting resources + once-per-fight one-shots: restored
|
// Fight-scoped depleting resources + once-per-fight one-shots: restored
|
||||||
// from the persisted statuses so a charge or "already used" flag can't
|
// from the persisted statuses so a charge or "already used" flag can't
|
||||||
// reset across a suspend/resume. commit writes the updated values back.
|
// reset across a suspend/resume. commit writes the updated values back.
|
||||||
@@ -223,19 +222,19 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
|
|||||||
te.sess.Phase = CombatPhaseEnemyTurn
|
te.sess.Phase = CombatPhaseEnemyTurn
|
||||||
}
|
}
|
||||||
|
|
||||||
// petStrike resolves the player's pet attack for a turn-based fight. Whether
|
// petStrike resolves the player's pet attack for a turn-based fight. The pet
|
||||||
// the pet lands a hit was decided once at fight start (rollCombatSessionPetProc)
|
// rolls fresh on every player-acting turn (PetAttackProc), mirroring the
|
||||||
// and parked on the session; the pet then strikes a single time on the player's
|
// auto-resolve engine's per-round chance rather than a once-per-fight strike.
|
||||||
// first acting turn — this clears the flag so it never repeats. Damage reuses
|
// The roll rides the per-(round,phase) step RNG, so a suspend/resume or reaper
|
||||||
// the auto-resolve formula (PetAttackDmg + d5), and PetAttackDmg already carries
|
// auto-play of the same turn reproduces the same outcome. Damage reuses the
|
||||||
// any mid-fight buff delta via applySessionBuffs. Returns true if the strike
|
// auto-resolve formula (PetAttackDmg + d5), and PetAttackDmg already carries any
|
||||||
// dropped the enemy.
|
// mid-fight buff delta via applySessionBuffs. Returns true if the strike dropped
|
||||||
|
// the enemy.
|
||||||
func (te *turnEngine) petStrike() bool {
|
func (te *turnEngine) petStrike() bool {
|
||||||
st := te.st
|
st := te.st
|
||||||
if !st.petProcReady {
|
if te.player.Mods.PetAttackProc <= 0 || st.randFloat() >= te.player.Mods.PetAttackProc {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
st.petProcReady = false
|
|
||||||
petDmg := te.player.Mods.PetAttackDmg + st.roll(5)
|
petDmg := te.player.Mods.PetAttackDmg + st.roll(5)
|
||||||
st.enemyHP = max(0, st.enemyHP-petDmg)
|
st.enemyHP = max(0, st.enemyHP-petDmg)
|
||||||
st.events = append(st.events, CombatEvent{
|
st.events = append(st.events, CombatEvent{
|
||||||
@@ -245,32 +244,6 @@ func (te *turnEngine) petStrike() bool {
|
|||||||
return enemyDown(st, turnCombatPhase.Name)
|
return enemyDown(st, turnCombatPhase.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// rollCombatSessionPetProc makes the one-and-only per-fight pet-attack roll and
|
|
||||||
// parks the result on the session. Called once at fight start. The draw is
|
|
||||||
// deterministic — seeded off the session id on a stream distinct from the
|
|
||||||
// per-(round,phase) combat streams — so a reaper auto-play of an abandoned
|
|
||||||
// fight reproduces the same outcome. Returns true if the pet will attack (so
|
|
||||||
// the caller can decide whether the session needs persisting).
|
|
||||||
//
|
|
||||||
// Note: only the base PetAttackProc (class/race/subclass passives) is rolled
|
|
||||||
// here — a pet-proc buff cast mid-fight gets no fresh roll, consistent with the
|
|
||||||
// per-fight rule. Such a buff still raises PetAttackDmg if the pet does strike.
|
|
||||||
func rollCombatSessionPetProc(sess *CombatSession, playerMods CombatModifiers) bool {
|
|
||||||
if playerMods.PetAttackProc <= 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var seed uint64 = 1469598103934665603
|
|
||||||
for _, c := range sess.SessionID {
|
|
||||||
seed = (seed ^ uint64(c)) * 1099511628211
|
|
||||||
}
|
|
||||||
rng := rand.New(rand.NewPCG(seed, 0x9E3779B97F4A7C15))
|
|
||||||
if rngFloat(rng) < playerMods.PetAttackProc {
|
|
||||||
sess.Statuses.PetProcReady = true
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// stepPlayerActionEffect resolves a !cast / !consume turn: the command handler
|
// stepPlayerActionEffect resolves a !cast / !consume turn: the command handler
|
||||||
// has already rolled the spell / picked the item and spent the resource, so the
|
// 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
|
// engine only applies the HP deltas and emits the event before handing off to
|
||||||
@@ -375,6 +348,17 @@ func (te *turnEngine) stepEnemyTurn() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !abilityDealtDamage {
|
if !abilityDealtDamage {
|
||||||
|
// Pet defensive procs are a per-round concept (mirrors the auto-resolve
|
||||||
|
// engine): roll once for the whole enemy turn, then apply to every swing
|
||||||
|
// in a multiattack profile. Whiff makes the enemy's attack this round a
|
||||||
|
// guaranteed miss; deflect halves the incoming damage. The shared
|
||||||
|
// resolveEnemyAttack primitive consumes both flags.
|
||||||
|
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
|
||||||
|
if petDeflect {
|
||||||
|
te.result.PetDeflected = true
|
||||||
|
}
|
||||||
|
|
||||||
// SRD multiattack: each profile entry is one attack roll resolved
|
// SRD multiattack: each profile entry is one attack roll resolved
|
||||||
// through the shared primitive. A registered elite/boss swings its full
|
// through the shared primitive. A registered elite/boss swings its full
|
||||||
// profile; everyone else gets a single attack from the template stats.
|
// profile; everyone else gets a single attack from the template stats.
|
||||||
@@ -385,7 +369,7 @@ func (te *turnEngine) stepEnemyTurn() {
|
|||||||
swing := *te.enemy
|
swing := *te.enemy
|
||||||
swing.Stats.Attack = atk.Damage
|
swing.Stats.Attack = atk.Damage
|
||||||
swing.Stats.AttackBonus = atk.AttackBonus
|
swing.Stats.AttackBonus = atk.AttackBonus
|
||||||
decided := resolveEnemyAttack(te.st, te.player, &swing, &turnCombatPhase, te.result, false, false, false)
|
decided := resolveEnemyAttack(te.st, te.player, &swing, &turnCombatPhase, te.result, petWhiff, petDeflect, false)
|
||||||
if te.st.playerHP <= 0 {
|
if te.st.playerHP <= 0 {
|
||||||
te.finish(CombatStatusLost)
|
te.finish(CombatStatusLost)
|
||||||
return
|
return
|
||||||
@@ -479,7 +463,6 @@ func (te *turnEngine) commit() {
|
|||||||
s.ArmorBroken = st.armorBroken
|
s.ArmorBroken = st.armorBroken
|
||||||
s.ArmorBreakAmt = st.armorBreakAmt
|
s.ArmorBreakAmt = st.armorBreakAmt
|
||||||
s.EnemySkipNext = st.enemySkipFirst
|
s.EnemySkipNext = st.enemySkipFirst
|
||||||
s.PetProcReady = st.petProcReady
|
|
||||||
s.WardCharges = st.wardCharges
|
s.WardCharges = st.wardCharges
|
||||||
s.SporeRounds = st.sporeRounds
|
s.SporeRounds = st.sporeRounds
|
||||||
s.ReflectFrac = st.reflectFrac
|
s.ReflectFrac = st.reflectFrac
|
||||||
|
|||||||
Reference in New Issue
Block a user