mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adds -party N / -party-classes to expedition-sim. Followers are seated
through the real !expedition invite / !expedition accept pair, so the
harness measures the tier gate, the busy guard and the supply pooling
rather than a roster hand-built to succeed. A follower who is refused
halts the run: a party reading taken from a walk that was secretly solo
is worse than no reading.
It immediately found what it was built to find. Only elite and boss
doorways seat the roster; exploration rooms, patrols and harvest
interrupts all resolve through SimulateCombat against ctx.Sender, and
P6d made the walk commands leader-only. So on a 38-room T5 expedition
the party fights 2-3 rooms together and the leader solos the rest -
then dies alone, tearing down the run rows only they own.
Measured at L15/16 over dragons_lair + abyss_portal, party of 3, n=15
per cell: zero TPKs and zero member deaths across 240 seats. Every
failure is the leader falling while two untouched members stand at full
HP. Fighter clears 100% (solo: 47-67%), cleric 33-53% (solo: 13-47%).
The band is unreadable until inline combat seats the party, so the C1
contingency - +35% monster HP per member - stays on the shelf; it would
punish the trash the leader already fights alone.
Hence the outcome vocabulary grows a third word. "tpk" used to mean any
run-ending event, which is how a leader dying beside a healthy party
stayed invisible through P5 and P6. Now: tpk (roster dead),
leader_down, fled (run over, leader alive) - read off the death flag,
not off HP, which the close-out leaves anywhere. A one-seat roster
makes leader-dead and all-dead the same predicate, so solo keeps its
labels for a real death.
Two bugs found in review before this landed:
- An unknown class was not an error anywhere: -class fightr built a
1-HP character and reported an ordinary outcome for it. Guarded in
BuildCharacter, not the flag parser, since the leader had the bug
long before parties did.
- The roster short-rest healed the dead - handleDnDShortRest does not
gate on the death flag, so a member killed in a won boss fight got
rested back above 0 and stopped counting as a casualty.
Golden byte-identical; go test ./... green.
1495 lines
54 KiB
Go
1495 lines
54 KiB
Go
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; the runner drives
|
||
// it directly via TickDay, which calls deliverRecap + deliverBriefing
|
||
// with a synthetic clock so multi-day expeditions are reachable without
|
||
// real-time waits.
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"sort"
|
||
"time"
|
||
|
||
"gogobee/internal/db"
|
||
|
||
"maunium.net/go/mautrix/id"
|
||
)
|
||
|
||
// simInlineBossCombat is a D8-e DIAGNOSTIC toggle. When GOGOBEE_SIM_INLINE_BOSS=1
|
||
// the sim routes boss/elite doorways through the inline SimulateCombat path
|
||
// (production autopilot behavior) instead of the turn-based !fight engine.
|
||
// Used to A/B the martial T4/T5 regression. NOT for production.
|
||
func simInlineBossCombat() bool { return os.Getenv("GOGOBEE_SIM_INLINE_BOSS") == "1" }
|
||
|
||
// 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)
|
||
}
|
||
// Synthetic players don't type !arm between fights; flip on the
|
||
// in-combat auto-arm so Fighter Second Wind, Cleric Healing Word,
|
||
// etc. fire the way a competent prod player would set them up.
|
||
// Without this the sim under-counts class survival.
|
||
simAutoArmEnabled = true
|
||
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) {
|
||
// An unknown class is not an error anywhere downstream: applyClassBaselineStats
|
||
// and computeMaxHP both fall through to a default, and the run walks off with a
|
||
// 1-HP character that dies on turn one. A typo in -class or -party-classes then
|
||
// reports a normal outcome for a character nobody asked to simulate.
|
||
if _, ok := classInfo(class); !ok {
|
||
return nil, fmt.Errorf("unknown class %q", class)
|
||
}
|
||
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,
|
||
Class: class,
|
||
Level: level,
|
||
}
|
||
applyClassBaselineStats(c)
|
||
// Match prod char-creation: race mods on top of the class array.
|
||
// BuildCharacter has historically defaulted to Human (+1 to all);
|
||
// without this step the synthetic character ran a full point under
|
||
// every real player at the same level.
|
||
scores := applyRaceMods(c.Race, [6]int{c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA})
|
||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA = scores[0], scores[1], scores[2], scores[3], scores[4], scores[5]
|
||
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)
|
||
}
|
||
// Class resource pool (stamina/favor/focus/spell_slot). !setup writes
|
||
// this for prod players; the sim has to do it explicitly so Second
|
||
// Wind / Healing Word / etc. have a pool to draw from.
|
||
if err := initResources(uid, class); err != nil {
|
||
return nil, fmt.Errorf("initResources: %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)
|
||
}
|
||
}
|
||
// Populate the known+prepared spell list. ensureSpellsForCharacter is
|
||
// the canonical seeder used by !setup and the auto-migrate path; prod
|
||
// players hit it on character create. The sim has to call it
|
||
// explicitly so simPickSpell sees a real spellbook. No-op for martials.
|
||
if err := ensureSpellsForCharacter(c); err != nil {
|
||
return nil, fmt.Errorf("ensureSpellsForCharacter: %w", err)
|
||
}
|
||
if err := outfitSimCharacter(uid, level); err != nil {
|
||
return nil, fmt.Errorf("outfitSimCharacter: %w", err)
|
||
}
|
||
if err := stockSimConsumables(uid, level); err != nil {
|
||
return nil, fmt.Errorf("stockSimConsumables: %w", err)
|
||
}
|
||
return c, nil
|
||
}
|
||
|
||
// stockSimConsumables drops a small tier-appropriate bundle of potions
|
||
// + a couple offensive items into the synthetic player's inventory so
|
||
// SelectConsumables / setupAutoHealFromInventory have something to fire
|
||
// during autoDriveCombat. Counts are deliberately modest — a real
|
||
// L7+ player typically carries 3-6 heals plus a couple of buffs; we
|
||
// mirror that band rather than max-stocking, which would mask class
|
||
// power gaps.
|
||
func stockSimConsumables(uid id.UserID, level int) error {
|
||
tier := simGearTierForLevel(level)
|
||
bundle := simConsumableBundle(tier)
|
||
for name, qty := range bundle {
|
||
def := consumableDefByName(name)
|
||
if def == nil {
|
||
continue
|
||
}
|
||
for i := 0; i < qty; i++ {
|
||
item := AdvItem{
|
||
Name: def.Name,
|
||
Type: "consumable",
|
||
Tier: def.Tier,
|
||
Value: int64(def.Price),
|
||
}
|
||
if err := addAdvInventoryItem(uid, item); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// simConsumableBundle returns the name→qty bundle for a given gear tier.
|
||
// Counts are deliberately lean — real players don't haul unlimited
|
||
// potions, and over-stocking would mask class-power gaps by letting the
|
||
// sim heal through any combat. The picker is heal-biased; the offensive
|
||
// item gives one boost cycle, not a sustained buff. Inventory rows are
|
||
// consumed in-combat by SelectConsumables, so a long expedition will
|
||
// run dry on its own.
|
||
func simConsumableBundle(tier int) map[string]int {
|
||
switch tier {
|
||
case 1:
|
||
return map[string]int{"Berry Poultice": 2}
|
||
case 2:
|
||
return map[string]int{"Herb Salve": 2, "Coal Bomb": 1}
|
||
case 3:
|
||
return map[string]int{"Herb Salve": 2, "Goblin Grease": 1}
|
||
case 4:
|
||
return map[string]int{"Spirit Tonic": 2, "Sapphire Elixir": 1}
|
||
default: // 5
|
||
return map[string]int{"Spirit Tonic": 3, "Ancient Artifact Oil": 1, "Voidstone Shard": 1}
|
||
}
|
||
}
|
||
|
||
// outfitSimCharacter promotes every adventure_equipment row for uid to
|
||
// a tier matched to character level, with full condition. createAdvCharacter
|
||
// seeds tier-0 ("Basic Ass Sword" etc) which leaves the sim character
|
||
// effectively unarmed; without this step the synthetic player can't beat
|
||
// anything above goblin_warrens regardless of stats.
|
||
//
|
||
// Tier mapping mirrors the zone-tier curve (L1-3 → T1, L4-6 → T2,
|
||
// 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)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for slot, eq := range equip {
|
||
def := equipmentDefByTier(slot, tier)
|
||
eq.Tier = tier
|
||
eq.Condition = 100
|
||
eq.Name = def.Name
|
||
eq.ActionsUsed = 0
|
||
if err := saveAdvEquipment(uid, eq); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func simGearTierForLevel(level int) int {
|
||
switch {
|
||
case level >= 13:
|
||
return 5
|
||
case level >= 10:
|
||
return 4
|
||
case level >= 7:
|
||
return 3
|
||
case level >= 4:
|
||
return 2
|
||
default:
|
||
return 1
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
type SimResult struct {
|
||
UserID string
|
||
Class string
|
||
Level int
|
||
Zone string
|
||
Outcome string // "extracted" | "cleared" | "tpk" | "leader_down" | "fled" | "boss_doorway" | "fork" | "halted" | "ongoing"
|
||
StopCode string // sim-stopReason label of the last autopilot walk
|
||
Rooms int // total rooms walked across all autopilot bursts
|
||
Walks int // autopilot walk invocations (each = up to autopilotRoomCap rooms)
|
||
DayTicks int // synthetic day rollovers fired by TickDay
|
||
StartHP int
|
||
EndHP int
|
||
StartCoin float64
|
||
EndCoin float64
|
||
SUStart float32
|
||
SUEnd float32
|
||
DaysAtEnd int
|
||
Threat int
|
||
// YieldCount is the total number of material rows the synthetic
|
||
// player banked across the expedition (one row per +1 of any
|
||
// resource). YieldsByName breaks that total down by resource name
|
||
// for tier/per-resource calibration. Both are read from
|
||
// adventure_inventory at end-of-run.
|
||
YieldCount int
|
||
YieldsByName map[string]int
|
||
// Combats holds a per-combat trace for every fight the synthetic
|
||
// player entered during the expedition (boss + elites + patrols).
|
||
// Used by post-hoc analysis to dig into class-survival walls
|
||
// without re-running the matrix. Populated from combat_sessions
|
||
// rows + their TurnLog at end-of-run.
|
||
Combats []SimCombatSummary
|
||
// PartySize is the seated roster (1 for a solo run). Members carries a
|
||
// per-seat projection for the followers; the leader's own numbers stay
|
||
// on the top-level fields so a solo row is byte-shaped as it always was.
|
||
PartySize int
|
||
// LeaderAlive is read off the AdventureCharacter death flag, not HP — the
|
||
// close-out after a loss leaves HP in no particular state.
|
||
LeaderAlive bool
|
||
Members []SimMemberStat `json:",omitempty"`
|
||
// DaySnapshots traces HP/SU/threat/rooms at every day rollover
|
||
// (Night camp) plus the start (Day 0) and the final state. Used by
|
||
// D7-c long-expedition baselining to see how the trajectory bends
|
||
// across multi-day runs without scrubbing the log.
|
||
DaySnapshots []SimDaySnapshot
|
||
Log []SimLogEntry
|
||
}
|
||
|
||
// SimDaySnapshot is a point-in-time projection of the sim state at a
|
||
// day-rollover boundary. Day 0 is captured at expedition start; every
|
||
// subsequent entry lands right after a Night camp lands (CurrentDay
|
||
// already incremented). A final entry is appended at end-of-run.
|
||
type SimDaySnapshot struct {
|
||
Day int
|
||
HPCurrent int
|
||
HPMax int
|
||
Supplies float32
|
||
Threat int
|
||
Rooms int // cumulative autopilot rooms walked at snapshot time
|
||
}
|
||
|
||
// SimMemberStat is one follower's slice of a party run. The leader is not
|
||
// listed here — their numbers are the SimResult's own top-level fields.
|
||
// Alive is what a T5 band reading actually turns on: a party "clears" when
|
||
// the boss dies, but a member who died on the way still paid for it.
|
||
type SimMemberStat struct {
|
||
UserID string
|
||
Class string
|
||
Level int
|
||
StartHP int
|
||
EndHP int
|
||
Alive bool
|
||
}
|
||
|
||
// SimCombatSummary is a compact per-fight trace: the entry stats, the
|
||
// per-round damage dealt by each side, and the outcome. Lets J-phase
|
||
// analysis ask "did Fighter L12 hit the manor boss often enough?"
|
||
// without re-running the matrix.
|
||
type SimCombatSummary struct {
|
||
SessionID string
|
||
EncounterID string
|
||
EnemyID string
|
||
Status string // active/won/lost/fled/expired
|
||
Rounds int
|
||
PlayerHPMax int
|
||
PlayerHPEnd int
|
||
EnemyHPMax int
|
||
EnemyHPEnd int
|
||
PlayerDamage int // total damage dealt by player across the fight
|
||
EnemyDamage int // total damage dealt by enemy across the fight
|
||
PlayerHits int // d20 rolls by player that landed (>= enemy AC)
|
||
PlayerMisses int
|
||
EnemyHits int
|
||
EnemyMisses int
|
||
PlayerAC int // inferred from RollAgainst on enemy attack events
|
||
EnemyAC int // inferred from RollAgainst on player attack events
|
||
// Events is the raw per-round TurnLog. Populated only when
|
||
// SetSimIncludeTrace(true) has been called, and only on the LAST
|
||
// combat per expedition (the boss room) to keep JSONL size bounded.
|
||
// Used by J2 caster-survival analysis.
|
||
Events []CombatEvent `json:",omitempty"`
|
||
}
|
||
|
||
// simIncludeTrace gates per-round event capture on SimCombatSummary.
|
||
// Off by default — matrix runs already emit megabytes of summary rows
|
||
// and the raw turn log multiplies that. SetSimIncludeTrace flips it on
|
||
// for targeted J2-style diagnostic sweeps.
|
||
var simIncludeTrace = false
|
||
|
||
// SetSimIncludeTrace toggles inclusion of the raw per-round CombatEvent
|
||
// stream on the LAST SimCombatSummary of each expedition (the boss
|
||
// 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.
|
||
type SimLogEntry struct {
|
||
Day int
|
||
Type string
|
||
Summary string
|
||
Flavor string
|
||
TS time.Time
|
||
}
|
||
|
||
// simWalkInterval — how much synthetic time each autopilot walk
|
||
// represents. Matches the production autorun cooldown so the
|
||
// nightCampWindow check inside decideAutopilotCamp lands on the same
|
||
// real-time cadence: ~8 walks ≈ 16h ≈ one Night-camp rollover.
|
||
const simWalkInterval = autoRunCooldown
|
||
|
||
// RunExpedition starts an expedition for uid in zoneID and loops the
|
||
// autopilot walk (compact mode, so elite rooms auto-resolve inline)
|
||
// until a hard stop fires. Between walks it advances a synthetic clock
|
||
// (simWalkInterval per walk) and calls into the same maybeAutoCamp /
|
||
// pitchBossSafetyCamp scheduler the production autorun ticker uses, so
|
||
// HP-low mid-day rests + Night-camp rollovers fire under the sim.
|
||
//
|
||
// walkCap bounds the number of autopilot bursts as a safety net. Each
|
||
// burst walks up to autopilotRoomCap rooms.
|
||
//
|
||
// maxDays, when > 0, stops the run once res.DayTicks reaches that count
|
||
// — used by D7-c long-expedition baselining to bound multi-day runs by
|
||
// synthetic day count rather than walk count. 0 leaves the run
|
||
// unbounded by days (the walkCap safety net still applies).
|
||
//
|
||
// 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, walkCap, maxDays int) (*SimResult, error) {
|
||
return s.RunPartyExpedition(uid, nil, zoneID, walkCap, maxDays)
|
||
}
|
||
|
||
// RunPartyExpedition is RunExpedition with followers. uid leads; every id in
|
||
// members is invited and accepts on Day 1, through the same
|
||
// `!expedition invite` / `!expedition accept` commands a player types — so the
|
||
// sim measures the seating rules (tier gate, busy guard, supply pooling) rather
|
||
// than a hand-built roster that cannot fail. Each member must already own a
|
||
// character and a bankroll.
|
||
//
|
||
// A nil members list is the solo path, unchanged and bit-identical: no invite is
|
||
// sent, no roster row is written, and `sess.IsParty()` stays false all the way
|
||
// down into the turn engine. That is what keeps the d8prereq_corpus baselines
|
||
// replayable.
|
||
func (s *SimRunner) RunPartyExpedition(uid id.UserID, members []id.UserID, zoneID ZoneID, walkCap, maxDays 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}
|
||
// D5-b made a bare "start <zone>" return the loadout prompt without
|
||
// outfitting. Force the tier-max "heavy" preset so multi-day runs
|
||
// have enough supplies to actually exercise [[project-sim-event-anchored-broken]] rollovers.
|
||
if err := s.P.handleDnDExpeditionCmd(ctx, "start "+string(zoneID)+" heavy"); 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")
|
||
}
|
||
// Seat the followers before the first walk — the invite window is Day 1,
|
||
// and their packs have to reach the pool before SUStart is read.
|
||
if len(members) > 0 {
|
||
if err := s.seatParty(uid, members); err != nil {
|
||
res.Outcome = "halted"
|
||
res.StopCode = "party:" + err.Error()
|
||
return res, err
|
||
}
|
||
if exp, _ = getActiveExpedition(uid); exp == nil {
|
||
res.Outcome = "halted"
|
||
return res, fmt.Errorf("expedition vanished while seating the party")
|
||
}
|
||
}
|
||
roster := append([]id.UserID{uid}, members...)
|
||
res.PartySize = len(roster)
|
||
for _, m := range members {
|
||
stat := SimMemberStat{UserID: string(m)}
|
||
if mc, _ := LoadDnDCharacter(m); mc != nil {
|
||
stat.Class, stat.Level, stat.StartHP = string(mc.Class), mc.Level, mc.HPCurrent
|
||
}
|
||
res.Members = append(res.Members, stat)
|
||
}
|
||
res.SUStart = exp.Supplies.Current
|
||
// Day-0 baseline so the snapshot stream always opens with a known
|
||
// starting state, even if the run halts before the first rollover.
|
||
s.captureDaySnapshot(res, exp, uid)
|
||
|
||
// Synthetic clock — anchored on the expedition's real start_date so
|
||
// nightCampWindow / nightSafetyNet comparisons against LastBriefingAt
|
||
// stay coherent with the rows the autopilot scheduler reads.
|
||
simNow := exp.StartDate
|
||
|
||
for i := 0; i < walkCap; i++ {
|
||
simNow = simNow.Add(simWalkInterval)
|
||
walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true, simInlineBossCombat())
|
||
if walk.initErr != "" {
|
||
res.Outcome = "halted"
|
||
res.StopCode = "init:" + walk.initErr
|
||
break
|
||
}
|
||
res.Walks++
|
||
res.Rooms += walk.rooms
|
||
res.StopCode = stopReasonLabel(walk.reason)
|
||
|
||
// Hard exits: walk says we're done.
|
||
switch walk.reason {
|
||
case stopEnded:
|
||
// stopEnded means "the run is over", which is not the same as
|
||
// "everyone died": resolveCombatRoom ends the run on a *timeout*
|
||
// loss too, and deliberately does not mark that player dead (it is
|
||
// mechanically a retreat). Ask the death flags rather than assume.
|
||
res.Outcome = simRunEndOutcome(roster)
|
||
i = walkCap // exit
|
||
case stopComplete:
|
||
// A stopComplete that reaches the sim is normally a full zone
|
||
// clear (the walk auto-advances mid-zone region boundaries
|
||
// internally). But a mid-zone region clear can still surface
|
||
// here — e.g. the walk's auto-advance hit a transit error and
|
||
// returned stopComplete. Mirror runAutopilotWalk: if the
|
||
// expedition is still active in a multi-region zone with a
|
||
// next region, cross into it and keep simulating instead of
|
||
// scoring a premature "cleared".
|
||
if fresh, ferr := getActiveExpedition(uid); ferr == nil && fresh != nil &&
|
||
IsMultiRegionZone(fresh.ZoneID) {
|
||
if cur, ok := CurrentRegion(fresh); ok {
|
||
if next, ok := nextRegion(fresh.ZoneID, cur.ID); ok {
|
||
if _, terr := s.P.advanceToNextRegion(uid, fresh, cur, next); terr != nil {
|
||
res.Outcome = "halted"
|
||
res.StopCode = "region_transit:" + terr.Error()
|
||
i = walkCap
|
||
break
|
||
}
|
||
exp = fresh
|
||
break // continue the outer walk loop in the next region
|
||
}
|
||
}
|
||
}
|
||
res.Outcome = "cleared"
|
||
i = walkCap
|
||
case stopBoss, stopElite:
|
||
// Auto-resolve the encounter: !fight to open, then !attack
|
||
// per round until the session resolves (won / lost / fled).
|
||
killed, err := s.P.autoDriveCombat(ctx)
|
||
if err != nil {
|
||
res.Outcome = "halted"
|
||
res.StopCode = "combat:" + err.Error()
|
||
i = walkCap
|
||
break
|
||
}
|
||
if !killed {
|
||
// The party lost or fled — autopilot can't continue past the
|
||
// gate.
|
||
res.Outcome = simRunEndOutcome(roster)
|
||
i = walkCap
|
||
} else {
|
||
// Combat won — a competent prod player would short-rest
|
||
// between fights when wounded or down on slots (H5 added
|
||
// the partial slot refresh). Without this the sim
|
||
// under-counts caster mid-expedition staying power. Every
|
||
// seat rests: charges and slots are character-scoped, and a
|
||
// member who never rested would drag the party's numbers. A seat
|
||
// that died in the fight the party just won is skipped:
|
||
// handleDnDShortRest does not gate on the death flag, so resting
|
||
// a corpse heals it back above 0 and the casualty disappears
|
||
// from res.Members[i].EndHP.
|
||
for _, m := range roster {
|
||
if !simSeatAlive(m) {
|
||
continue
|
||
}
|
||
s.maybeShortRest(MessageContext{Sender: m}, m)
|
||
}
|
||
}
|
||
// Boss kill closes the run via combat resolution → continue
|
||
// the loop so the next walk picks up the cleared-run state.
|
||
case stopFork:
|
||
// Deterministic sim policy: take the first UNLOCKED path. The
|
||
// old blind "go 1" stalled forever on all-skill-check forks
|
||
// (feywild fork1) — resolveForkChoice rejects a locked edge but
|
||
// zoneCmdGo swallows it as a sent-DM with a nil return, so the
|
||
// run never advanced and burned every walk at the same node. A
|
||
// real player reads the menu and picks a passable path; mirror
|
||
// that. choice==0 means every edge is locked (a graph soft-lock
|
||
// the author must fix) — halt loudly rather than spin.
|
||
choice := s.firstUnlockedForkChoice(ctx.Sender)
|
||
if choice == 0 {
|
||
res.Outcome = "halted"
|
||
res.StopCode = "fork_all_locked"
|
||
i = walkCap
|
||
break
|
||
}
|
||
if err := s.P.handleDnDExpeditionCmd(ctx, fmt.Sprintf("go %d", choice)); err != nil {
|
||
res.Outcome = "halted"
|
||
res.StopCode = "fork:" + err.Error()
|
||
i = walkCap
|
||
break
|
||
}
|
||
case stopBlocked:
|
||
res.Outcome = "blocked"
|
||
i = walkCap
|
||
case stopBossSafety:
|
||
// Compact autopilot bailed before the boss (HP/SU gate). Mirror
|
||
// the production autorun: force-pitch a rest camp, dwell, then
|
||
// break it so the next walk re-engages the boss.
|
||
fresh, _ := getActiveExpedition(uid)
|
||
if fresh == nil {
|
||
res.Outcome = "extracted"
|
||
i = walkCap
|
||
break
|
||
}
|
||
advanced, err := s.applyAutoCampBossSafety(fresh, &simNow)
|
||
if err != nil {
|
||
res.Outcome = "halted"
|
||
res.StopCode = "boss_safety_camp:" + err.Error()
|
||
i = walkCap
|
||
break
|
||
}
|
||
if advanced {
|
||
res.DayTicks++
|
||
if fresh2, _ := getActiveExpedition(uid); fresh2 != nil {
|
||
s.captureDaySnapshot(res, fresh2, uid)
|
||
}
|
||
}
|
||
if exp, _ = getActiveExpedition(uid); exp == nil {
|
||
res.Outcome = "extracted"
|
||
i = walkCap
|
||
}
|
||
if maxDays > 0 && res.DayTicks >= maxDays {
|
||
if res.Outcome == "" {
|
||
res.Outcome = "day_capped"
|
||
}
|
||
i = walkCap
|
||
}
|
||
default:
|
||
// stopOK / stopPreflight / stopHarvestCombat — soft stops.
|
||
// Run the same camp scheduler the production autorun fires
|
||
// after every walk. With simNow past nightCampWindow the
|
||
// pitch is a Night camp and drives the day rollover; below
|
||
// that, an HP-low rest may fire mid-day.
|
||
fresh, _ := getActiveExpedition(uid)
|
||
if fresh == nil {
|
||
res.Outcome = "extracted"
|
||
i = walkCap
|
||
break
|
||
}
|
||
advanced, err := s.applyAutoCamp(fresh, &simNow)
|
||
if err != nil {
|
||
res.Outcome = "halted"
|
||
res.StopCode = "auto_camp:" + err.Error()
|
||
i = walkCap
|
||
break
|
||
}
|
||
if advanced {
|
||
res.DayTicks++
|
||
if fresh2, _ := getActiveExpedition(uid); fresh2 != nil {
|
||
s.captureDaySnapshot(res, fresh2, uid)
|
||
}
|
||
}
|
||
// maybeAutoCamp's drift step may have force-extracted (starvation).
|
||
if exp, _ = getActiveExpedition(uid); exp == nil {
|
||
res.Outcome = "extracted"
|
||
i = walkCap
|
||
}
|
||
if maxDays > 0 && res.DayTicks >= maxDays {
|
||
if res.Outcome == "" {
|
||
res.Outcome = "day_capped"
|
||
}
|
||
i = walkCap
|
||
}
|
||
}
|
||
}
|
||
if res.Outcome == "" {
|
||
res.Outcome = "ongoing"
|
||
}
|
||
|
||
if c2, _ := LoadDnDCharacter(uid); c2 != nil {
|
||
res.EndHP = c2.HPCurrent
|
||
}
|
||
for i, m := range members {
|
||
if mc, _ := LoadDnDCharacter(m); mc != nil {
|
||
res.Members[i].EndHP = mc.HPCurrent
|
||
}
|
||
res.Members[i].Alive = simSeatAlive(m)
|
||
}
|
||
res.LeaderAlive = simSeatAlive(uid)
|
||
res.EndCoin = s.Euro.GetBalance(uid)
|
||
if exp2, _ := getActiveExpedition(uid); exp2 != nil {
|
||
res.SUEnd = exp2.Supplies.Current
|
||
res.DaysAtEnd = exp2.CurrentDay
|
||
res.Threat = exp2.ThreatLevel
|
||
// Pull the full log for the live expedition.
|
||
res.Log, _ = simLogEntries(exp2.ID)
|
||
} else {
|
||
// Expedition closed — the row may still be queryable by the
|
||
// most-recent expedition for the user.
|
||
if past := mostRecentExpeditionID(uid); past != "" {
|
||
res.Log, _ = simLogEntries(past)
|
||
}
|
||
}
|
||
res.YieldCount, res.YieldsByName = simMaterialYields(uid)
|
||
res.Combats = simCombatSummaries(uid)
|
||
// Final snapshot. Re-read the expedition so closed-run state is
|
||
// visible (extracted runs return nil from getActiveExpedition; the
|
||
// row is still on disk via mostRecentExpeditionID). If the
|
||
// expedition row is gone we synthesize from the cached SU/threat
|
||
// already on res so the snapshot stream always closes.
|
||
if exp2, _ := getActiveExpedition(uid); exp2 != nil {
|
||
s.captureDaySnapshot(res, exp2, uid)
|
||
} else if past := mostRecentExpeditionID(uid); past != "" {
|
||
if exp3, _ := getExpedition(past); exp3 != nil {
|
||
s.captureDaySnapshot(res, exp3, uid)
|
||
}
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
// seatParty walks each follower through `!expedition invite` → `!expedition
|
||
// accept heavy`, as the leader and then as themselves. Both commands answer a
|
||
// refusal with a DM and a nil error — a no-op against the sim's nil client — so
|
||
// the seat is verified against the roster afterwards rather than the return
|
||
// value. A follower who cannot sit down (tier gate, empty purse, a run of their
|
||
// own) is a halted run, not a quietly smaller party: a two-player T5 reading
|
||
// taken from a solo walk is worse than no reading.
|
||
func (s *SimRunner) seatParty(leader id.UserID, members []id.UserID) error {
|
||
exp, err := getActiveExpedition(leader)
|
||
if err != nil || exp == nil {
|
||
return fmt.Errorf("no expedition to seat: %w", err)
|
||
}
|
||
for _, m := range members {
|
||
if err := s.P.handleDnDExpeditionCmd(MessageContext{Sender: leader},
|
||
"invite "+string(m)); err != nil {
|
||
return fmt.Errorf("invite %s: %w", m, err)
|
||
}
|
||
if err := s.P.handleDnDExpeditionCmd(MessageContext{Sender: m},
|
||
"accept "+simPartyLoadout); err != nil {
|
||
return fmt.Errorf("accept %s: %w", m, err)
|
||
}
|
||
}
|
||
size, err := partySize(exp.ID)
|
||
if err != nil {
|
||
return fmt.Errorf("read roster: %w", err)
|
||
}
|
||
if want := len(members) + 1; size != want {
|
||
return fmt.Errorf("roster seated %d of %d — a follower was refused", size, want)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// simPartyLoadout mirrors the leader's own "heavy" purchase in RunExpedition:
|
||
// each member buys the tier-max pack, and the pool is their sum. Anything less
|
||
// and the party starves before the boss, which would read as a difficulty
|
||
// finding rather than the supply-budget artifact it is.
|
||
const simPartyLoadout = "heavy"
|
||
|
||
// simSeatAlive reports whether a seat is still standing. Death is recorded on
|
||
// the AdventureCharacter (markAdventureDead → Kill), not as HPCurrent <= 0: the
|
||
// close-out that follows a loss can leave HP anywhere, and a player who merely
|
||
// fled at 0 HP is not dead. Reading the death flag is the only honest answer.
|
||
func simSeatAlive(uid id.UserID) bool {
|
||
c, err := loadAdvCharacter(uid)
|
||
if err != nil || c == nil {
|
||
return false
|
||
}
|
||
return c.Alive
|
||
}
|
||
|
||
// simRunEndOutcome names how a run ended once the walk says it is over. The
|
||
// three cases are distinct and the distinction is the whole point of a party
|
||
// reading:
|
||
//
|
||
// tpk — every seat is dead. The turn engine only reports Lost once
|
||
// anyAlive() goes false, so a real party loss marks the roster.
|
||
// leader_down — the leader died and members are still standing. Only the
|
||
// leader owns the run and expedition rows, so their death tears
|
||
// both down; inline room/patrol combat is fought by the leader
|
||
// alone, which is how a party reaches this state with untouched
|
||
// members (N3/P7 finding).
|
||
// fled — the run ended with the leader alive: a timeout loss (a
|
||
// mechanical retreat, deliberately not a death) or a forced
|
||
// extract.
|
||
//
|
||
// For a solo roster leader-dead and all-dead are the same predicate, so a solo
|
||
// run still scores exactly "tpk" or "fled" as it always did.
|
||
func simRunEndOutcome(roster []id.UserID) string {
|
||
leaderAlive := simSeatAlive(roster[0])
|
||
if leaderAlive {
|
||
return "fled"
|
||
}
|
||
for _, m := range roster[1:] {
|
||
if simSeatAlive(m) {
|
||
return "leader_down"
|
||
}
|
||
}
|
||
return "tpk"
|
||
}
|
||
|
||
// firstUnlockedForkChoice returns the 1-based index of the first
|
||
// traversable option at the pending fork, or 0 if every edge is locked
|
||
// (a graph soft-lock — see feywild fork1, which had no LockNone exit).
|
||
func (s *SimRunner) firstUnlockedForkChoice(uid id.UserID) int {
|
||
run, err := getActiveZoneRun(uid)
|
||
if err != nil || run == nil {
|
||
return 1
|
||
}
|
||
pf, err := decodePendingFork(run.NodeChoices)
|
||
if err != nil || pf == nil {
|
||
return 1
|
||
}
|
||
for _, o := range pf.Options {
|
||
if o.Unlocked {
|
||
return o.Index
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// captureDaySnapshot appends a SimDaySnapshot reflecting current state.
|
||
// HP is read from the live character row; SU/threat/day from the live
|
||
// expedition. Rooms is the running res.Rooms counter.
|
||
func (s *SimRunner) captureDaySnapshot(res *SimResult, exp *Expedition, uid id.UserID) {
|
||
snap := SimDaySnapshot{
|
||
Day: exp.CurrentDay,
|
||
Supplies: exp.Supplies.Current,
|
||
Threat: exp.ThreatLevel,
|
||
Rooms: res.Rooms,
|
||
}
|
||
if c, _ := LoadDnDCharacter(uid); c != nil {
|
||
snap.HPCurrent = c.HPCurrent
|
||
snap.HPMax = c.HPMax
|
||
}
|
||
res.DaySnapshots = append(res.DaySnapshots, snap)
|
||
}
|
||
|
||
// simCombatSummaries pulls every combat_sessions row for uid and folds
|
||
// its TurnLog into a SimCombatSummary. AC values are inferred from the
|
||
// RollAgainst column on attack events (the engine writes the defender's
|
||
// AC there). Rows are ordered by started_at so the boss fight is last.
|
||
func simCombatSummaries(uid id.UserID) []SimCombatSummary {
|
||
rows, err := db.Get().Query(`
|
||
SELECT session_id, encounter_id, enemy_id, status, round,
|
||
player_hp, player_hp_max, enemy_hp, enemy_hp_max,
|
||
turn_log_json
|
||
FROM combat_session
|
||
WHERE user_id = ?
|
||
ORDER BY started_at ASC`, string(uid))
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
defer rows.Close()
|
||
var out []SimCombatSummary
|
||
var lastEvents []CombatEvent
|
||
for rows.Next() {
|
||
var s SimCombatSummary
|
||
var turnLogJSON string
|
||
if err := rows.Scan(
|
||
&s.SessionID, &s.EncounterID, &s.EnemyID, &s.Status, &s.Rounds,
|
||
&s.PlayerHPEnd, &s.PlayerHPMax, &s.EnemyHPEnd, &s.EnemyHPMax,
|
||
&turnLogJSON); err != nil {
|
||
continue
|
||
}
|
||
var events []CombatEvent
|
||
if turnLogJSON != "" {
|
||
_ = json.Unmarshal([]byte(turnLogJSON), &events)
|
||
}
|
||
lastEvents = events
|
||
for _, ev := range events {
|
||
switch ev.Actor {
|
||
case "player":
|
||
if ev.Roll > 0 {
|
||
if ev.Damage > 0 {
|
||
s.PlayerHits++
|
||
} else {
|
||
s.PlayerMisses++
|
||
}
|
||
if ev.RollAgainst > s.EnemyAC {
|
||
s.EnemyAC = ev.RollAgainst
|
||
}
|
||
}
|
||
if ev.Damage > 0 {
|
||
s.PlayerDamage += ev.Damage
|
||
}
|
||
case "enemy":
|
||
if ev.Roll > 0 {
|
||
if ev.Damage > 0 {
|
||
s.EnemyHits++
|
||
} else {
|
||
s.EnemyMisses++
|
||
}
|
||
if ev.RollAgainst > s.PlayerAC {
|
||
s.PlayerAC = ev.RollAgainst
|
||
}
|
||
}
|
||
if ev.Damage > 0 {
|
||
s.EnemyDamage += ev.Damage
|
||
}
|
||
}
|
||
}
|
||
out = append(out, s)
|
||
}
|
||
if simIncludeTrace && len(out) > 0 {
|
||
out[len(out)-1].Events = lastEvents
|
||
}
|
||
return out
|
||
}
|
||
|
||
// simMaterialYields tallies the material rows the synthetic player
|
||
// banked in adventure_inventory. One row per +1, so the count is the
|
||
// raw resource-unit total. Map breakdown is keyed by resource name.
|
||
func simMaterialYields(uid id.UserID) (int, map[string]int) {
|
||
rows, err := db.Get().Query(`
|
||
SELECT name, COUNT(*)
|
||
FROM adventure_inventory
|
||
WHERE user_id = ? AND item_type = 'material'
|
||
GROUP BY name`, string(uid))
|
||
if err != nil {
|
||
return 0, nil
|
||
}
|
||
defer rows.Close()
|
||
out := make(map[string]int)
|
||
total := 0
|
||
for rows.Next() {
|
||
var name string
|
||
var n int
|
||
if err := rows.Scan(&name, &n); err != nil {
|
||
return total, out
|
||
}
|
||
out[name] = n
|
||
total += n
|
||
}
|
||
return total, out
|
||
}
|
||
|
||
// autoDriveCombat dispatches !fight at the current elite/boss gate,
|
||
// then loops !attack/!cast/!consume until the combat session resolves.
|
||
// Returns true when the player won (enemy dead, room cleared), false when
|
||
// the player lost or fled. autoCombatRoundCap is a safety cap against
|
||
// pathological stalemates (shouldn't trigger in practice — combat is
|
||
// strictly monotone in HP).
|
||
//
|
||
// Shared by the headless sim and the production background autopilot
|
||
// (long-expedition D8-f): a silent ctx (ctx.Silent) suppresses the
|
||
// per-round DM narration so the autorun digest can summarize the fight
|
||
// without spamming the player a message per round. Driving the real
|
||
// !fight/!attack engine here is what gives autopilot bosses true parity
|
||
// with manual `!fight` — including enemy multiattack, which the old
|
||
// inline SimulateCombat path ignored.
|
||
const autoCombatRoundCap = 200
|
||
|
||
func (p *AdventurePlugin) autoDriveCombat(ctx MessageContext) (bool, error) {
|
||
if err := p.handleFightCmd(ctx); err != nil {
|
||
return false, fmt.Errorf("fight: %w", err)
|
||
}
|
||
sess, err := activeCombatSessionFor(ctx.Sender)
|
||
if err != nil {
|
||
return false, fmt.Errorf("getActiveCombatSession: %w", err)
|
||
}
|
||
if sess == nil {
|
||
// No session opened (bestiary miss, doorway already cleared).
|
||
return false, nil
|
||
}
|
||
// The cap counts dispatches, and a party spends one per seat per round. Scale
|
||
// it by the roster so a three-player boss fight gets the same 200 rounds of
|
||
// headroom a solo one does; solo multiplies by 1 and is unchanged.
|
||
sessionID := sess.SessionID
|
||
dispatchCap := autoCombatRoundCap * sess.RosterSize()
|
||
for i := 0; i < dispatchCap; i++ {
|
||
// Re-fetch by id so we can read Won/Lost/Fled — the "active"
|
||
// filter on getActiveCombatSession returns nil once status flips.
|
||
cur, err := getCombatSession(sessionID)
|
||
if err != nil {
|
||
return false, fmt.Errorf("getCombatSession: %w", err)
|
||
}
|
||
if cur == nil {
|
||
return false, fmt.Errorf("combat session %s vanished mid-fight", sessionID)
|
||
}
|
||
switch cur.Status {
|
||
case CombatStatusWon:
|
||
return true, nil
|
||
case CombatStatusLost, CombatStatusFled:
|
||
return false, nil
|
||
}
|
||
|
||
// Every combat command is refused to anyone but the seat on the clock, so
|
||
// a party fight has to be driven as whoever that is. Solo skips the lookup
|
||
// entirely: seat 0 is the only seat, and the sender is already sitting in
|
||
// it — the bit-identical path the balance corpus rests on.
|
||
turn := ctx
|
||
seat := 0
|
||
if cur.IsParty() {
|
||
seat, err = p.actingSeatForAutopilot(cur)
|
||
if err != nil {
|
||
return false, fmt.Errorf("iter %d: %w", i, err)
|
||
}
|
||
turn.Sender = id.UserID(cur.seatUserID(seat))
|
||
}
|
||
|
||
kind, arg := p.pickAutoCombatActionForSeat(turn.Sender, cur, seat)
|
||
var dispatchErr error
|
||
switch kind {
|
||
case "consume":
|
||
dispatchErr = p.handleConsumeCmd(turn, arg)
|
||
case "cast":
|
||
dispatchErr = p.handleCombatCastCmd(turn, arg)
|
||
default:
|
||
dispatchErr = p.handleAttackCmd(turn)
|
||
}
|
||
if dispatchErr != nil {
|
||
return false, fmt.Errorf("%s iter %d: %w", kind, i, dispatchErr)
|
||
}
|
||
}
|
||
return false, fmt.Errorf("combat exceeded %d dispatches (%d seats × %d rounds)",
|
||
dispatchCap, sess.RosterSize(), autoCombatRoundCap)
|
||
}
|
||
|
||
// actingSeatForAutopilot names the seat autoDriveCombat must act as next. The
|
||
// session it reads has already been settled by the command that parked it here,
|
||
// so it is sitting on a live player's turn; a session that is not is a bug in
|
||
// the engine, not a state the autopilot should paper over by spinning to the
|
||
// round cap.
|
||
func (p *AdventurePlugin) actingSeatForAutopilot(sess *CombatSession) (int, error) {
|
||
players, enemy, err := p.partyCombatantsForSession(sess)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("rebuild party fight: %w", err)
|
||
}
|
||
seat, waiting := actingSeat(sess, players, enemy)
|
||
if !waiting {
|
||
return 0, fmt.Errorf("combat session %s: active but waiting on nobody (phase %q)",
|
||
sess.SessionID, sess.Phase)
|
||
}
|
||
return seat, nil
|
||
}
|
||
|
||
// simShortRestHPPct is the HP-percentage threshold below which the sim
|
||
// pulls the short-rest lever after a combat. 60% mirrors the
|
||
// "competent prod player" model: rest when you've taken real damage and
|
||
// have charges to spend, but don't burn charges on scratches.
|
||
const simShortRestHPPct = 60
|
||
|
||
// maybeShortRest fires a short rest after a won combat when the
|
||
// character has charges and either (a) HP is below simShortRestHPPct or
|
||
// (b) any spell slots have been used. Mirrors what a prod player would
|
||
// type between rooms; H5 (commit 1cd53eb) made this slot-recovering for
|
||
// casters. Errors are swallowed — short rest is best-effort QoL and a
|
||
// failure should not abort the sim run.
|
||
func (s *SimRunner) maybeShortRest(ctx MessageContext, uid id.UserID) {
|
||
c, _ := LoadDnDCharacter(uid)
|
||
if c == nil || c.ShortRestCharges <= 0 {
|
||
return
|
||
}
|
||
needsHP := c.HPMax > 0 && c.HPCurrent*100 < c.HPMax*simShortRestHPPct
|
||
hasUsedSlots, _ := casterHasUsedSlots(uid)
|
||
if !needsHP && !hasUsedSlots {
|
||
return
|
||
}
|
||
_ = s.P.handleDnDShortRest(ctx)
|
||
// handleDnDShortRest sets RestingUntil = now+1h. The autopilot walk
|
||
// doesn't gate on that, but TickDay's overnight camp / future zone
|
||
// transitions might — clear it so the sim isn't accidentally
|
||
// locked out of state it would otherwise reach.
|
||
if c2, _ := LoadDnDCharacter(uid); c2 != nil && c2.RestingUntil != nil {
|
||
c2.RestingUntil = nil
|
||
_ = SaveDnDCharacter(c2)
|
||
}
|
||
}
|
||
|
||
// simHealHPThresholdPct is the player-HP percentage below which the sim
|
||
// reaches for a heal consumable before its !attack/!cast. 40% mirrors
|
||
// the "competent player" prod assumption — heal early enough to absorb
|
||
// one more big hit, not so early that a 1-HP scratch burns a potion.
|
||
const simHealHPThresholdPct = 40
|
||
|
||
// pickAutoCombatAction is the per-turn decision tree, mirroring
|
||
// what a competent prod player would type:
|
||
//
|
||
// 1. If HP is below simHealHPThresholdPct and the inventory has a heal
|
||
// consumable, !consume <heal>. (Slot-spell heals were tried in J2c
|
||
// but regressed druid by 10pp — slot heals like cure_wounds heal
|
||
// ~10HP vs 40HP from a tier-4 Spirit Tonic, and burned a slot the
|
||
// caster needed for damage spells. Consumable-first wins.)
|
||
// 2. Else if the character is a non-martial-first spellcaster with a
|
||
// usable damage spell + slot (or a damaging cantrip), !cast it.
|
||
// Higher-slot damage outranks lower-slot damage. (J2c also tried
|
||
// scoring control spells; net ±0 vs J2b in the n=100 sweep, and
|
||
// a higher control weight regressed warlock by 6.6pp — control
|
||
// scoring was reverted. Bard/cleric trailing remains a class-pool
|
||
// issue, not a picker issue.)
|
||
// 3. Else !attack.
|
||
//
|
||
// Pre-J2a the sim looped !attack only, which underweighted every caster
|
||
// class — see sim_results/j2_findings.md for the trace evidence.
|
||
func (p *AdventurePlugin) pickAutoCombatAction(uid id.UserID, sess *CombatSession) (kind, arg string) {
|
||
return p.pickAutoCombatActionForSeat(uid, sess, 0)
|
||
}
|
||
|
||
// pickAutoCombatActionForSeat is pickAutoCombatAction for an arbitrary seat.
|
||
// The decision tree reads HP and the running concentration aura, and both are
|
||
// per-character — before N3/P5 they were read straight off the session row,
|
||
// which is seat 0. Driving a party member's turn off the leader's HP would heal
|
||
// the wrong person and re-arm the wrong aura.
|
||
func (p *AdventurePlugin) pickAutoCombatActionForSeat(uid id.UserID, sess *CombatSession, seat int) (kind, arg string) {
|
||
c, _ := LoadDnDCharacter(uid)
|
||
if c == nil || sess == nil {
|
||
return "attack", ""
|
||
}
|
||
st := sess.actorStatusesForSeat(seat)
|
||
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
|
||
lowHP := hpMax > 0 && hp*100 < hpMax*simHealHPThresholdPct
|
||
if lowHP {
|
||
inv := p.loadConsumableInventory(uid)
|
||
for _, it := range inv {
|
||
if it.Def.Effect == EffectHeal {
|
||
return "consume", it.Def.Name
|
||
}
|
||
}
|
||
}
|
||
if isSpellcaster(c) && !simMartialFirstClass(c.Class) {
|
||
// Cleric: Spiritual Weapon is a BuffSelf that fires a spectral
|
||
// bonus-action attack each round via SpiritWeaponProc/Dmg mods —
|
||
// simPickSpell skips BuffSelf entries by design, so a cleric
|
||
// otherwise never spends an L2 slot on it. Force the pick once
|
||
// per fight (BuffSpiritProc==0) so the picker doesn't pretend
|
||
// it's not a damage option.
|
||
if id := simPickSpiritualWeapon(c, uid, st); id != "" {
|
||
return "cast", id
|
||
}
|
||
// Once a concentration aura is up, a competent caster maintains it and
|
||
// attacks (or casts a non-concentration spell) rather than burning a
|
||
// slot to re-arm the same aura — so the picker excludes concentration
|
||
// spells while one is active.
|
||
auraActive := st.ConcentrationDmg > 0
|
||
if id := simPickSpell(c, uid, auraActive); id != "" {
|
||
return "cast", id
|
||
}
|
||
}
|
||
return "attack", ""
|
||
}
|
||
|
||
// simMartialFirstClass marks the half-casters whose damage identity is
|
||
// weapon-attack + ExtraAttack rather than slot spells. Picker skips
|
||
// !cast for these — Ranger's weapon swing with Hunter's Mark + Extra
|
||
// Attack out-damages a single Lightning Arrow at L12, and the J2b
|
||
// re-baseline measured a 35pp regression when the picker burned slots
|
||
// on damage_save spells for them. Paladin currently has no damage-
|
||
// effect spells in defaults (kit is bless/cure/buff), so the picker is
|
||
// already a no-op there; listed here for intent.
|
||
func simMartialFirstClass(class DnDClass) bool {
|
||
switch class {
|
||
case ClassRanger, ClassPaladin:
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// simPickSpiritualWeapon returns a !cast argument for Spiritual Weapon
|
||
// when a cleric should open the fight with it: the buff is not already
|
||
// active on the session, the spell is prepared, and some slot ≥ L2 is
|
||
// available. Returns "" otherwise. The buff path in combat_cmd.go folds
|
||
// into BuffSpiritProc/Dmg via applyBuffDelta, which the turn engine fires
|
||
// each round via spiritWeaponStrike — so one cast is worth more than a
|
||
// single L2 damage spell across a multi-round fight.
|
||
//
|
||
// Slot pick: lowest available slot ≥ 2. Upcasting is +1d8 per 2 slots
|
||
// above 2nd, so spending a precious L5 to add a single d8 to the proc is
|
||
// not worth burning the bigger slot's damage potential elsewhere; sim
|
||
// behaves like a competent player and saves the high slot.
|
||
func simPickSpiritualWeapon(c *DnDCharacter, uid id.UserID, st ActorStatuses) string {
|
||
if c == nil || c.Class != ClassCleric {
|
||
return ""
|
||
}
|
||
if st.BuffSpiritProc > 0 {
|
||
return ""
|
||
}
|
||
known, err := listKnownSpells(uid)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
prepared := false
|
||
for _, k := range known {
|
||
if k.SpellID == "spiritual_weapon" && k.Prepared {
|
||
prepared = true
|
||
break
|
||
}
|
||
}
|
||
if !prepared {
|
||
return ""
|
||
}
|
||
slots, _ := getSpellSlots(uid)
|
||
const simMaxSlot = 5
|
||
for sl := 2; sl <= simMaxSlot; sl++ {
|
||
pair, ok := slots[sl]
|
||
if !ok || pair[0]-pair[1] <= 0 {
|
||
continue
|
||
}
|
||
if sl == 2 {
|
||
return "spiritual_weapon"
|
||
}
|
||
return fmt.Sprintf("spiritual_weapon --upcast %d", sl)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// simPickSpell returns the spell argument a competent player would pass
|
||
// to !cast this turn, or "" when no usable spell is available (forcing a
|
||
// !attack). The return value is fed straight to handleCombatCastCmd, so
|
||
// upcast picks come back as `"<spell_id> --upcast N"`.
|
||
// Selection rules:
|
||
// - Only damage-effect spells (damage_attack / damage_save / damage_auto).
|
||
// Control/buff/heal are out (J2c sweep showed control scoring at
|
||
// either 22 or 5 nets ≤±2pp at L12 and regresses warlock at 22 —
|
||
// no headroom worth the complexity). Healing is handled by the
|
||
// consumable-first branch in simPickCombatAction.
|
||
// - Reaction-cast spells are excluded (engine rejects them).
|
||
// - For each prepared leveled spell, enumerate one candidate per
|
||
// available slot at level ≥ native (D8-b, aggressive upcasting).
|
||
// spellExpectedDamage handles the +1-die-per-slot-above-native
|
||
// scaling. Cantrips contribute one slot-0 candidate.
|
||
// - Among feasible candidates, prefer higher slot level (preserves
|
||
// high-slot supremacy and burns the big slots first); tie-break on
|
||
// expected damage from the dice string.
|
||
func simPickSpell(c *DnDCharacter, uid id.UserID, auraActive bool) string {
|
||
known, err := listKnownSpells(uid)
|
||
if err != nil || len(known) == 0 {
|
||
return ""
|
||
}
|
||
slots, _ := getSpellSlots(uid)
|
||
type cand struct {
|
||
id string
|
||
slot int
|
||
nativeLevel int
|
||
expDmg float64
|
||
}
|
||
var cands []cand
|
||
for _, k := range known {
|
||
if !k.Prepared {
|
||
continue
|
||
}
|
||
sp, ok := lookupSpell(k.SpellID)
|
||
if !ok {
|
||
continue
|
||
}
|
||
switch sp.Effect {
|
||
case EffectDamageAttack, EffectDamageSave, EffectDamageAuto:
|
||
default:
|
||
continue
|
||
}
|
||
if sp.CastTime == CastReaction {
|
||
continue
|
||
}
|
||
// An aura is already ticking — don't re-arm it; prefer attacks or a
|
||
// non-concentration spell this turn.
|
||
if auraActive && sp.Concentration {
|
||
continue
|
||
}
|
||
onList := false
|
||
for _, cl := range sp.Classes {
|
||
if cl == c.Class {
|
||
onList = true
|
||
break
|
||
}
|
||
}
|
||
if !onList {
|
||
continue
|
||
}
|
||
if sp.Level == 0 {
|
||
cands = append(cands, cand{id: sp.ID, slot: 0, nativeLevel: 0, expDmg: spellExpectedDamage(sp, 0, c.Level)})
|
||
continue
|
||
}
|
||
// simMaxSlot mirrors parseCombatCast's slot-level cap; anything
|
||
// above it would be rejected by the cast handler anyway.
|
||
const simMaxSlot = 5
|
||
for sl := sp.Level; sl <= simMaxSlot; sl++ {
|
||
pair, ok := slots[sl]
|
||
if !ok || pair[0]-pair[1] <= 0 {
|
||
continue
|
||
}
|
||
cands = append(cands, cand{
|
||
id: sp.ID,
|
||
slot: sl,
|
||
nativeLevel: sp.Level,
|
||
expDmg: spellExpectedDamage(sp, sl, c.Level),
|
||
})
|
||
}
|
||
}
|
||
if len(cands) == 0 {
|
||
return ""
|
||
}
|
||
sort.Slice(cands, func(i, j int) bool {
|
||
if cands[i].slot != cands[j].slot {
|
||
return cands[i].slot > cands[j].slot
|
||
}
|
||
return cands[i].expDmg > cands[j].expDmg
|
||
})
|
||
best := cands[0]
|
||
if best.slot > best.nativeLevel && best.nativeLevel > 0 {
|
||
return fmt.Sprintf("%s --upcast %d", best.id, best.slot)
|
||
}
|
||
return best.id
|
||
}
|
||
|
||
// applyAutoCamp drives the production camp scheduler under the sim's
|
||
// synthetic clock: call maybeAutoCamp with *simNow, then if a camp
|
||
// pitched, advance *simNow past minAutoCampDwell and break the camp so
|
||
// the next walk can proceed. Returns whether the pitch was a Night
|
||
// camp (i.e. a day rollover fired).
|
||
func (s *SimRunner) applyAutoCamp(exp *Expedition, simNow *time.Time) (bool, error) {
|
||
_, d, ok := s.P.maybeAutoCamp(exp, *simNow)
|
||
if !ok {
|
||
return false, nil
|
||
}
|
||
return s.dwellAndBreakAutoCamp(exp, simNow, d.Night)
|
||
}
|
||
|
||
// applyAutoCampBossSafety mirrors applyAutoCamp for the stopBossSafety
|
||
// gate — the camp is force-pitched even when the regular HP threshold
|
||
// hasn't tripped (decideAutopilotCamp also blocks pitches at boss
|
||
// rooms, which is exactly where this one belongs).
|
||
func (s *SimRunner) applyAutoCampBossSafety(exp *Expedition, simNow *time.Time) (bool, error) {
|
||
_, d, ok := s.P.pitchBossSafetyCamp(exp, *simNow)
|
||
if !ok {
|
||
return false, nil
|
||
}
|
||
return s.dwellAndBreakAutoCamp(exp, simNow, d.Night)
|
||
}
|
||
|
||
// dwellAndBreakAutoCamp advances *simNow past minAutoCampDwell and
|
||
// breaks the auto-pitched camp. Reloads exp from the DB first so the
|
||
// camp row reflects the just-applied pitch. Returns the night flag
|
||
// passed in (for the DayTicks counter).
|
||
func (s *SimRunner) dwellAndBreakAutoCamp(exp *Expedition, simNow *time.Time, night bool) (bool, error) {
|
||
*simNow = simNow.Add(minAutoCampDwell)
|
||
fresh, err := getExpedition(exp.ID)
|
||
if err != nil {
|
||
return night, err
|
||
}
|
||
if fresh != nil {
|
||
_ = breakAutoCampIfDue(fresh, *simNow)
|
||
}
|
||
return night, nil
|
||
}
|
||
|
||
// TickDay drives one synthetic day rollover for exp: 21:00 recap of
|
||
// the current day, then 06:00 briefing of the next day. The briefing
|
||
// is what bumps current_day and applies supply burn / overnight camp /
|
||
// threat drift. exp is re-read from the DB after each step so callers
|
||
// see fresh state.
|
||
//
|
||
// The clock is anchored on exp.StartDate so repeat calls advance one
|
||
// real-day per call regardless of wall-clock time when the sim runs.
|
||
//
|
||
// Event-anchored expeditions (D2-b) own the rollover inside the
|
||
// autopilot's night-camp pitch — RunExpedition exercises that path via
|
||
// applyAutoCamp; TickDay is retained for tests and the legacy
|
||
// non-event-anchored fallback. The event-anchored branch here short-
|
||
// circuits to processNightCamp so callers that DO invoke TickDay on a
|
||
// post-cutoff expedition still see one-call-one-day semantics.
|
||
func (s *SimRunner) TickDay(exp *Expedition) error {
|
||
if exp == nil {
|
||
return fmt.Errorf("nil expedition")
|
||
}
|
||
// Anchor on start_date + current_day so each call lands on a fresh
|
||
// (day, threshold) pair. CurrentDay starts at 1; we want the recap
|
||
// of day D to fire before the briefing of day D+1.
|
||
dayBase := time.Date(
|
||
exp.StartDate.Year(), exp.StartDate.Month(), exp.StartDate.Day(),
|
||
0, 0, 0, 0, time.UTC,
|
||
).AddDate(0, 0, exp.CurrentDay-1)
|
||
|
||
recapAt := dayBase.Add(time.Duration(expeditionRecapHour) * time.Hour).Add(30 * time.Second)
|
||
if err := s.P.deliverRecap(exp, recapAt); err != nil {
|
||
return fmt.Errorf("deliverRecap: %w", err)
|
||
}
|
||
if fresh, _ := getExpedition(exp.ID); fresh != nil {
|
||
*exp = *fresh
|
||
}
|
||
|
||
briefAt := dayBase.AddDate(0, 0, 1).Add(time.Duration(expeditionBriefingHour) * time.Hour).Add(30 * time.Second)
|
||
if isEventAnchored(exp) {
|
||
if err := s.tickEventAnchoredRollover(exp, briefAt); err != nil {
|
||
return err
|
||
}
|
||
} else {
|
||
if err := s.P.deliverBriefing(exp, briefAt); err != nil {
|
||
return fmt.Errorf("deliverBriefing: %w", err)
|
||
}
|
||
}
|
||
if fresh, _ := getExpedition(exp.ID); fresh != nil {
|
||
*exp = *fresh
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// tickEventAnchoredRollover mirrors pitchAutopilotCamp with Night=true
|
||
// for the sim: burn → optional Standard rest (if supplies cover it) →
|
||
// drift → stamp last_briefing_at. No DM, no camp row left active (rest
|
||
// is applied and immediately cleared the way pitchAutopilotCamp does
|
||
// via applyCampRest + the next walk-tick break). On the no-rest branch
|
||
// we still want burn/drift so supplies drain — matches a stalled
|
||
// autopilot which D2-b's safety net force-fires via processNightCamp.
|
||
func (s *SimRunner) tickEventAnchoredRollover(exp *Expedition, briefAt time.Time) error {
|
||
burn, err := s.P.nightRolloverBurn(exp)
|
||
if err != nil {
|
||
return fmt.Errorf("nightRolloverBurn: %w", err)
|
||
}
|
||
// Pitch a Standard rest if affordable, else skip (autopilot would
|
||
// also bail in low-SU; the burn already landed). Rough is the
|
||
// fallback so the sim isn't stuck at zero healing on tight budgets.
|
||
kind := CampTypeStandard
|
||
if exp.Supplies.Current < campSupplyCost[kind] {
|
||
kind = CampTypeRough
|
||
}
|
||
if exp.Supplies.Current >= campSupplyCost[kind] {
|
||
exp.Supplies.Current -= campSupplyCost[kind]
|
||
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
|
||
return fmt.Errorf("updateSupplies: %w", err)
|
||
}
|
||
applyCampRest(exp, kind)
|
||
}
|
||
drift := s.P.nightRolloverDrift(exp, briefAt)
|
||
_ = burn
|
||
_ = drift
|
||
return nil
|
||
}
|
||
|
||
// simLogEntries returns every dnd_expedition_log row for expID, oldest
|
||
// first, projected into SimLogEntry.
|
||
func simLogEntries(expID string) ([]SimLogEntry, error) {
|
||
rows, err := db.Get().Query(`
|
||
SELECT day, timestamp, entry_type, summary, flavor
|
||
FROM dnd_expedition_log
|
||
WHERE expedition_id = ?
|
||
ORDER BY timestamp ASC, entry_id ASC`, expID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var out []SimLogEntry
|
||
for rows.Next() {
|
||
var e SimLogEntry
|
||
if err := rows.Scan(&e.Day, &e.TS, &e.Type, &e.Summary, &e.Flavor); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, e)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// mostRecentExpeditionID looks up the latest expedition row for uid —
|
||
// used after RunExpedition to recover the log even when the expedition
|
||
// has been extracted (and so is no longer "active").
|
||
func mostRecentExpeditionID(uid id.UserID) string {
|
||
var id string
|
||
_ = db.Get().QueryRow(`
|
||
SELECT expedition_id
|
||
FROM dnd_expedition
|
||
WHERE user_id = ?
|
||
ORDER BY start_date DESC
|
||
LIMIT 1`, string(uid)).Scan(&id)
|
||
return id
|
||
}
|
||
|
||
// stopReasonLabel maps the internal stopReason enum to a stable string
|
||
// for sim output (callers outside the package can't switch on the enum).
|
||
func stopReasonLabel(r stopReason) string {
|
||
switch r {
|
||
case stopOK:
|
||
return "ok"
|
||
case stopFork:
|
||
return "fork"
|
||
case stopElite:
|
||
return "elite"
|
||
case stopBoss:
|
||
return "boss"
|
||
case stopEnded:
|
||
return "ended"
|
||
case stopComplete:
|
||
return "complete"
|
||
case stopBlocked:
|
||
return "blocked"
|
||
case stopHarvestCombat:
|
||
return "harvest_combat"
|
||
case stopPreflight:
|
||
return "preflight"
|
||
default:
|
||
return "unknown"
|
||
}
|
||
}
|