mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
80
cmd/expedition-sim/main.go
Normal file
80
cmd/expedition-sim/main.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
194
internal/plugin/expedition_sim.go
Normal file
194
internal/plugin/expedition_sim.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user