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) {
|
func TestTurnAbilityFires(t *testing.T) {
|
||||||
enemy := baseEnemy() // MaxHP 60
|
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 {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -78,10 +92,7 @@ func TestTurnAbilityFires(t *testing.T) {
|
|||||||
// within applyAbility with no persistent state.
|
// within applyAbility with no persistent state.
|
||||||
func TestApplyAbility_Slice2Effects(t *testing.T) {
|
func TestApplyAbility_Slice2Effects(t *testing.T) {
|
||||||
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
|
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
|
||||||
st := &combatState{
|
st := testCombatState(playerHP, enemyHP, 1, rand.New(rand.NewPCG(7, 7)))
|
||||||
playerHP: playerHP, enemyHP: enemyHP, round: 1,
|
|
||||||
rng: rand.New(rand.NewPCG(7, 7)),
|
|
||||||
}
|
|
||||||
return st, basePlayer(), baseEnemy()
|
return st, basePlayer(), baseEnemy()
|
||||||
}
|
}
|
||||||
phase := &turnCombatPhase
|
phase := &turnCombatPhase
|
||||||
@@ -136,10 +147,7 @@ func TestApplyAbility_Slice2Effects(t *testing.T) {
|
|||||||
// all but evade), and the shared resolution primitives read that state.
|
// all but evade), and the shared resolution primitives read that state.
|
||||||
func TestApplyAbility_Slice3Effects(t *testing.T) {
|
func TestApplyAbility_Slice3Effects(t *testing.T) {
|
||||||
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
|
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
|
||||||
st := &combatState{
|
st := testCombatState(playerHP, enemyHP, 1, rand.New(rand.NewPCG(9, 9)))
|
||||||
playerHP: playerHP, enemyHP: enemyHP, round: 1,
|
|
||||||
rng: rand.New(rand.NewPCG(9, 9)),
|
|
||||||
}
|
|
||||||
return st, basePlayer(), baseEnemy()
|
return st, basePlayer(), baseEnemy()
|
||||||
}
|
}
|
||||||
phase := &turnCombatPhase
|
phase := &turnCombatPhase
|
||||||
@@ -188,7 +196,8 @@ func TestApplyAbility_Slice3Effects(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// enemyDown lets survive_at_1 cheat death exactly once.
|
// 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") {
|
if enemyDown(stS, "Duel") {
|
||||||
t.Error("survive_at_1: armed enemy at 0 HP should not be down")
|
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.
|
// itself, and the shared resolution primitives / helpers read that state.
|
||||||
func TestApplyAbility_Slice4Effects(t *testing.T) {
|
func TestApplyAbility_Slice4Effects(t *testing.T) {
|
||||||
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
|
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
|
||||||
st := &combatState{
|
st := testCombatState(playerHP, enemyHP, 1, rand.New(rand.NewPCG(11, 11)))
|
||||||
playerHP: playerHP, enemyHP: enemyHP, round: 1,
|
|
||||||
rng: rand.New(rand.NewPCG(11, 11)),
|
|
||||||
}
|
|
||||||
return st, basePlayer(), baseEnemy()
|
return st, basePlayer(), baseEnemy()
|
||||||
}
|
}
|
||||||
phase := &turnCombatPhase
|
phase := &turnCombatPhase
|
||||||
|
|||||||
@@ -292,10 +292,23 @@ var bossCombatPhases = []CombatPhase{
|
|||||||
|
|
||||||
// ── Simulation ───────────────────────────────────────────────────────────────
|
// ── Simulation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// combatState tracks mutable state during the simulation.
|
// actor is the per-combatant half of the simulation state — everything that
|
||||||
type combatState struct {
|
// 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
|
playerHP int
|
||||||
enemyHP int
|
|
||||||
|
|
||||||
// Consumable one-shots
|
// Consumable one-shots
|
||||||
healChargesLeft int // remaining heal-at-<50% triggers
|
healChargesLeft int // remaining heal-at-<50% triggers
|
||||||
@@ -304,13 +317,10 @@ type combatState struct {
|
|||||||
reflectFrac float64
|
reflectFrac float64
|
||||||
autoCrit bool
|
autoCrit bool
|
||||||
|
|
||||||
// Monster ability effects
|
// Monster ability effects landing on this actor
|
||||||
poisonTicks int
|
poisonTicks int
|
||||||
poisonDmg int
|
poisonDmg int
|
||||||
stunPlayer bool
|
stunPlayer bool
|
||||||
enraged bool
|
|
||||||
armorBroken bool
|
|
||||||
armorBreakAmt float64
|
|
||||||
|
|
||||||
// Sovereign reprieve
|
// Sovereign reprieve
|
||||||
deathSaveUsed bool
|
deathSaveUsed bool
|
||||||
@@ -320,10 +330,6 @@ type combatState struct {
|
|||||||
raged bool // Orc Rage already triggered this fight
|
raged bool // Orc Rage already triggered this fight
|
||||||
pendingRageAttack bool // next player attack gets +50% damage
|
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.
|
// Phase 10 SUB2a-ii first-attack one-shots.
|
||||||
firstAttackBonusUsed bool
|
firstAttackBonusUsed bool
|
||||||
assassinateRerollUsed bool
|
assassinateRerollUsed bool
|
||||||
@@ -332,27 +338,52 @@ type combatState struct {
|
|||||||
// Phase 10 SUB2b — Abjuration Arcane Ward HP buffer.
|
// Phase 10 SUB2b — Abjuration Arcane Ward HP buffer.
|
||||||
arcaneWardHP int
|
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
|
// concentrationDmg — per-round damage of an active concentration AOE
|
||||||
// (Spirit Guardians et al.). Armed by a !cast of a concentration damage
|
// (Spirit Guardians et al.). Concentration is per-caster, so it lives
|
||||||
// spell, ticked against the enemy every round_end until the fight ends
|
// here rather than on the fight.
|
||||||
// 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.
|
|
||||||
concentrationDmg int
|
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
|
// Phase 13 bestiary slice 3 — stateful monster-ability effects. Each is
|
||||||
// armed by applyAbility and read by the shared resolution primitives, so
|
// armed by applyAbility and read by the shared resolution primitives, so
|
||||||
// both engines honour them; the turn-based engine additionally round-trips
|
// both engines honour them; the turn-based engine additionally round-trips
|
||||||
// them through CombatStatuses so they survive a suspend/resume.
|
// 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
|
enemyEvadeNext bool // evade: next player weapon attack auto-misses
|
||||||
enemyBlockUp bool // block: enemy holds a parry stance (~50% block on player hits)
|
enemyBlockUp bool // block: enemy holds a parry stance (~50% block on player hits)
|
||||||
enemyAdvantage bool // advantage: enemy rolls its attacks with advantage
|
enemyAdvantage bool // advantage: enemy rolls its attacks with advantage
|
||||||
enemyRetaliateFrac float64 // retaliate: fraction of a player hit reflected back
|
enemyRetaliateFrac float64 // retaliate: fraction of a player hit reflected back
|
||||||
enemyRegen int // regenerate: enemy heals this much each round end
|
enemyRegen int // regenerate: enemy heals this much each round end
|
||||||
enemySurviveArmed bool // survive_at_1: enemy cheats death once, dropping to 1 HP
|
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
|
// Phase 13 bestiary slice 4 — the former flavor-only placeholders, now
|
||||||
// backed by real state.
|
// backed by real state.
|
||||||
@@ -377,6 +408,47 @@ type combatState struct {
|
|||||||
// Auto-resolve leaves st.rng nil — behaviorally identical to the
|
// Auto-resolve leaves st.rng nil — behaviorally identical to the
|
||||||
// pre-injection code; the turn-based engine and the timeout reaper seed
|
// pre-injection code; the turn-based engine and the timeout reaper seed
|
||||||
// it per session so a fight can be resumed and replayed reproducibly.
|
// 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) roll(n int) int { return rngIntN(st.rng, n) }
|
||||||
func (st *combatState) randFloat() float64 { return rngFloat(st.rng) }
|
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 {
|
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
|
||||||
enemyStart = enemy.Stats.StartHP
|
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{
|
st := &combatState{
|
||||||
playerHP: playerStart,
|
actor: seat0,
|
||||||
|
actors: []*actor{seat0},
|
||||||
enemyHP: enemyStart,
|
enemyHP: enemyStart,
|
||||||
wardCharges: player.Mods.WardCharges,
|
|
||||||
sporeRounds: player.Mods.SporeCloud,
|
|
||||||
reflectFrac: player.Mods.ReflectNext,
|
|
||||||
autoCrit: player.Mods.AutoCritFirst,
|
|
||||||
enemySkipFirst: player.Mods.SpellEnemySkipFirst,
|
enemySkipFirst: player.Mods.SpellEnemySkipFirst,
|
||||||
arcaneWardHP: player.Mods.ArcaneWardHP,
|
|
||||||
rng: rng,
|
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{
|
result := CombatResult{
|
||||||
PlayerStartHP: player.Stats.MaxHP,
|
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.
|
// resumeTurnEngine rebuilds the in-memory combatState from a persisted session.
|
||||||
// rng is the deterministic source for this step (see combatSessionRNG).
|
// rng is the deterministic source for this step (see combatSessionRNG).
|
||||||
func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.Rand) *turnEngine {
|
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,
|
playerHP: sess.PlayerHP,
|
||||||
enemyHP: sess.EnemyHP,
|
|
||||||
round: sess.Round,
|
|
||||||
poisonTicks: sess.Statuses.PoisonTicks,
|
poisonTicks: sess.Statuses.PoisonTicks,
|
||||||
poisonDmg: sess.Statuses.PoisonDmg,
|
poisonDmg: sess.Statuses.PoisonDmg,
|
||||||
stunPlayer: sess.Statuses.StunPlayer,
|
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
|
// 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.
|
||||||
@@ -139,6 +137,20 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
|
|||||||
firstAttackBonusUsed: sess.Statuses.FirstAtkBonusUsed,
|
firstAttackBonusUsed: sess.Statuses.FirstAtkBonusUsed,
|
||||||
assassinateRerollUsed: sess.Statuses.AssassinateReroll,
|
assassinateRerollUsed: sess.Statuses.AssassinateReroll,
|
||||||
assassinateBonusUsed: sess.Statuses.AssassinateBonus,
|
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,
|
// Slice-3 stateful monster-ability effects — armed by applyAbility,
|
||||||
// round-tripped here so they survive a suspend/resume or reaper auto-play.
|
// round-tripped here so they survive a suspend/resume or reaper auto-play.
|
||||||
enemyEvadeNext: sess.Statuses.EnemyEvadeNext,
|
enemyEvadeNext: sess.Statuses.EnemyEvadeNext,
|
||||||
@@ -147,9 +159,6 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
|
|||||||
enemyRetaliateFrac: sess.Statuses.EnemyRetaliateFrac,
|
enemyRetaliateFrac: sess.Statuses.EnemyRetaliateFrac,
|
||||||
enemyRegen: sess.Statuses.EnemyRegen,
|
enemyRegen: sess.Statuses.EnemyRegen,
|
||||||
enemySurviveArmed: sess.Statuses.EnemySurviveArmed,
|
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.
|
// Slice-4 monster-ability effects — the former flavor-only placeholders.
|
||||||
enemySpellResist: sess.Statuses.EnemySpellResist,
|
enemySpellResist: sess.Statuses.EnemySpellResist,
|
||||||
enemyRevealNext: sess.Statuses.EnemyRevealNext,
|
enemyRevealNext: sess.Statuses.EnemyRevealNext,
|
||||||
|
|||||||
Reference in New Issue
Block a user