From b167882e3e3473f34258d07867b65c958136ffea Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 22 May 2026 08:28:51 -0700 Subject: [PATCH] expedition-sim: -pet-level flag to model a base housing pet Synthetic sim chars are vanilla base-class (no pet, no subclass), so the per-round pet attack/deflect/whiff path was never exercised in the sim. Add -pet-level N (1-10) which stamps a base Massive Dog (no armor) onto the AdventureCharacter via the normal save path before the run, so combat's DerivePlayerStats sees HasPet()==true. 0 (default) stays petless. Measured lift (n=40, 10 classes x L3/7/12 x 3 zones): overall clear-rate 38.3% petless -> 47.8% at pet L10 (+9.4pp); biggest gains go to the caster trailers (bard +13.3, warlock +11.9, cleric +11.4), narrowing class spread. --- cmd/expedition-sim/main.go | 7 ++++++ internal/plugin/expedition_sim.go | 39 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/cmd/expedition-sim/main.go b/cmd/expedition-sim/main.go index 147e029..78a56e8 100644 --- a/cmd/expedition-sim/main.go +++ b/cmd/expedition-sim/main.go @@ -43,10 +43,17 @@ func main() { runs = flag.Int("runs", 1, "replicates per (class,level,zone) cell (matrix mode)") trace = flag.Bool("trace", false, "include raw per-round CombatEvent stream on the LAST combat of each expedition (boss room) — for J2 diagnostic sweeps") + + petLevel = flag.Int("pet-level", 0, "attach a base housing pet at this level (1-10) to every sim character; 0 = no pet (default, matches prod char-creation)") ) flag.Parse() + if *petLevel < 0 || *petLevel > 10 { + fail("pet-level must be 0-10, got", *petLevel) + } + plugin.SetSimIncludeTrace(*trace) + plugin.SetSimPetLevel(*petLevel) if *matrix { // Matrix default: drop log to keep stdout manageable; explicit diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index 1d17b2e..18decd5 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -60,6 +60,11 @@ func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*D if err := createAdvCharacter(uid, "sim_"+string(class)); err != nil { return nil, fmt.Errorf("createAdvCharacter: %w", err) } + if simPetLevel > 0 { + if err := attachSimPet(uid, simPetLevel); err != nil { + return nil, fmt.Errorf("attachSimPet: %w", err) + } + } c := &DnDCharacter{ UserID: uid, Race: RaceHuman, @@ -170,6 +175,26 @@ func simConsumableBundle(tier int) map[string]int { // L7-9 → T3, L10-12 → T4, L13+ → T5). It's a "kitted-out at expected // difficulty" baseline, not a min-max — players past the appropriate // shop visit should be at or above this band. +// attachSimPet stamps a base housing pet (Massive Dog, no armor) at the +// given level onto the synthetic character via the normal adv-char save +// path, so combat's DerivePlayerStats sees HasPet()==true. Dog vs cat is +// numerically identical today, so type is arbitrary; armor tier stays 0 to +// model the plain "base pet" rather than a kitted one. +func attachSimPet(uid id.UserID, level int) error { + char, err := loadAdvCharacter(uid) + if err != nil { + return err + } + char.PetType = "dog" + char.PetName = "SimDog" + char.PetLevel = level + char.PetArmorTier = 0 + char.PetArrived = true + char.PetChasedAway = false + char.PetXP = 0 + return saveAdvCharacter(char) +} + func outfitSimCharacter(uid id.UserID, level int) error { tier := simGearTierForLevel(level) equip, err := loadAdvEquipment(uid) @@ -300,6 +325,20 @@ var simIncludeTrace = false // room). Callers should flip this on before BuildCharacter / RunExpedition. func SetSimIncludeTrace(on bool) { simIncludeTrace = on } +// simPetLevel, when > 0, attaches a base housing pet (Massive Dog, no +// armor) at that level to every synthetic character. 0 (the default) leaves +// the character petless, matching prod char-creation. Pets are otherwise +// unreachable in the sim — synthetic chars never trigger the arrival flow — +// so this is the only way to exercise the per-round pet attack / deflect / +// whiff path for balance measurement. Combat reads pet stats off the +// AdventureCharacter (see DerivePlayerStats), so BuildCharacter stamps the +// fields there, not on the DnDCharacter. +var simPetLevel = 0 + +// SetSimPetLevel attaches a base pet at the given level (1-10) to sim +// characters. 0 disables. Flip before BuildCharacter. +func SetSimPetLevel(level int) { simPetLevel = level } + // SimLogEntry is a JSONL-friendly projection of one dnd_expedition_log // row. We expose just the fields a post-hoc analyzer needs without // pulling the full ExpeditionEntry type.