diff --git a/cmd/expedition-sim/main.go b/cmd/expedition-sim/main.go new file mode 100644 index 0000000..0e36feb --- /dev/null +++ b/cmd/expedition-sim/main.go @@ -0,0 +1,80 @@ +// expedition-sim drives synthetic players through expeditions for batch +// analysis. Re-uses production plugin paths against a fresh sqlite DB so +// outcomes mirror what live players hit. +// +// Usage: expedition-sim [-class fighter] [-level 5] [-zone goblin_warrens] +// [-bank 1000] [-cap 200] [-data DIR] +// +// Defaults run one L5 fighter through goblin_warrens and print a JSON +// SimResult to stdout. Per-room logs and matrix-mode batches land in a +// follow-up; this binary is the scaffold the analysis stack hangs off. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + + "gogobee/internal/plugin" + + "maunium.net/go/mautrix/id" +) + +func main() { + var ( + class = flag.String("class", "fighter", "DnD class id (fighter, mage, cleric, …)") + level = flag.Int("level", 5, "character level") + zone = flag.String("zone", "goblin_warrens", "zone id (see internal/plugin/dnd_zone_registry.go)") + bank = flag.Float64("bank", 1000, "starting coin balance — must cover outfitting") + cap = flag.Int("cap", 200, "max !expedition run invocations per expedition") + dataDir = flag.String("data", "", "data dir for the temp sqlite db (default: OS tempdir)") + userTag = flag.String("user", "@sim:expedition", "synthetic user id") + ) + flag.Parse() + + dir := *dataDir + if dir == "" { + var err error + dir, err = os.MkdirTemp("", "expedition-sim-") + if err != nil { + fail("mkdir temp:", err) + } + defer os.RemoveAll(dir) + } + + runner, err := plugin.NewSimRunner(dir) + if err != nil { + fail("init runner:", err) + } + defer runner.Close() + + uid := id.UserID(*userTag) + if _, err := runner.BuildCharacter(uid, plugin.DnDClass(*class), *level); err != nil { + fail("build character:", err) + } + + runner.Euro.Credit(uid, *bank, "expedition-sim bankroll") + + res, err := runner.RunExpedition(uid, plugin.ZoneID(*zone), *cap) + if err != nil { + // RunExpedition returns a partial result alongside the error so the + // outcome of a partial run is still observable. + if res != nil { + emit(res) + } + fail("run expedition:", err) + } + emit(res) +} + +func emit(res *plugin.SimResult) { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(res) +} + +func fail(args ...interface{}) { + fmt.Fprintln(os.Stderr, args...) + os.Exit(1) +} diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go new file mode 100644 index 0000000..19b6607 --- /dev/null +++ b/internal/plugin/expedition_sim.go @@ -0,0 +1,194 @@ +package plugin + +// Expedition sim runner — headless driver that walks expeditions through +// the production plugin code paths against a temp sqlite DB. Used by +// cmd/expedition-sim to generate batched corpora for H3/H4 analysis. +// +// SendDM is a no-op when AdventurePlugin.Client is nil, so the runner +// reads ground truth from the DB rather than parsing transcripts. The +// real-time day cycle (06:00/21:00 cron) is bypassed; expeditions are +// driven to completion via repeated !expedition run cycles until the +// autopilot stops or supplies drain. + +import ( + "fmt" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// SimRunner owns a temp-DB AdventurePlugin + EuroPlugin pair and exposes +// just enough surface to drive synthetic players end-to-end. +type SimRunner struct { + P *AdventurePlugin + Euro *EuroPlugin +} + +// NewSimRunner initializes a fresh sqlite DB in dataDir and constructs +// the plugin pair. dataDir should already exist and be writable; the +// caller is responsible for cleanup. Re-init after a Close+new dataDir. +func NewSimRunner(dataDir string) (*SimRunner, error) { + db.Close() + if err := db.Init(dataDir); err != nil { + return nil, fmt.Errorf("db init: %w", err) + } + euro := &EuroPlugin{} + p := &AdventurePlugin{euro: euro} + return &SimRunner{P: p, Euro: euro}, nil +} + +// Close releases the shared sqlite handle. Safe to call multiple times. +func (s *SimRunner) Close() { + db.Close() +} + +// BuildCharacter persists a synthetic character at (class, level) under +// uid, with adventure_character + dnd_character + spell-slot rows wired +// up so handleDnDExpeditionCmd accepts them. Stats are class-flavored +// but otherwise vanilla (no race/subclass perks, no equipment). +func (s *SimRunner) BuildCharacter(uid id.UserID, class DnDClass, level int) (*DnDCharacter, error) { + if err := createAdvCharacter(uid, "sim_"+string(class)); err != nil { + return nil, fmt.Errorf("createAdvCharacter: %w", err) + } + c := &DnDCharacter{ + UserID: uid, + Race: RaceHuman, + Class: class, + Level: level, + } + applyClassBaselineStats(c) + conMod := abilityModifier(c.CON) + c.HPMax = computeMaxHP(c.Class, conMod, level) + c.HPCurrent = c.HPMax + c.ArmorClass = computeAC(c.Class, abilityModifier(c.DEX)) + c.ShortRestCharges = level + if err := SaveDnDCharacter(c); err != nil { + return nil, fmt.Errorf("SaveDnDCharacter: %w", err) + } + if pool := slotsForClassLevel(class, level); len(pool) > 0 { + if err := setSpellSlotsForLevel(uid, class, level); err != nil { + return nil, fmt.Errorf("setSpellSlotsForLevel: %w", err) + } + } + return c, nil +} + +// applyClassBaselineStats sets ability scores that put each class in +// its expected role band — martials prioritize STR/CON, casters their +// casting stat. Values mirror the array choices in the class-balance +// harness so sim numbers stay comparable. +func applyClassBaselineStats(c *DnDCharacter) { + switch c.Class { + case ClassFighter, ClassPaladin: + c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 16, 13, 15, 8, 12, 10 + case ClassRogue, ClassRanger: + c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 10, 16, 14, 12, 13, 8 + case ClassMage, ClassSorcerer: + c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 8, 14, 13, 16, 12, 10 + case ClassCleric, ClassDruid: + c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 12, 10, 14, 8, 16, 13 + case ClassBard, ClassWarlock: + c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 8, 13, 14, 10, 12, 16 + default: + c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = 12, 12, 12, 12, 12, 12 + } +} + +// SimResult is the per-expedition summary the runner emits. The +// per-room log lives in the dnd_expedition_log table for now; later +// phases can extract a structured slice here. +type SimResult struct { + UserID string + Class string + Level int + Zone string + Outcome string // "extracted" | "abandoned" | "tpk" | "halted" | "ongoing" + Rooms int + StartHP int + EndHP int + StartCoin float64 + EndCoin float64 + SUStart float32 + SUEnd float32 + DaysAtEnd int +} + +// RunExpedition starts an expedition for uid in zoneID and loops +// !expedition run until the autopilot stops advancing rooms. runCap +// bounds the total !expedition run invocations as a safety net against +// runaway loops (each invocation walks up to autopilotRoomCap rooms). +// +// Pre-state: uid must own a synthetic character via BuildCharacter and +// have a coin balance sufficient for outfitting (caller's responsibility). +func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, runCap int) (*SimResult, error) { + c, err := LoadDnDCharacter(uid) + if err != nil || c == nil { + return nil, fmt.Errorf("LoadDnDCharacter: %w", err) + } + res := &SimResult{ + UserID: string(uid), + Class: string(c.Class), + Level: c.Level, + Zone: string(zoneID), + StartHP: c.HPCurrent, + StartCoin: s.Euro.GetBalance(uid), + } + + ctx := MessageContext{Sender: uid} + if err := s.P.handleDnDExpeditionCmd(ctx, "start "+string(zoneID)); err != nil { + return res, fmt.Errorf("expedition start: %w", err) + } + exp, _ := getActiveExpedition(uid) + if exp == nil { + res.Outcome = "halted" + return res, fmt.Errorf("expedition did not persist after start") + } + res.SUStart = exp.Supplies.Current + + priorRoom := -1 + stalls := 0 + for i := 0; i < runCap; i++ { + if err := s.P.handleDnDExpeditionCmd(ctx, "run"); err != nil { + return res, fmt.Errorf("expedition run iter %d: %w", i, err) + } + cur, _ := getActiveExpedition(uid) + if cur == nil { + // Expedition closed (extracted, TPK, abandoned). + res.Outcome = "extracted" + break + } + run, _ := getActiveZoneRun(uid) + room := -1 + if run != nil { + room = run.CurrentRoom + } + if room == priorRoom { + stalls++ + if stalls >= 2 { + // Two consecutive runs with no room advance — autopilot + // has stopped (HP/SU/threshold). Sim mode bails so we + // can record the halt rather than loop forever. + res.Outcome = "halted" + break + } + } else { + stalls = 0 + priorRoom = room + } + res.Rooms++ + } + if res.Outcome == "" { + res.Outcome = "ongoing" + } + + if c2, _ := LoadDnDCharacter(uid); c2 != nil { + res.EndHP = c2.HPCurrent + } + res.EndCoin = s.Euro.GetBalance(uid) + if exp2, _ := getActiveExpedition(uid); exp2 != nil { + res.SUEnd = exp2.Supplies.Current + res.DaysAtEnd = exp2.CurrentDay + } + return res, nil +}