Sim scaffold: cmd/expedition-sim drives synthetic expeditions

SimRunner re-uses production plugin code paths against a fresh sqlite DB —
SendDM no-ops without a Matrix client, so ground truth comes from the DB.
BuildCharacter persists adv+dnd character rows with class-appropriate
ability scores and spell slots; RunExpedition loops !expedition run with
stall detection until the autopilot halts or the expedition closes.

The cmd binary is a thin orchestrator: one flag-driven run, JSON output.
Per-room structured logging, day-cycle fast-forward, and matrix batching
are next session.
This commit is contained in:
prosolis
2026-05-17 12:55:23 -07:00
parent 1cd53eb890
commit ebea2b430a
2 changed files with 274 additions and 0 deletions

View File

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