mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +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
|
||||
|
||||
Reference in New Issue
Block a user