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.
This commit is contained in:
prosolis
2026-05-22 08:28:51 -07:00
parent 95e0995c7f
commit b167882e3e
2 changed files with 46 additions and 0 deletions

View File

@@ -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

View File

@@ -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.