mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
N3 widened the combat *roster* from 1 to N but never widened the action model,
the scaling model, or the test net to match. Building the hireable companion
walked into all three. Only one of the four defects found was the companion's;
the rest have been live in prod for every human party since N3.
The party golden did not exist (§5)
Solo combat is pinned exhaustively (7468 lines); the entire N-body layer had
nothing. That is why a healer class that cannot heal shipped without a test
going red. Adds party_characterization.golden (9 scenarios x 5 seeds, incl.
weak and dying seats) and TestPartyCharacterization_OneSeatIsStillSolo, so the
N-body path can never quietly stop being a superset of the balance corpus.
Regenerate only on purpose: -update-party.
No action could target another seat (§1)
Every heal in the engine was self-scoped. A party cleric could not put one hit
point on a friend. Adds turnActionEffect.AllyHeal/AllySeat, `!cast <spell>
@user` and `--target @user` -- the latter has been advertised in !help and
silently swallowed by parseCombatCast since SP2 ("reserved for SP3"). The auto
picker uses it too (simPickAllyHeal), so away-players and engine-driven healers
behave like competent ones. It will not raise the dead.
Corpses kept buffing the boss (§2b)
enemyActionsThisRound counted len(st.actors), dead included -- so a party that
lost a member kept paying for them, and the survivor faced a boss still swinging
at two-player cadence, alone. A death spiral with the arrow pointing the wrong
way. Now counts livingActors(). Party golden moved deliberately for this.
An engine-driven seat was a bool any command could clear (§3)
autoDriveCombat drives a party by dispatching each seat's turn AS that seat, so
a companion's own auto-played move arrived at beginCombatTurn looking like a
player returning to the keyboard and cleared the latch that was moving him. He
then stood in the fight doing nothing while the boss he had inflated killed
everyone. ActorStatuses.EngineDriven is now a persisted seat property that no
command clears, and the driver calls driveEngineSeat instead of impersonating.
"The party" could be empty (§4)
A solo expedition has no expedition_party rows, so asking the roster who was in
the party answered "nobody" -- and every caller fell back to something plausible.
That is how the companion got hired at level 1 for exactly the player the feature
exists for. expeditionParty()/partyHumans() always include the owner.
The companion himself (!expedition hire [class] / !expedition dismiss)
Day 1, leader only, costs coins, role-fills the gap, globally exclusive. He is an
NPC seat and must never become a player: no player_meta, no dnd_character, no
inventory, no DM room -- mint him a player_meta row and
ensureDnDCharacterForCombat will auto-build the news bot a real character, and
he starts appearing in the graveyard and filing death notices about himself.
Mail and seats are different sets: he fights, he does not get written to.
Measured on millenia, n=750/arm. Before these fixes he was -28pp -- worse than no
companion at all. After: solo 48.5% -> 63.9% clear (+15.3pp), with +28.0pp for
trailing players and +2.0pp for leaders. Help, never a carry.
The solo golden is byte-identical throughout: solo combat provably did not move,
and the balance corpus is intact.
Known gap: the companion cannot cast (castActionForSeat loads a sheet from the DB
and he has none by design), so a hired Cleric is still just a bad fighter.
Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
160 lines
6.8 KiB
Go
160 lines
6.8 KiB
Go
package plugin
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"math/rand/v2"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Party characterization — the net that did not exist.
|
|
//
|
|
// TestCombatCharacterization pins SOLO combat exhaustively, and it has been the
|
|
// tripwire for every balance change since. Party combat had nothing. The whole
|
|
// N-body layer — initiative order, per-seat resolution, enemy targeting, the P8
|
|
// action-economy and HP scaling, downed-seat handling — shipped unpinned.
|
|
//
|
|
// What that cost: N3 shipped a Cleric class that cannot heal a single ally (no
|
|
// action in the engine can target another seat) and not one test went red. The
|
|
// hired companion then stood in fights doing nothing, and the unit tests stayed
|
|
// green through that too. It took a 1500-run expedition sweep to see either.
|
|
//
|
|
// So: pin it. Any change to the party engine now has to state, in the diff, what
|
|
// it moved. Regenerate ONLY on purpose:
|
|
//
|
|
// go test ./internal/plugin -run TestPartyCharacterization -update
|
|
//
|
|
// This golden covers simulateParty — the auto-resolve engine, which decides most
|
|
// expedition rooms and which the balance harness is built on. The turn engine's
|
|
// party half (manual play + boss fights) needs DB fixtures and is pinned by
|
|
// combat_turn_party_test.go; widening THAT into a golden is the follow-up.
|
|
|
|
var updatePartyGolden = flag.Bool("update-party", false, "rewrite the party characterization golden")
|
|
|
|
// partyScenario is one pinned N-body fight.
|
|
type partyScenario struct {
|
|
name string
|
|
seats []Combatant
|
|
enemy Combatant
|
|
phases []CombatPhase
|
|
}
|
|
|
|
// seatOfClass shapes a seat that stands in for a class archetype. These are
|
|
// deliberately engine-level (stat blocks, not sheets): the golden pins what the
|
|
// ENGINE does with a roster, not what the character layer feeds it.
|
|
func seatOfClass(name string, hp, atk, def, speed int) Combatant {
|
|
c := basePlayer()
|
|
c.Name = name
|
|
c.Stats.MaxHP = hp
|
|
c.Stats.Attack = atk
|
|
c.Stats.Defense = def
|
|
c.Stats.Speed = speed
|
|
return c
|
|
}
|
|
|
|
func partyCharacterizationScenarios() []partyScenario {
|
|
tank := seatOfClass("Tank", 160, 14, 12, 8)
|
|
striker := seatOfClass("Striker", 90, 22, 6, 14)
|
|
support := seatOfClass("Support", 110, 11, 9, 10)
|
|
// The seat this whole plan is about: a body that is real but below median.
|
|
// If scaling ever stops overcharging for it, THIS line is what moves.
|
|
weak := seatOfClass("Weak", 70, 8, 5, 9)
|
|
// A seat that will fall early. Its corpse must not keep buffing the enemy —
|
|
// when §2 lands, this scenario is the one that proves it.
|
|
glass := seatOfClass("Glass", 12, 18, 0, 16)
|
|
|
|
return []partyScenario{
|
|
{"duo/even", []Combatant{tank, striker}, tankyEnemy(), dungeonCombatPhases},
|
|
{"duo/tank+support", []Combatant{tank, support}, tankyEnemy(), dungeonCombatPhases},
|
|
{"duo/median+weak", []Combatant{tank, weak}, tankyEnemy(), dungeonCombatPhases},
|
|
{"duo/glass falls early", []Combatant{tank, glass}, hardHitEnemy(), dungeonCombatPhases},
|
|
{"trio/even", []Combatant{tank, striker, support}, tankyEnemy(), dungeonCombatPhases},
|
|
{"trio/one weak seat", []Combatant{tank, striker, weak}, tankyEnemy(), dungeonCombatPhases},
|
|
{"trio/two glass seats", []Combatant{tank, glass, glass}, hardHitEnemy(), dungeonCombatPhases},
|
|
{"duo/vs ability enemy", []Combatant{tank, striker},
|
|
abilityEnemy("Wither", "poison", "Duel"), dungeonCombatPhases},
|
|
// The degenerate case. A one-seat roster MUST stay bit-identical to solo —
|
|
// it is the invariant the entire balance corpus rests on, and the reason
|
|
// the solo golden is allowed to stay untouched while this file grows.
|
|
{"solo/one-seat roster", []Combatant{basePlayer()}, baseEnemy(), dungeonCombatPhases},
|
|
}
|
|
}
|
|
|
|
// formatPartyResult prints the seat on EVERY line. The solo formatter does not
|
|
// (it cannot; there is only one seat), and CombatEvent.Seat is `omitempty`, so a
|
|
// JSON trace renders seat 0 and "no seat" identically — which sent me chasing a
|
|
// phantom "the companion never swings" bug for an hour. A party golden that
|
|
// could not tell you WHO acted would be worth very little.
|
|
func formatPartyResult(r PartyCombatResult) string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "result: won=%v timedOut=%v rounds=%d survivors=%v\n",
|
|
r.PlayerWon, r.TimedOut, r.TotalRounds, r.AnySurvivor())
|
|
fmt.Fprintf(&b, " enemy: start=%d entry=%d end=%d\n", r.EnemyStartHP, r.EnemyEntryHP, r.EnemyEndHP)
|
|
for i, s := range r.Seats {
|
|
fmt.Fprintf(&b, " seat[%d]: start=%d entry=%d end=%d\n",
|
|
i, s.PlayerStartHP, s.PlayerEntryHP, s.PlayerEndHP)
|
|
}
|
|
for i, e := range r.Events {
|
|
fmt.Fprintf(&b, " [%02d] r%d seat=%d %-12s %-8s %-16s dmg=%-5d php=%-4d ehp=%-4d roll=%d/%d desc=%q\n",
|
|
i, e.Round, e.Seat, e.Phase, e.Actor, e.Action, e.Damage, e.PlayerHP, e.EnemyHP,
|
|
e.Roll, e.RollAgainst, e.Desc)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func TestPartyCharacterization(t *testing.T) {
|
|
var b strings.Builder
|
|
for _, sc := range partyCharacterizationScenarios() {
|
|
for _, seed := range charSeeds {
|
|
rng := rand.New(rand.NewPCG(seed, 0xC0FFEE))
|
|
res := simulatePartyWithRNG(sc.seats, sc.enemy, sc.phases, rng)
|
|
fmt.Fprintf(&b, "=== %s seed=%d ===\n", sc.name, seed)
|
|
b.WriteString(formatPartyResult(res))
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
got := b.String()
|
|
|
|
path := filepath.Join("testdata", "party_characterization.golden")
|
|
if *updatePartyGolden {
|
|
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Log("party golden rewritten:", path)
|
|
return
|
|
}
|
|
want, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read party golden (run with -update-party to create it): %v", err)
|
|
}
|
|
if string(want) != got {
|
|
t.Fatalf("PARTY ENGINE BEHAVIOUR MOVED.\n\n%s\n\n"+
|
|
"If this was deliberate, say so in the commit and regenerate:\n"+
|
|
" go test ./internal/plugin -run TestPartyCharacterization -update-party",
|
|
firstDiff(string(want), got))
|
|
}
|
|
}
|
|
|
|
// The one-seat roster is the solo engine. If this ever fails, the N-body path has
|
|
// stopped being a superset of the path the entire balance corpus was measured on,
|
|
// and every baseline in the repo is suspect.
|
|
func TestPartyCharacterization_OneSeatIsStillSolo(t *testing.T) {
|
|
for _, seed := range charSeeds {
|
|
solo := simulateCombatWithRNG(basePlayer(), baseEnemy(), dungeonCombatPhases,
|
|
rand.New(rand.NewPCG(seed, 0xC0FFEE)))
|
|
party := simulatePartyWithRNG([]Combatant{basePlayer()}, baseEnemy(), dungeonCombatPhases,
|
|
rand.New(rand.NewPCG(seed, 0xC0FFEE)))
|
|
|
|
if solo.PlayerWon != party.PlayerWon || solo.TotalRounds != party.TotalRounds ||
|
|
solo.EnemyEndHP != party.EnemyEndHP || solo.PlayerEndHP != party.Seats[0].PlayerEndHP {
|
|
t.Fatalf("seed %d: a one-seat roster diverged from solo\n solo: won=%v rounds=%d ehp=%d php=%d\n party: won=%v rounds=%d ehp=%d php=%d",
|
|
seed,
|
|
solo.PlayerWon, solo.TotalRounds, solo.EnemyEndHP, solo.PlayerEndHP,
|
|
party.PlayerWon, party.TotalRounds, party.EnemyEndHP, party.Seats[0].PlayerEndHP)
|
|
}
|
|
}
|
|
}
|