mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +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
310 lines
12 KiB
Go
310 lines
12 KiB
Go
package plugin
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// The companion's whole contract is "he fights, and he is not a player". These
|
|
// tests pin both halves — and specifically the seams where an NPC seat would
|
|
// otherwise be silently treated as a person.
|
|
|
|
func TestCompanion_HiredSeatIsNotAMouth(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
owner := id.UserID("@leader:example.org")
|
|
seedExpedition(t, "exp-hire", owner, "active")
|
|
seatLeaderFixture(t, "exp-hire")
|
|
|
|
if err := hireCompanion("exp-hire", ClassCleric, 4); err != nil {
|
|
t.Fatalf("hireCompanion: %v", err)
|
|
}
|
|
|
|
// The roster holds two seats...
|
|
members, err := partyMembers("exp-hire")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(members) != 2 {
|
|
t.Fatalf("roster has %d seats, want 2 (leader + companion)", len(members))
|
|
}
|
|
|
|
// ...but only one of them eats. partySize feeds the daily supply burn and the
|
|
// "your party is still waiting on you" lock-out; counting the companion would
|
|
// bill the leader for rations he never bought, and strand him out of his next
|
|
// expedition behind a party of one bot.
|
|
n, err := partySize("exp-hire")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("partySize = %d, want 1 — the companion is not a mouth", n)
|
|
}
|
|
}
|
|
|
|
func TestCompanion_GetsNoMailButTakesASeat(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
owner := id.UserID("@leader2:example.org")
|
|
seedExpedition(t, "exp-mail", owner, "active")
|
|
seatLeaderFixture(t, "exp-mail")
|
|
if err := hireCompanion("exp-mail", ClassFighter, 3); err != nil {
|
|
t.Fatalf("hireCompanion: %v", err)
|
|
}
|
|
exp, err := getExpedition("exp-mail")
|
|
if err != nil || exp == nil {
|
|
t.Fatalf("getExpedition: %v", err)
|
|
}
|
|
|
|
// Mail and seats are different sets. Every DM seam reads the audience; the
|
|
// combat roster reads the seats. Getting this backwards either DMs a bot or
|
|
// charges a leader for a body that never sits down.
|
|
for _, uid := range expeditionAudience(exp) {
|
|
if isCompanionSeat(uid) {
|
|
t.Fatal("companion is in the DM audience — he does not get mail")
|
|
}
|
|
}
|
|
var seated bool
|
|
for _, uid := range expeditionSeats(exp) {
|
|
if isCompanionSeat(uid) {
|
|
seated = true
|
|
}
|
|
}
|
|
if !seated {
|
|
t.Fatal("companion is not in the fight roster — he was paid for and never sat down")
|
|
}
|
|
}
|
|
|
|
func TestCompanion_IsGloballyExclusive(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
for _, e := range []struct {
|
|
id string
|
|
owner id.UserID
|
|
}{{"exp-a", "@a:example.org"}, {"exp-b", "@b:example.org"}} {
|
|
seedExpedition(t, e.id, e.owner, "active")
|
|
seatLeaderFixture(t, e.id)
|
|
}
|
|
|
|
if err := hireCompanion("exp-a", ClassMage, 5); err != nil {
|
|
t.Fatalf("first hire: %v", err)
|
|
}
|
|
// He is one person. A second party cannot have him — "out on assignment" is
|
|
// the scarcity knob, not a bug to route around.
|
|
if err := hireCompanion("exp-b", ClassMage, 5); !errors.Is(err, ErrCompanionOnAssignment) {
|
|
t.Errorf("second hire err = %v, want ErrCompanionOnAssignment", err)
|
|
}
|
|
// Re-hiring him into the party he's already with is its own answer.
|
|
if err := hireCompanion("exp-a", ClassMage, 5); !errors.Is(err, ErrCompanionAlreadyHired) {
|
|
t.Errorf("re-hire err = %v, want ErrCompanionAlreadyHired", err)
|
|
}
|
|
|
|
// Dismissed, he's available again.
|
|
if err := dismissCompanion("exp-a"); err != nil {
|
|
t.Fatalf("dismiss: %v", err)
|
|
}
|
|
if err := hireCompanion("exp-b", ClassMage, 5); err != nil {
|
|
t.Errorf("hire after dismiss: %v, want success", err)
|
|
}
|
|
}
|
|
|
|
// The bug that made the whole feature worse than useless: a SOLO expedition has
|
|
// no expedition_party rows at all (the roster only materializes on the first
|
|
// invite), so reading the roster to size the companion answered "nobody" for
|
|
// exactly the player who is hiring him — the one with no friends around. Every
|
|
// solo hire got a level-1 Pete, in whatever tier the leader was actually in.
|
|
func TestCompanion_SoloLeaderSizesHimCorrectly(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
owner := id.UserID("@lonely:example.org")
|
|
seedExpedition(t, "exp-solo-hire", owner, "active")
|
|
|
|
// A level-12 fighter, adventuring alone. No roster rows exist.
|
|
if err := SaveDnDCharacter(&DnDCharacter{
|
|
UserID: owner, Race: RaceHuman, Class: ClassFighter, Level: 12,
|
|
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
|
|
HPMax: 120, HPCurrent: 120, ArmorClass: 18,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if got := companionPartyLevel("exp-solo-hire"); got != 11 {
|
|
t.Errorf("companionPartyLevel = %d, want 11 (the lone leader's 12, less the below-median step). "+
|
|
"A level-1 companion in the leader's zone is worse than no companion at all.", got)
|
|
}
|
|
// And he fills the hole the lone fighter actually has, rather than defaulting
|
|
// as if the party were empty.
|
|
if got := companionRoleFill(companionPartyClasses("exp-solo-hire")); got != ClassCleric {
|
|
t.Errorf("role fill for a lone fighter = %v, want cleric", got)
|
|
}
|
|
}
|
|
|
|
// §4 — asking for "the party" must never answer "nobody".
|
|
//
|
|
// A solo expedition has no expedition_party rows (absence means solo; the roster
|
|
// materializes on the first invite). Every consumer that read the roster table
|
|
// directly therefore got an empty list for a solo player and fell back to
|
|
// whatever looked reasonable locally. That is how the companion was hired at
|
|
// level 1 for exactly the player the feature exists for.
|
|
//
|
|
// expeditionParty always includes the owner. If this test ever fails, that
|
|
// guarantee is gone and the same class of bug is back.
|
|
func TestParty_SoloExpeditionStillHasAParty(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
owner := id.UserID("@alone:example.org")
|
|
seedExpedition(t, "exp-alone", owner, "active")
|
|
|
|
// No roster rows exist at all.
|
|
rows, err := partyMembers("exp-alone")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 0 {
|
|
t.Fatalf("solo expedition has %d roster rows; the premise of this test is gone", len(rows))
|
|
}
|
|
|
|
// And yet the party is not empty.
|
|
seats, err := expeditionParty("exp-alone", string(owner))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(seats) != 1 || seats[0].UserID != owner || seats[0].Kind != SeatLeader {
|
|
t.Fatalf("expeditionParty on a solo run = %+v, want exactly the owner as leader. "+
|
|
"An empty answer here is what hires a level-1 companion.", seats)
|
|
}
|
|
humans, err := partyHumans("exp-alone", string(owner))
|
|
if err != nil || len(humans) != 1 {
|
|
t.Fatalf("partyHumans on a solo run = %+v (err %v), want the owner", humans, err)
|
|
}
|
|
}
|
|
|
|
func TestCompanion_RoleFillTakesTheHole(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
party []DnDClass
|
|
want DnDClass
|
|
}{
|
|
{"no healer", []DnDClass{ClassFighter, ClassRogue}, ClassCleric},
|
|
{"no front line", []DnDClass{ClassCleric, ClassMage}, ClassFighter},
|
|
{"no damage", []DnDClass{ClassCleric, ClassFighter}, ClassMage},
|
|
// A paladin covers healer AND front line, so paladin+rogue has no hole at
|
|
// all — it falls through to the default, a second body up front.
|
|
{"a complete party gets the default", []DnDClass{ClassPaladin, ClassRogue}, ClassFighter},
|
|
{"a paladin still leaves the damage hole", []DnDClass{ClassPaladin}, ClassMage},
|
|
{"solo fighter needs the medic first", []DnDClass{ClassFighter}, ClassCleric},
|
|
{"empty party", nil, ClassCleric},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := companionRoleFill(tc.party); got != tc.want {
|
|
t.Errorf("companionRoleFill(%v) = %v, want %v", tc.party, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// §3 — an engine-driven seat's latch is permanent, and no command can take it.
|
|
//
|
|
// This is the invariant that, when it did not exist, made the companion stand in
|
|
// fights doing nothing. The expedition autopilot drives a party by dispatching
|
|
// each seat's turn AS that seat; his own auto-played move therefore arrived at
|
|
// beginCombatTurn looking exactly like a human returning to the keyboard, and the
|
|
// "they typed, so they're here" branch cleared the latch that was moving him.
|
|
// After round 1 he never acted again — while the boss he had inflated by 15% HP
|
|
// killed the party. The sweep measured it at -27pp clear rate.
|
|
//
|
|
// Autopilot is provisional (a human is away; a keystroke ends it). EngineDriven is
|
|
// not (there is nobody to come back). They must never collapse into one flag.
|
|
func TestCompanion_EngineSeatKeepsTheWheel(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
p := &AdventurePlugin{}
|
|
uid := id.UserID("@lead:example.org")
|
|
|
|
monster := dndBestiary["goblin"]
|
|
c, _, _ := p.companionCombatant(ClassFighter, 8, monster, 2, 0)
|
|
enemy := Combatant{Name: "x", Stats: CombatStats{MaxHP: 500, AC: 12, Attack: 4, AttackBonus: 4}}
|
|
|
|
sess, err := p.startPartyCombatSession("run-e", "enc", "goblin", &enemy, []CombatSeatSetup{
|
|
{UserID: uid, HP: 200, HPMax: 200, Mods: c.Mods, C: &c},
|
|
{UserID: companionUserID(), HP: 120, HPMax: 120, Mods: c.Mods, C: &c, EngineDriven: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if !sess.seatIsEngineDriven(1) {
|
|
t.Fatal("companion seat is not engine-driven")
|
|
}
|
|
if sess.seatIsEngineDriven(0) {
|
|
t.Fatal("the human's seat came out engine-driven")
|
|
}
|
|
// The engine drives it without waiting on anybody...
|
|
if !sess.seatIsAutopiloted(1) || !sess.seatNeedsNoHuman(1) {
|
|
t.Fatal("an engine seat must resolve without waiting for a human")
|
|
}
|
|
// ...and the human's seat still waits for its human.
|
|
if sess.seatIsAutopiloted(0) {
|
|
t.Fatal("the human's seat was latched onto autopilot at seating")
|
|
}
|
|
|
|
// The wheel cannot be taken back, because there is nobody to take it. This is
|
|
// the exact line that used to strand him: a "keystroke" from his own id.
|
|
sess.actorStatusesPtr(1).Autopilot = false
|
|
if !sess.seatIsAutopiloted(1) {
|
|
t.Fatal("clearing Autopilot stranded the engine seat — EngineDriven must keep it moving")
|
|
}
|
|
}
|
|
|
|
// The one that actually matters: he has to FIGHT. Every other guard in this
|
|
// file is about keeping him out of things, and a companion synthesized down to
|
|
// zeroed stats would pass all of them while standing in the fight doing nothing
|
|
// — a hire that silently buys the leader an empty chair.
|
|
func TestCompanion_ActuallySwings(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
p := &AdventurePlugin{}
|
|
|
|
monster := dndBestiary["goblin"]
|
|
tank := monster
|
|
tank.HP = 5000 // outlast the phase clock, so a real swing has time to land
|
|
|
|
pete, _, _ := p.companionCombatant(ClassFighter, 6, tank, 2, 0)
|
|
|
|
if pete.Stats.MaxHP <= 0 || pete.Stats.Attack <= 0 || pete.Stats.AC <= 0 {
|
|
t.Fatalf("companion built with dead stats (%d HP / %d atk / %d AC) — he'd stand in the fight and do nothing",
|
|
pete.Stats.MaxHP, pete.Stats.Attack, pete.Stats.AC)
|
|
}
|
|
|
|
enemy := Combatant{Name: tank.Name, Stats: CombatStats{MaxHP: 5000, AC: 10, Attack: 1, AttackBonus: 1}}
|
|
res := simulatePartyWithRNG([]Combatant{pete}, enemy, dungeonCombatPhases, seededRNG(7))
|
|
|
|
var swings int
|
|
for _, e := range res.Events {
|
|
if e.Actor == "player" && e.Seat == 0 && e.Roll > 0 {
|
|
swings++
|
|
}
|
|
}
|
|
if swings == 0 {
|
|
t.Fatal("the companion never swung — he was hired, seated, and did nothing")
|
|
}
|
|
}
|
|
|
|
func TestCompanion_SheetIsBelowMedianAndNeverPersisted(t *testing.T) {
|
|
setupEmptyTestDB(t)
|
|
|
|
// He is statted like a player of his level, so he never drifts from the tuned
|
|
// math — but he arrives a level down, which is the whole "help, never a carry"
|
|
// rule expressed in one number.
|
|
dc := companionSheet(ClassFighter, 7)
|
|
if dc.Level != 7 || dc.HPMax <= 0 {
|
|
t.Fatalf("companionSheet = level %d / %d HP, want a real level-7 chassis", dc.Level, dc.HPMax)
|
|
}
|
|
|
|
// And he leaves no trace: a player_meta row is the thing that would turn him
|
|
// into a real character everywhere (graveyard, news, XP, leaderboards), so the
|
|
// synthesis must not have written one.
|
|
if c, _ := LoadDnDCharacter(companionUserID()); c != nil {
|
|
t.Fatal("companion has a persisted dnd_character row — he is a player now, which is the one thing he must never be")
|
|
}
|
|
if _, err := loadAdvCharacter(companionUserID()); err == nil {
|
|
t.Fatal("companion has a persisted player_meta row — ensureDnDCharacterForCombat will auto-build him a character on his first swing")
|
|
}
|
|
}
|