mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Combat engine: pay off the N-body debt, and hire Pete
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
This commit is contained in:
@@ -34,6 +34,11 @@ func simInlineBossCombat() bool { return os.Getenv("GOGOBEE_SIM_INLINE_BOSS") ==
|
||||
type SimRunner struct {
|
||||
P *AdventurePlugin
|
||||
Euro *EuroPlugin
|
||||
// Companion hires Pete into the party on Day 1: "" = none, "auto" = fill the
|
||||
// missing role, or an explicit class id. He takes a combat seat and no loot,
|
||||
// which is exactly the thing the sweep is here to measure — whether an extra
|
||||
// below-median body lifts the trailing case without carrying it.
|
||||
Companion string
|
||||
}
|
||||
|
||||
// NewSimRunner initializes a fresh sqlite DB in dataDir and constructs
|
||||
@@ -400,7 +405,42 @@ type SimCombatSummary struct {
|
||||
// SetSimIncludeTrace(true) has been called, and only on the LAST
|
||||
// combat per expedition (the boss room) to keep JSONL size bounded.
|
||||
// Used by J2 caster-survival analysis.
|
||||
Events []CombatEvent `json:",omitempty"`
|
||||
Events []simTraceEvent `json:",omitempty"`
|
||||
}
|
||||
|
||||
// simTraceEvent is CombatEvent with the seat ALWAYS emitted.
|
||||
//
|
||||
// CombatEvent.Seat is `omitempty` for wire-compat reasons that are correct for
|
||||
// persistence and wrong for a trace: seat 0 and "no seat" serialize identically,
|
||||
// so a party trace silently reads as a solo fight. That is not hypothetical — it
|
||||
// is what made a hired companion who never took a turn look, in the JSON, like a
|
||||
// fight that only ever had one seat, and it cost an hour of chasing the wrong bug.
|
||||
// A diagnostic that cannot tell you who acted is not a diagnostic.
|
||||
type simTraceEvent struct {
|
||||
Round int
|
||||
Seat int // always present, even when 0
|
||||
Phase string
|
||||
Actor string
|
||||
Action string
|
||||
Damage int
|
||||
PlayerHP int
|
||||
EnemyHP int
|
||||
Roll int
|
||||
RollAgainst int
|
||||
Desc string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// simTraceEvents converts a TurnLog for the trace.
|
||||
func simTraceEvents(events []CombatEvent) []simTraceEvent {
|
||||
out := make([]simTraceEvent, len(events))
|
||||
for i, e := range events {
|
||||
out[i] = simTraceEvent{
|
||||
Round: e.Round, Seat: e.Seat, Phase: e.Phase, Actor: e.Actor, Action: e.Action,
|
||||
Damage: e.Damage, PlayerHP: e.PlayerHP, EnemyHP: e.EnemyHP,
|
||||
Roll: e.Roll, RollAgainst: e.RollAgainst, Desc: e.Desc,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// simIncludeTrace gates per-round event capture on SimCombatSummary.
|
||||
@@ -516,6 +556,22 @@ func (s *SimRunner) RunPartyExpedition(uid id.UserID, members []id.UserID, zoneI
|
||||
return res, fmt.Errorf("expedition vanished while seating the party")
|
||||
}
|
||||
}
|
||||
// Hire the companion on Day 1, after the humans are seated so the role fill
|
||||
// sees the party it is filling a hole in. He is not in `members` — he buys no
|
||||
// packs and takes no seat in the human roster — but fightRoster reads
|
||||
// expeditionSeats, so he shows up in every fight from here on.
|
||||
if s.Companion != "" {
|
||||
class, ok := parseCompanionClass(s.Companion)
|
||||
if !ok {
|
||||
class = companionRoleFill(companionPartyClasses(exp.ID))
|
||||
}
|
||||
if err := hireCompanion(exp.ID, class, companionPartyLevel(exp.ID)); err != nil {
|
||||
res.Outcome = "halted"
|
||||
res.StopCode = "companion:" + err.Error()
|
||||
return res, err
|
||||
}
|
||||
}
|
||||
|
||||
roster := append([]id.UserID{uid}, members...)
|
||||
res.PartySize = len(roster)
|
||||
for _, m := range members {
|
||||
@@ -942,7 +998,7 @@ func simCombatSummaries(uid id.UserID) []SimCombatSummary {
|
||||
out = append(out, s)
|
||||
}
|
||||
if simIncludeTrace && len(out) > 0 {
|
||||
out[len(out)-1].Events = lastEvents
|
||||
out[len(out)-1].Events = simTraceEvents(lastEvents)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1038,6 +1094,18 @@ func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) {
|
||||
turn.Sender = id.UserID(cur.seatUserID(seat))
|
||||
}
|
||||
|
||||
// An engine-driven seat is never dispatched as a chat command. There is no
|
||||
// character for the handlers to load, and a command arriving from that
|
||||
// seat's id reads to beginCombatTurn as a player returning to the keyboard
|
||||
// — which is how the driver used to clear the very latch that was moving
|
||||
// the seat. Drive it directly instead.
|
||||
if cur.seatIsEngineDriven(seat) {
|
||||
if err := p.driveEngineSeat(cur, seat); err != nil {
|
||||
return false, fmt.Errorf("engine seat %d: %w", seat, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
kind, arg := p.pickAutoCombatActionForSeat(turn.Sender, cur, seat)
|
||||
var dispatchErr error
|
||||
switch kind {
|
||||
@@ -1157,6 +1225,14 @@ func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *Comba
|
||||
}
|
||||
}
|
||||
}
|
||||
// §1 — a healer with a hurt friend heals the friend. This is what a competent
|
||||
// player does, and until the engine could target another seat it was not an
|
||||
// option the picker even had. It runs before the damage picks: a party member
|
||||
// about to die is a more urgent use of a slot than another Fireball.
|
||||
if id, target := simPickAllyHeal(c, uid, sess, seat); id != "" {
|
||||
return "cast", id + " @" + target
|
||||
}
|
||||
|
||||
if isSpellcaster(c) && !simMartialFirstClass(c.Class) {
|
||||
// Cleric: Spiritual Weapon is a BuffSelf that fires a spectral
|
||||
// bonus-action attack each round via SpiritWeaponProc/Dmg mods —
|
||||
@@ -1261,6 +1337,85 @@ func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) st
|
||||
// - Among feasible candidates, prefer higher slot level (preserves
|
||||
// high-slot supremacy and burns the big slots first); tie-break on
|
||||
// expected damage from the dice string.
|
||||
//
|
||||
// simHealAllyThresholdPct is how hurt a friend has to be before a healer spends a
|
||||
// slot on them rather than on damage. Deliberately lower than the self-heal
|
||||
// threshold: a heal is a wasted turn if the target was going to live anyway, and
|
||||
// the party's damage output is what ends the fight.
|
||||
const simHealAllyThresholdPct = 45
|
||||
|
||||
// simPickAllyHeal returns the heal spell and target name a competent healer would
|
||||
// use on the worst-hurt *other* seat, or ("", "") if nobody needs it, the caster
|
||||
// cannot heal, or the fight is solo.
|
||||
//
|
||||
// This is the picker half of §1. Before the engine could target another seat there
|
||||
// was nothing for it to choose, so an autopiloted or engine-driven healer simply
|
||||
// swung a weapon while their friends died beside them.
|
||||
func simPickAllyHeal(c *DnDCharacter, uid id.UserID, sess *CombatSession, seat int) (spellID, target string) {
|
||||
if sess == nil || !sess.IsParty() || c == nil || !isSpellcaster(c) {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Who is worst off? Only living seats — the engine will not raise the downed,
|
||||
// and a heal aimed at a corpse is a lost turn.
|
||||
worst, worstPct := -1, 101
|
||||
for i := range sess.RosterSize() {
|
||||
if i == seat {
|
||||
continue
|
||||
}
|
||||
hp, hpMax := sess.seatHP(i), sess.seatHPMax(i)
|
||||
if hp <= 0 || hpMax <= 0 {
|
||||
continue
|
||||
}
|
||||
if pct := hp * 100 / hpMax; pct < worstPct {
|
||||
worst, worstPct = i, pct
|
||||
}
|
||||
}
|
||||
if worst < 0 || worstPct >= simHealAllyThresholdPct {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// The cheapest heal that is actually castable — burning a 5th-level slot to
|
||||
// top somebody up is not what a competent player does.
|
||||
known, err := listKnownSpells(uid)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
slots, _ := getSpellSlots(uid)
|
||||
best, bestLevel := "", 99
|
||||
for _, k := range known {
|
||||
if !k.Prepared {
|
||||
continue
|
||||
}
|
||||
sp, ok := lookupSpell(k.SpellID)
|
||||
if !ok || sp.Effect != EffectSpellHeal || sp.CastTime == CastReaction {
|
||||
continue
|
||||
}
|
||||
onList := false
|
||||
for _, cl := range sp.Classes {
|
||||
if cl == c.Class {
|
||||
onList = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !onList || sp.Level == 0 {
|
||||
continue // heals are slot spells; a level-0 "heal" is not a thing we cast
|
||||
}
|
||||
if pair, ok := slots[sp.Level]; !ok || pair[0]-pair[1] <= 0 {
|
||||
continue
|
||||
}
|
||||
if sp.Level < bestLevel {
|
||||
best, bestLevel = sp.ID, sp.Level
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return "", ""
|
||||
}
|
||||
// Target by the seat's own Matrix localpart: splitCastTarget resolves against
|
||||
// the seats in this fight, so this round-trips without a room lookup.
|
||||
return best, id.UserID(sess.seatUserID(worst)).Localpart()
|
||||
}
|
||||
|
||||
func simPickSpell(c *DnDCharacter, uid id.UserID, auraActive bool) string {
|
||||
known, err := listKnownSpells(uid)
|
||||
if err != nil || len(known) == 0 {
|
||||
|
||||
Reference in New Issue
Block a user