mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
N3/P2: give the combat engine an N-player roster
Splits combatState into a fight-scoped half and a per-character half. Everything that belongs to one PC -- HP, ward/spore/reflect charges, heal charges, poison ticks, the death save, Lucky/Rage, the first-attack one-shots, the arcane ward, concentration, and the debuffs an enemy stacks onto a specific character -- moves to a new `actor`. What belongs to the fight stays: the enemy pool, the enemy's stance (evade/block/advantage/retaliate/regen/survive), the round counter, the event log, and the RNG stream. combatState embeds *actor, so the promoted fields keep their names and all ~230 existing reads (st.playerHP, st.wardCharges, ...) compile untouched. The embedded pointer is a cursor: seat(i) points it at a roster member. Solo seats one actor and never moves the cursor, so the draw order off the single RNG stream is unchanged. That is the whole point. TestCombatCharacterization -- 57 scenarios x 5 seeds, 7468 pinned golden lines -- is byte-identical before and after. Solo combat provably did not move, so the d8prereq balance corpus survives the parties work and only party bands need new baselines in P7. Hold-person is fight-scoped (holding the enemy holds it for everyone) while stat_drain/debuff/max_hp_drain are per-character, which is why they landed on opposite sides of the split. No multi-actor *semantics* here: nothing yet decides who the enemy swings at or how initiative interleaves N players. That is P3. This commit only lands the data model, and the roster tests cover what the solo golden structurally cannot see -- cursor isolation, shared-state visibility across seats, and the pointer embed (a value embed would silently copy on seat() and fail the round-trip assertion).
This commit is contained in:
@@ -39,9 +39,23 @@ func TestEnemyAttackProfile_Registered(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// testCombatState seats a single actor, the shape every direct-primitive test
|
||||
// wants. The engine reads per-actor state through the embedded cursor, so a
|
||||
// combatState with a nil actor panics on the first st.playerHP touch.
|
||||
func testCombatState(playerHP, enemyHP, round int, rng *rand.Rand) *combatState {
|
||||
a := &actor{playerHP: playerHP}
|
||||
return &combatState{
|
||||
actor: a,
|
||||
actors: []*actor{a},
|
||||
enemyHP: enemyHP,
|
||||
round: round,
|
||||
rng: rng,
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnAbilityFires(t *testing.T) {
|
||||
enemy := baseEnemy() // MaxHP 60
|
||||
st := &combatState{rng: rand.New(rand.NewPCG(1, 1))}
|
||||
st := testCombatState(0, 0, 0, rand.New(rand.NewPCG(1, 1)))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -78,10 +92,7 @@ func TestTurnAbilityFires(t *testing.T) {
|
||||
// within applyAbility with no persistent state.
|
||||
func TestApplyAbility_Slice2Effects(t *testing.T) {
|
||||
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
|
||||
st := &combatState{
|
||||
playerHP: playerHP, enemyHP: enemyHP, round: 1,
|
||||
rng: rand.New(rand.NewPCG(7, 7)),
|
||||
}
|
||||
st := testCombatState(playerHP, enemyHP, 1, rand.New(rand.NewPCG(7, 7)))
|
||||
return st, basePlayer(), baseEnemy()
|
||||
}
|
||||
phase := &turnCombatPhase
|
||||
@@ -136,10 +147,7 @@ func TestApplyAbility_Slice2Effects(t *testing.T) {
|
||||
// all but evade), and the shared resolution primitives read that state.
|
||||
func TestApplyAbility_Slice3Effects(t *testing.T) {
|
||||
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
|
||||
st := &combatState{
|
||||
playerHP: playerHP, enemyHP: enemyHP, round: 1,
|
||||
rng: rand.New(rand.NewPCG(9, 9)),
|
||||
}
|
||||
st := testCombatState(playerHP, enemyHP, 1, rand.New(rand.NewPCG(9, 9)))
|
||||
return st, basePlayer(), baseEnemy()
|
||||
}
|
||||
phase := &turnCombatPhase
|
||||
@@ -188,7 +196,8 @@ func TestApplyAbility_Slice3Effects(t *testing.T) {
|
||||
}
|
||||
|
||||
// enemyDown lets survive_at_1 cheat death exactly once.
|
||||
stS := &combatState{enemyHP: 0, enemySurviveArmed: true}
|
||||
stS := testCombatState(100, 0, 1, rand.New(rand.NewPCG(13, 13)))
|
||||
stS.enemySurviveArmed = true
|
||||
if enemyDown(stS, "Duel") {
|
||||
t.Error("survive_at_1: armed enemy at 0 HP should not be down")
|
||||
}
|
||||
@@ -224,10 +233,7 @@ func TestApplyAbility_Slice3Effects(t *testing.T) {
|
||||
// itself, and the shared resolution primitives / helpers read that state.
|
||||
func TestApplyAbility_Slice4Effects(t *testing.T) {
|
||||
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
|
||||
st := &combatState{
|
||||
playerHP: playerHP, enemyHP: enemyHP, round: 1,
|
||||
rng: rand.New(rand.NewPCG(11, 11)),
|
||||
}
|
||||
st := testCombatState(playerHP, enemyHP, 1, rand.New(rand.NewPCG(11, 11)))
|
||||
return st, basePlayer(), baseEnemy()
|
||||
}
|
||||
phase := &turnCombatPhase
|
||||
|
||||
@@ -292,10 +292,23 @@ var bossCombatPhases = []CombatPhase{
|
||||
|
||||
// ── Simulation ───────────────────────────────────────────────────────────────
|
||||
|
||||
// combatState tracks mutable state during the simulation.
|
||||
type combatState struct {
|
||||
// actor is the per-combatant half of the simulation state — everything that
|
||||
// belongs to one player character rather than to the fight as a whole.
|
||||
//
|
||||
// It is embedded into combatState as a *pointer*, which promotes its fields:
|
||||
// `st.playerHP` still resolves, now to `st.actor.playerHP`. The embedded
|
||||
// pointer is a cursor naming whoever is currently resolving. Solo combat has a
|
||||
// one-element roster and never moves the cursor, so every read and every RNG
|
||||
// draw is bit-identical to the pre-roster engine — which is what keeps the
|
||||
// solo balance corpus (sim_results/d8prereq_corpus.jsonl lineage) valid.
|
||||
//
|
||||
// Field names are deliberately unchanged from when they lived on combatState.
|
||||
type actor struct {
|
||||
// c is the Combatant this state belongs to. Nil only in tests that drive
|
||||
// a primitive directly without a roster.
|
||||
c *Combatant
|
||||
|
||||
playerHP int
|
||||
enemyHP int
|
||||
|
||||
// Consumable one-shots
|
||||
healChargesLeft int // remaining heal-at-<50% triggers
|
||||
@@ -304,13 +317,10 @@ type combatState struct {
|
||||
reflectFrac float64
|
||||
autoCrit bool
|
||||
|
||||
// Monster ability effects
|
||||
// Monster ability effects landing on this actor
|
||||
poisonTicks int
|
||||
poisonDmg int
|
||||
stunPlayer bool
|
||||
enraged bool
|
||||
armorBroken bool
|
||||
armorBreakAmt float64
|
||||
|
||||
// Sovereign reprieve
|
||||
deathSaveUsed bool
|
||||
@@ -320,10 +330,6 @@ type combatState struct {
|
||||
raged bool // Orc Rage already triggered this fight
|
||||
pendingRageAttack bool // next player attack gets +50% damage
|
||||
|
||||
// Phase 9 spell — enemy skip-first-attack (consumed on the first round
|
||||
// the enemy would otherwise attack).
|
||||
enemySkipFirst bool
|
||||
|
||||
// Phase 10 SUB2a-ii first-attack one-shots.
|
||||
firstAttackBonusUsed bool
|
||||
assassinateRerollUsed bool
|
||||
@@ -332,27 +338,52 @@ type combatState struct {
|
||||
// Phase 10 SUB2b — Abjuration Arcane Ward HP buffer.
|
||||
arcaneWardHP int
|
||||
|
||||
// Debuffs the enemy has stacked onto this actor specifically.
|
||||
playerAtkDrain int // stat_drain: flat reduction to this actor's hit damage
|
||||
playerACDebuff int // debuff: flat reduction to this actor's effective AC
|
||||
maxHPDrain int // max_hp_drain: reduction to this actor's effective MaxHP
|
||||
|
||||
// concentrationDmg — per-round damage of an active concentration AOE
|
||||
// (Spirit Guardians et al.). Armed by a !cast of a concentration damage
|
||||
// spell, ticked against the enemy every round_end until the fight ends
|
||||
// or another concentration spell overwrites it. Only the turn engine
|
||||
// reads it; SimulateCombat resolves whole fights in one pass and folds
|
||||
// the aura's value into the picker's concentration multiplier instead.
|
||||
// (Spirit Guardians et al.). Concentration is per-caster, so it lives
|
||||
// here rather than on the fight.
|
||||
concentrationDmg int
|
||||
}
|
||||
|
||||
// combatState tracks mutable state during the simulation. The embedded *actor
|
||||
// is the cursor: the player character currently resolving. Everything declared
|
||||
// directly on combatState is shared by the whole fight — the enemy, the round
|
||||
// counter, the event log, and the RNG stream.
|
||||
type combatState struct {
|
||||
*actor // cursor into actors; promotes the per-actor fields
|
||||
actors []*actor // the player roster, in seating order. len == 1 for solo.
|
||||
|
||||
enemyHP int
|
||||
|
||||
// Monster ability effects (enemy-side stance — shared across the roster)
|
||||
enraged bool
|
||||
armorBroken bool
|
||||
armorBreakAmt float64
|
||||
|
||||
// Phase 9 spell — enemy skip-first-attack (consumed on the first round
|
||||
// the enemy would otherwise attack). Holding the enemy holds it for
|
||||
// everyone, so this is fight-scoped, not per-caster.
|
||||
enemySkipFirst bool
|
||||
|
||||
// Phase 13 bestiary slice 3 — stateful monster-ability effects. Each is
|
||||
// armed by applyAbility and read by the shared resolution primitives, so
|
||||
// both engines honour them; the turn-based engine additionally round-trips
|
||||
// them through CombatStatuses so they survive a suspend/resume.
|
||||
//
|
||||
// These describe the *enemy's* stance, so they are fight-scoped: an enemy
|
||||
// holding a parry stance parries the next swing from anyone. The debuffs
|
||||
// it stacks onto a specific character (stat_drain / debuff / max_hp_drain)
|
||||
// live on actor instead.
|
||||
enemyEvadeNext bool // evade: next player weapon attack auto-misses
|
||||
enemyBlockUp bool // block: enemy holds a parry stance (~50% block on player hits)
|
||||
enemyAdvantage bool // advantage: enemy rolls its attacks with advantage
|
||||
enemyRetaliateFrac float64 // retaliate: fraction of a player hit reflected back
|
||||
enemyRegen int // regenerate: enemy heals this much each round end
|
||||
enemySurviveArmed bool // survive_at_1: enemy cheats death once, dropping to 1 HP
|
||||
playerAtkDrain int // stat_drain: flat reduction to the player's hit damage
|
||||
playerACDebuff int // debuff: flat reduction to the player's effective AC
|
||||
maxHPDrain int // max_hp_drain: reduction to the player's effective MaxHP
|
||||
|
||||
// Phase 13 bestiary slice 4 — the former flavor-only placeholders, now
|
||||
// backed by real state.
|
||||
@@ -377,6 +408,47 @@ type combatState struct {
|
||||
// Auto-resolve leaves st.rng nil — behaviorally identical to the
|
||||
// pre-injection code; the turn-based engine and the timeout reaper seed
|
||||
// it per session so a fight can be resumed and replayed reproducibly.
|
||||
// newActor seats one player character, deriving its opening per-fight state
|
||||
// from that character's modifiers.
|
||||
func newActor(c *Combatant) *actor {
|
||||
startHP := c.Stats.MaxHP
|
||||
if c.Stats.StartHP > 0 && c.Stats.StartHP < c.Stats.MaxHP {
|
||||
startHP = c.Stats.StartHP
|
||||
}
|
||||
a := &actor{
|
||||
c: c,
|
||||
playerHP: startHP,
|
||||
wardCharges: c.Mods.WardCharges,
|
||||
sporeRounds: c.Mods.SporeCloud,
|
||||
reflectFrac: c.Mods.ReflectNext,
|
||||
autoCrit: c.Mods.AutoCritFirst,
|
||||
arcaneWardHP: c.Mods.ArcaneWardHP,
|
||||
}
|
||||
// HealItemCharges: explicit count overrides legacy one-shot. Backfill
|
||||
// to 1 charge if the caller set a HealItem amount but no count.
|
||||
a.healChargesLeft = c.Mods.HealItemCharges
|
||||
if a.healChargesLeft == 0 && c.Mods.HealItem > 0 {
|
||||
a.healChargesLeft = 1
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// seat points the cursor at roster index i. Every per-actor read in the
|
||||
// resolution primitives (st.playerHP, st.wardCharges, …) follows the cursor.
|
||||
// Solo combat seats index 0 once and never moves it.
|
||||
func (st *combatState) seat(i int) { st.actor = st.actors[i] }
|
||||
|
||||
// anyAlive reports whether at least one seated character is still standing.
|
||||
// Solo fights read this as "the player is alive".
|
||||
func (st *combatState) anyAlive() bool {
|
||||
for _, a := range st.actors {
|
||||
if a.playerHP > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (st *combatState) roll(n int) int { return rngIntN(st.rng, n) }
|
||||
func (st *combatState) randFloat() float64 { return rngFloat(st.rng) }
|
||||
|
||||
@@ -398,23 +470,18 @@ func simulateCombatWithRNG(player, enemy Combatant, phases []CombatPhase, rng *r
|
||||
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
|
||||
enemyStart = enemy.Stats.StartHP
|
||||
}
|
||||
// Solo roster: one seat, cursor parked on it for the whole fight. The
|
||||
// resolution primitives read the cursor, so this is the degenerate case
|
||||
// of the N-player model and draws RNG in exactly the pre-roster order.
|
||||
seat0 := newActor(&player)
|
||||
seat0.playerHP = playerStart
|
||||
st := &combatState{
|
||||
playerHP: playerStart,
|
||||
actor: seat0,
|
||||
actors: []*actor{seat0},
|
||||
enemyHP: enemyStart,
|
||||
wardCharges: player.Mods.WardCharges,
|
||||
sporeRounds: player.Mods.SporeCloud,
|
||||
reflectFrac: player.Mods.ReflectNext,
|
||||
autoCrit: player.Mods.AutoCritFirst,
|
||||
enemySkipFirst: player.Mods.SpellEnemySkipFirst,
|
||||
arcaneWardHP: player.Mods.ArcaneWardHP,
|
||||
rng: rng,
|
||||
}
|
||||
// HealItemCharges: explicit count overrides legacy one-shot. Backfill
|
||||
// to 1 charge if the caller set a HealItem amount but no count.
|
||||
st.healChargesLeft = player.Mods.HealItemCharges
|
||||
if st.healChargesLeft == 0 && player.Mods.HealItem > 0 {
|
||||
st.healChargesLeft = 1
|
||||
}
|
||||
|
||||
result := CombatResult{
|
||||
PlayerStartHP: player.Stats.MaxHP,
|
||||
|
||||
153
internal/plugin/combat_roster_test.go
Normal file
153
internal/plugin/combat_roster_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package plugin
|
||||
|
||||
// Roster/cursor tests for the N-player combat state (N3/P2).
|
||||
//
|
||||
// The solo path is pinned bit-for-bit by TestCombatCharacterization; these
|
||||
// cover the machinery that pin cannot see, because solo never moves the
|
||||
// cursor off seat 0.
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewActor_DerivesPerFightStateFromMods(t *testing.T) {
|
||||
c := basePlayer()
|
||||
c.Mods.WardCharges = 2
|
||||
c.Mods.SporeCloud = 3
|
||||
c.Mods.ReflectNext = 0.5
|
||||
c.Mods.AutoCritFirst = true
|
||||
c.Mods.ArcaneWardHP = 25
|
||||
|
||||
a := newActor(&c)
|
||||
|
||||
if a.c != &c {
|
||||
t.Error("actor should point back at its Combatant")
|
||||
}
|
||||
if a.playerHP != c.Stats.MaxHP {
|
||||
t.Errorf("playerHP = %d, want MaxHP %d", a.playerHP, c.Stats.MaxHP)
|
||||
}
|
||||
if a.wardCharges != 2 || a.sporeRounds != 3 || a.reflectFrac != 0.5 || !a.autoCrit || a.arcaneWardHP != 25 {
|
||||
t.Errorf("consumable one-shots not carried from Mods: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewActor_StartHPAndHealChargeBackfill(t *testing.T) {
|
||||
// StartHP below MaxHP means the character walks in wounded.
|
||||
wounded := basePlayer()
|
||||
wounded.Stats.StartHP = 40
|
||||
if got := newActor(&wounded).playerHP; got != 40 {
|
||||
t.Errorf("wounded entry HP = %d, want 40", got)
|
||||
}
|
||||
|
||||
// StartHP at or above MaxHP is ignored (guards a stale snapshot).
|
||||
full := basePlayer()
|
||||
full.Stats.StartHP = 999
|
||||
if got := newActor(&full).playerHP; got != full.Stats.MaxHP {
|
||||
t.Errorf("StartHP above MaxHP should not raise entry HP, got %d", got)
|
||||
}
|
||||
|
||||
// Legacy one-shot: a HealItem amount with no explicit count backfills to 1.
|
||||
legacy := basePlayer()
|
||||
legacy.Mods.HealItem = 30
|
||||
if got := newActor(&legacy).healChargesLeft; got != 1 {
|
||||
t.Errorf("legacy heal backfill = %d charges, want 1", got)
|
||||
}
|
||||
|
||||
// An explicit count wins over the backfill.
|
||||
stocked := basePlayer()
|
||||
stocked.Mods.HealItem = 30
|
||||
stocked.Mods.HealItemCharges = 4
|
||||
if got := newActor(&stocked).healChargesLeft; got != 4 {
|
||||
t.Errorf("explicit heal charges = %d, want 4", got)
|
||||
}
|
||||
|
||||
// No HealItem at all means no charges, even if a count leaked through.
|
||||
none := basePlayer()
|
||||
if got := newActor(&none).healChargesLeft; got != 0 {
|
||||
t.Errorf("no heal item should mean 0 charges, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The cursor is the whole point of the embed: per-actor fields must follow
|
||||
// seat(), and fight-scoped fields must not.
|
||||
func TestCombatState_SeatSwitchesPerActorStateOnly(t *testing.T) {
|
||||
alice, bob := basePlayer(), basePlayer()
|
||||
alice.Name, bob.Name = "Alice", "Bob"
|
||||
a0, a1 := newActor(&alice), newActor(&bob)
|
||||
|
||||
st := &combatState{
|
||||
actor: a0,
|
||||
actors: []*actor{a0, a1},
|
||||
enemyHP: 100,
|
||||
rng: rand.New(rand.NewPCG(1, 1)),
|
||||
}
|
||||
|
||||
// Wound seat 0 and burn one of its once-per-fight one-shots.
|
||||
st.seat(0)
|
||||
st.playerHP = 10
|
||||
st.luckyUsed = true
|
||||
|
||||
// Seat 1 must be untouched — a promoted write goes to the cursor, not the
|
||||
// struct. This is the assertion that would fail if actor were embedded by
|
||||
// value instead of by pointer.
|
||||
st.seat(1)
|
||||
if st.playerHP != bob.Stats.MaxHP {
|
||||
t.Errorf("seat 1 HP = %d, want a full pool %d — seat 0's wound leaked", st.playerHP, bob.Stats.MaxHP)
|
||||
}
|
||||
if st.luckyUsed {
|
||||
t.Error("seat 1 saw seat 0's consumed Lucky reroll")
|
||||
}
|
||||
if st.c.Name != "Bob" {
|
||||
t.Errorf("cursor Combatant = %q, want Bob", st.c.Name)
|
||||
}
|
||||
|
||||
// Fight-scoped state is shared: writing it under one seat is visible
|
||||
// under the other.
|
||||
st.enemyHP = 42
|
||||
st.enemyBlockUp = true
|
||||
st.seat(0)
|
||||
if st.playerHP != 10 || !st.luckyUsed {
|
||||
t.Error("seat 0 lost its own state across a cursor round-trip")
|
||||
}
|
||||
if st.enemyHP != 42 || !st.enemyBlockUp {
|
||||
t.Error("enemy stance should be fight-scoped, not per-actor")
|
||||
}
|
||||
if st.c.Name != "Alice" {
|
||||
t.Errorf("cursor Combatant = %q, want Alice", st.c.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombatState_AnyAlive(t *testing.T) {
|
||||
alice, bob := basePlayer(), basePlayer()
|
||||
a0, a1 := newActor(&alice), newActor(&bob)
|
||||
st := &combatState{actor: a0, actors: []*actor{a0, a1}}
|
||||
|
||||
if !st.anyAlive() {
|
||||
t.Error("a fresh roster should be alive")
|
||||
}
|
||||
a0.playerHP = 0
|
||||
if !st.anyAlive() {
|
||||
t.Error("one downed member should not end the fight while another stands")
|
||||
}
|
||||
a1.playerHP = 0
|
||||
if st.anyAlive() {
|
||||
t.Error("a fully downed roster should not be alive")
|
||||
}
|
||||
}
|
||||
|
||||
// Solo fights seat exactly one actor and park the cursor on it. If this ever
|
||||
// regresses, the characterization golden stops proving anything about the
|
||||
// production auto-resolve path.
|
||||
func TestSimulateCombat_SeatsExactlyOneActor(t *testing.T) {
|
||||
p, e := basePlayer(), baseEnemy()
|
||||
seat0 := newActor(&p)
|
||||
st := &combatState{actor: seat0, actors: []*actor{seat0}, enemyHP: e.Stats.MaxHP}
|
||||
|
||||
if len(st.actors) != 1 {
|
||||
t.Fatalf("solo roster length = %d, want 1", len(st.actors))
|
||||
}
|
||||
if st.actor != st.actors[0] {
|
||||
t.Error("solo cursor must point at seat 0")
|
||||
}
|
||||
}
|
||||
@@ -111,17 +111,15 @@ func phaseOrdinal(phase string) uint64 {
|
||||
// resumeTurnEngine rebuilds the in-memory combatState from a persisted session.
|
||||
// rng is the deterministic source for this step (see combatSessionRNG).
|
||||
func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.Rand) *turnEngine {
|
||||
st := &combatState{
|
||||
// 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.
|
||||
seat0 := &actor{
|
||||
c: player,
|
||||
playerHP: sess.PlayerHP,
|
||||
enemyHP: sess.EnemyHP,
|
||||
round: sess.Round,
|
||||
poisonTicks: sess.Statuses.PoisonTicks,
|
||||
poisonDmg: sess.Statuses.PoisonDmg,
|
||||
stunPlayer: sess.Statuses.StunPlayer,
|
||||
enraged: sess.Statuses.Enraged,
|
||||
armorBroken: sess.Statuses.ArmorBroken,
|
||||
armorBreakAmt: sess.Statuses.ArmorBreakAmt,
|
||||
enemySkipFirst: sess.Statuses.EnemySkipNext,
|
||||
// Fight-scoped depleting resources + once-per-fight one-shots: restored
|
||||
// from the persisted statuses so a charge or "already used" flag can't
|
||||
// reset across a suspend/resume. commit writes the updated values back.
|
||||
@@ -139,6 +137,20 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
|
||||
firstAttackBonusUsed: sess.Statuses.FirstAtkBonusUsed,
|
||||
assassinateRerollUsed: sess.Statuses.AssassinateReroll,
|
||||
assassinateBonusUsed: sess.Statuses.AssassinateBonus,
|
||||
// Enemy debuffs stacked onto this character specifically.
|
||||
playerAtkDrain: sess.Statuses.PlayerAtkDrain,
|
||||
playerACDebuff: sess.Statuses.PlayerACDebuff,
|
||||
maxHPDrain: sess.Statuses.MaxHPDrain,
|
||||
}
|
||||
st := &combatState{
|
||||
actor: seat0,
|
||||
actors: []*actor{seat0},
|
||||
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,
|
||||
@@ -147,9 +159,6 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
|
||||
enemyRetaliateFrac: sess.Statuses.EnemyRetaliateFrac,
|
||||
enemyRegen: sess.Statuses.EnemyRegen,
|
||||
enemySurviveArmed: sess.Statuses.EnemySurviveArmed,
|
||||
playerAtkDrain: sess.Statuses.PlayerAtkDrain,
|
||||
playerACDebuff: sess.Statuses.PlayerACDebuff,
|
||||
maxHPDrain: sess.Statuses.MaxHPDrain,
|
||||
// Slice-4 monster-ability effects — the former flavor-only placeholders.
|
||||
enemySpellResist: sess.Statuses.EnemySpellResist,
|
||||
enemyRevealNext: sess.Statuses.EnemyRevealNext,
|
||||
|
||||
Reference in New Issue
Block a user