Files
gogobee/internal/plugin/combat_engine_party_test.go
prosolis 055f07d3c0 Combat engine §2(a): the enemy charges for what a seat brings
Both scaling levers counted seats. partyEnemyHPScale gave +15% boss HP for any
roster >= 2, and partyActionExpectation lifted the enemy from 1 to 2.4 attacks a
round. A seat COUNT charges the same for an under-levelled friend, a hired NPC,
and a true peer — so a below-median body cost a full seat's worth of boss and did
not give a full seat's worth back.

Measured, once the companion's free full-heal was taken away and he became honest:
hiring him was WORSE than going alone (66.1% against solo's 69.0%). That is this
bug, and it has been live for every under-levelled friend anyone has ever invited.

Seats now carry a SeatWeight, and both levers scale on the summed weight of the
LIVING seats rather than on a head count. The weight is level-based, priced against
the leader, times a discount for a hireling (no subclass, no magic items, gear that
is never Masterwork — the layers a player accrues and a hireling never will).

Level, and deliberately not a power score: an HP-x-damage proxy would rank a cleric
below a fighter and quietly make every mixed HUMAN party easier, which is a
difficulty regression smuggled in under a bug fix.

The safety argument is one property: **a peer weighs exactly 1.0**. So the curves
interpolate between the integer knots the P8 sweep tuned — (1, 1.0), (2, 2.4),
2n-1 from 3 up — and every integer input returns exactly what it always returned.
Solo is byte-identical, a party of same-level humans is byte-identical, both
goldens hold unmoved, and only an UNEQUAL roster lands between the knots. That is
the entire point of the change.

It also finishes §2(b): a seat that is down now buys the enemy nothing. §2(b) fixed
the head-count half; a corpse still carried its full weight until this.

Measured, 640 runs/arm, same grid:

  solo                    69.0%   (unchanged — corpus intact)
  + Pete                  76.8%   (+7.8pp)
  + a human cleric peer   77.6%   (+8.6pp)

  band                 solo    +Pete     lift
  trailing (<40%)     10.0%    31.0%   +21.0pp
  middle              58.9%    76.8%   +17.9pp
  leading (>=70%)     93.5%    99.2%    +5.7pp

Help, never a carry: he rescues the players who were drowning and barely moves the
ones who were already fine — and he stays below a real human of the leader's level,
which is the invariant a hireling must never break.

Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
2026-07-11 15:20:49 -07:00

287 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"math/rand/v2"
"testing"
)
// seededRNG gives each test its own deterministic stream, so none of these can
// join the flaky set that rode the package global before P4 seeded them.
func seededRNG(seed uint64) *rand.Rand {
return rand.New(rand.NewPCG(seed, seed^0x9e3779b9))
}
// The load-bearing claim of P6e: a one-seat roster draws from the RNG in exactly
// the pre-roster order, so SimulateCombat is the degenerate case of the N-body
// engine and the d8prereq_corpus baselines still compare. TestCombatCharacterization
// is the real proof (57 scenarios); this pins the delegation itself.
func TestSimulateCombat_IsTheOneSeatPartyCase(t *testing.T) {
for seed := uint64(1); seed <= 40; seed++ {
solo := simulateCombatWithRNG(basePlayer(), baseEnemy(), dungeonCombatPhases, seededRNG(seed))
party := simulatePartyWithRNG([]Combatant{basePlayer()}, baseEnemy(), dungeonCombatPhases, seededRNG(seed))
if len(party.Seats) != 1 {
t.Fatalf("seed %d: one player seated %d seats", seed, len(party.Seats))
}
got := party.Seats[0]
if got.PlayerWon != solo.PlayerWon || got.PlayerEndHP != solo.PlayerEndHP ||
got.EnemyEndHP != solo.EnemyEndHP || got.TotalRounds != solo.TotalRounds ||
got.TimedOut != solo.TimedOut {
t.Fatalf("seed %d: one-seat party diverged from solo\n solo=%+v\nparty=%+v", seed, solo, got)
}
if len(got.Events) != len(solo.Events) {
t.Fatalf("seed %d: event count %d != solo %d", seed, len(got.Events), len(solo.Events))
}
for i := range solo.Events {
if got.Events[i] != solo.Events[i] {
t.Fatalf("seed %d: event %d differs\n solo=%+v\nparty=%+v", seed, i, solo.Events[i], got.Events[i])
}
}
}
}
// A solo fight never stamps a seat, because seat 0 is the zero value and the
// field is omitempty. If this regresses, the golden file moves.
func TestSimulateCombat_SoloEventsCarryNoSeat(t *testing.T) {
res := simulateCombatWithRNG(basePlayer(), baseEnemy(), dungeonCombatPhases, seededRNG(7))
for i, e := range res.Events {
if e.Seat != 0 {
t.Fatalf("event %d (%s/%s) stamped seat %d in a solo fight", i, e.Phase, e.Action, e.Seat)
}
}
}
// The whole point of P6e. Three characters swinging at one monster must land more
// player attacks per round than one character does — before this, the members
// stood in the doorway while the leader fought.
func TestSimulateParty_EverySeatSwings(t *testing.T) {
tank := baseEnemy()
tank.Stats.MaxHP = 5000 // outlast the phase clock so we can count swings
countSwings := func(events []CombatEvent, seat int) int {
n := 0
for _, e := range events {
if e.Actor == "player" && e.Seat == seat && e.Roll > 0 {
n++
}
}
return n
}
res := simulatePartyWithRNG(
[]Combatant{basePlayer(), basePlayer(), basePlayer()}, tank, dungeonCombatPhases, seededRNG(11))
if len(res.Seats) != 3 {
t.Fatalf("seated %d, want 3", len(res.Seats))
}
for seat := range 3 {
if got := countSwings(res.Events, seat); got == 0 {
t.Fatalf("seat %d never swung — the party is not fighting together", seat)
}
}
}
// A member going down is not the end of the fight. Only an empty roster is.
// This is the bug P7 measured from the outside: every party loss was the leader
// dying alone, because a member was never in the fight to begin with.
func TestSimulateParty_DownedMemberDoesNotEndTheFight(t *testing.T) {
glass := basePlayer()
glass.Stats.MaxHP = 1
glass.Stats.Defense = 0
brute := basePlayer()
brute.Stats.MaxHP = 4000
killer := baseEnemy()
killer.Stats.MaxHP = 3000
killer.Stats.Attack = 40
res := simulatePartyWithRNG([]Combatant{brute, glass}, killer, dungeonCombatPhases, seededRNG(3))
if res.Seats[1].PlayerEndHP > 0 {
t.Skip("the glass cannon survived this seed; nothing to assert")
}
if !res.AnySurvivor() {
t.Fatalf("both seats down — pick a seed where the brute lives")
}
if res.TotalRounds <= 1 {
t.Fatalf("fight ended in %d round(s) when a member fell; the roster should have fought on",
res.TotalRounds)
}
// The brute must have kept swinging after the member fell.
lastMemberEvent, lastBruteEvent := -1, -1
for i, e := range res.Events {
if e.Seat == 1 && e.Actor == "player" {
lastMemberEvent = i
}
if e.Seat == 0 && e.Actor == "player" && e.Roll > 0 {
lastBruteEvent = i
}
}
if lastBruteEvent < lastMemberEvent {
t.Fatalf("the fight stopped when seat 1 fell (last brute swing %d, last member event %d)",
lastBruteEvent, lastMemberEvent)
}
}
// P8 scaling constants: solo is exempt on both levers (1 action, ×1.0 HP) so the
// characterization golden and the d8prereq corpus are untouched. A party's action
// budget is a fractional expectation — 2.4 for a duo (so it lands between soloing
// and a trio, where an integer 2 vs 3 has no room), 2N1 for N≥3 — plus ×1.15
// enemy HP.
func TestP8PartyScaling_SoloExemptPartyScaled(t *testing.T) {
if got := partyActionExpectation(1); got != 1 {
t.Fatalf("solo action expectation = %v, want 1", got)
}
if got := partyEnemyHPScale(1); got != 1.0 {
t.Fatalf("solo HP scale = %v, want 1.0", got)
}
if got := scaledEnemyMaxHP(200, 1); got != 200 {
t.Fatalf("solo enemy HP = %d, want 200 (unscaled)", got)
}
if got := partyActionExpectation(2); got != 2.4 {
t.Fatalf("duo action expectation = %v, want 2.4", got)
}
// The curve now takes a fractional weight rather than a head count, so that a
// below-median seat costs the enemy less than a peer does. Every INTEGER input
// must still return exactly what it always returned — that is what keeps solo
// and a party of peers byte-identical, and the balance corpus with them.
for n := 3; n <= 5; n++ {
if got, want := partyActionExpectation(float64(n)), float64(2*n-1); got != want {
t.Fatalf("party of %d: action expectation = %v, want %v", n, got, want)
}
}
// A duo carrying a half-strength body sits between soloing and a true duo.
if got := partyActionExpectation(1.5); got <= 1 || got >= 2.4 {
t.Fatalf("weight 1.5 action expectation = %v, want strictly between 1 and 2.4", got)
}
if got := partyEnemyHPScale(1.5); got <= 1.0 || got >= 1.15 {
t.Fatalf("weight 1.5 HP scale = %v, want strictly between 1.0 and 1.15", got)
}
if got := partyEnemyHPScale(3); got != 1.15 {
t.Fatalf("party HP scale = %v, want 1.15", got)
}
// int(200*1.15) truncates 229.99… to 229; the 1-HP floor is immaterial.
if got := scaledEnemyMaxHP(200, 3); got != 229 {
t.Fatalf("party enemy HP = %d, want 229 (trunc 200*1.15)", got)
}
}
// P8: the enemy's action economy scales with the roster. A party of N faces up to
// N attack-actions a round, each re-targeted, so the enemy's damage spreads across
// the roster instead of pinning one seat — the 1/N² exposure that made P6e's party
// a 100%-clear faceroll. Solo stays at exactly one swing a round, the pre-party
// behaviour the characterization golden pins.
func TestSimulateParty_EnemyActionEconomyScalesWithRoster(t *testing.T) {
enemyRollsPerRound := func(res PartyCombatResult) map[int]int {
perRound := map[int]int{}
for _, e := range res.Events {
if e.Actor == "enemy" && e.Roll > 0 {
perRound[e.Round]++
}
}
return perRound
}
// Solo: never more than one enemy swing in a round.
soloTank := baseEnemy()
soloTank.Stats.MaxHP = 5000
solo := simulatePartyWithRNG(
[]Combatant{basePlayer()}, soloTank, dungeonCombatPhases, seededRNG(23))
if len(enemyRollsPerRound(solo)) == 0 {
t.Fatal("solo: the enemy never attacked")
}
for round, n := range enemyRollsPerRound(solo) {
if n > 1 {
t.Fatalf("solo round %d: enemy swung %d times, want at most 1", round, n)
}
}
// Party of 3: no round exceeds the roster's action budget, and at least one
// round sees more than one swing — the enemy is really taking extra actions.
tank := baseEnemy()
tank.Stats.MaxHP = 5000
party := simulatePartyWithRNG(
[]Combatant{basePlayer(), basePlayer(), basePlayer()}, tank, dungeonCombatPhases, seededRNG(23))
budget := int(partyActionExpectation(3))
if partyActionExpectation(3) > float64(budget) {
budget++ // ceil: the coin-flip round can add one action
}
sawMulti := false
for round, n := range enemyRollsPerRound(party) {
if n > budget {
t.Fatalf("party round %d: enemy swung %d times, over the %d-action budget", round, n, budget)
}
if n > 1 {
sawMulti = true
}
}
if !sawMulti {
t.Fatal("a party of 3 never faced more than one enemy swing in a round — action economy did not scale")
}
}
// Over many seeds the enemy must spread its attention across the roster, or
// "targeting" is really "always seat 0" and members take no risk.
func TestSimulateParty_EnemySpreadsItsTargetsAcrossTheRoster(t *testing.T) {
hit := map[int]bool{}
for seed := uint64(1); seed <= 30; seed++ {
tank := baseEnemy()
tank.Stats.MaxHP = 5000
res := simulatePartyWithRNG(
[]Combatant{basePlayer(), basePlayer(), basePlayer()}, tank, dungeonCombatPhases, seededRNG(seed))
for _, e := range res.Events {
if e.Actor == "enemy" && e.Roll > 0 {
hit[e.Seat] = true
}
}
}
for seat := range 3 {
if !hit[seat] {
t.Fatalf("seat %d was never targeted across 30 seeds — the enemy is not really choosing", seat)
}
}
}
// Per-seat close-out reads its own events, not the party's. If eventsForSeat
// leaked, a member's potions would be burned for the leader's heals.
func TestEventsForSeat_PartitionsTheLog(t *testing.T) {
events := []CombatEvent{
{Action: "heal_item", Seat: 0},
{Action: "heal_item", Seat: 1},
{Action: "heal_item", Seat: 1},
{Action: "regen_tick"}, // unstamped: reads as the leader's
}
if got := len(eventsForSeat(events, 0)); got != 2 {
t.Fatalf("seat 0 saw %d events, want 2", got)
}
if got := countHealEventsFired(CombatResult{Events: eventsForSeat(events, 1)}); got != 2 {
t.Fatalf("seat 1 fired %d heal items, want 2", got)
}
if got := countHealEventsFired(CombatResult{Events: eventsForSeat(events, 0)}); got != 1 {
t.Fatalf("seat 0 fired %d heal items, want 1", got)
}
}
// A party that wins with a casualty is a win, and the casualty is still a
// casualty: survival is read per seat off HP, never off the fight's outcome.
func TestAnySurvivor_ReadsHPNotOutcome(t *testing.T) {
res := PartyCombatResult{
PlayerWon: true,
Seats: []CombatResult{
{PlayerEndHP: 12},
{PlayerEndHP: 0},
{PlayerEndHP: 3},
},
}
if !res.AnySurvivor() {
t.Fatal("AnySurvivor said nobody lived")
}
// A won fight nobody walked away from is still nobody walking away.
wiped := PartyCombatResult{PlayerWon: true, Seats: []CombatResult{{PlayerEndHP: 0}}}
if wiped.AnySurvivor() {
t.Fatal("AnySurvivor counted a downed seat as standing")
}
}