mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Sim runner: day-cycle fast-forward, auto-combat, log capture
RunExpedition now drives expeditions end-to-end through the production plugin paths: - TickDay calls deliverRecap + deliverBriefing with a synthetic clock anchored on exp.StartDate, advancing one day per call without real-time waits. - Autopilot bursts use compact mode so elite gates auto-resolve inline. - Boss / surviving elite gates are auto-resolved via handleFightCmd + looped handleAttackCmd until the session flips Won/Lost/Fled. - Forks resolve to path 1 deterministically. - SimResult exposes StopCode, Walks, DayTicks, Threat, plus the full dnd_expedition_log projected as SimLogEntry rows.
This commit is contained in:
@@ -6,12 +6,14 @@ package plugin
|
|||||||
//
|
//
|
||||||
// SendDM is a no-op when AdventurePlugin.Client is nil, so the runner
|
// SendDM is a no-op when AdventurePlugin.Client is nil, so the runner
|
||||||
// reads ground truth from the DB rather than parsing transcripts. The
|
// reads ground truth from the DB rather than parsing transcripts. The
|
||||||
// real-time day cycle (06:00/21:00 cron) is bypassed; expeditions are
|
// real-time day cycle (06:00/21:00 cron) is bypassed; the runner drives
|
||||||
// driven to completion via repeated !expedition run cycles until the
|
// it directly via TickDay, which calls deliverRecap + deliverBriefing
|
||||||
// autopilot stops or supplies drain.
|
// with a synthetic clock so multi-day expeditions are reachable without
|
||||||
|
// real-time waits.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
|
||||||
@@ -95,16 +97,17 @@ func applyClassBaselineStats(c *DnDCharacter) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SimResult is the per-expedition summary the runner emits. The
|
// SimResult is the per-expedition summary the runner emits.
|
||||||
// per-room log lives in the dnd_expedition_log table for now; later
|
|
||||||
// phases can extract a structured slice here.
|
|
||||||
type SimResult struct {
|
type SimResult struct {
|
||||||
UserID string
|
UserID string
|
||||||
Class string
|
Class string
|
||||||
Level int
|
Level int
|
||||||
Zone string
|
Zone string
|
||||||
Outcome string // "extracted" | "abandoned" | "tpk" | "halted" | "ongoing"
|
Outcome string // "extracted" | "cleared" | "tpk" | "boss_doorway" | "fork" | "halted" | "ongoing"
|
||||||
Rooms int
|
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
|
StartHP int
|
||||||
EndHP int
|
EndHP int
|
||||||
StartCoin float64
|
StartCoin float64
|
||||||
@@ -112,16 +115,32 @@ type SimResult struct {
|
|||||||
SUStart float32
|
SUStart float32
|
||||||
SUEnd float32
|
SUEnd float32
|
||||||
DaysAtEnd int
|
DaysAtEnd int
|
||||||
|
Threat int
|
||||||
|
Log []SimLogEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunExpedition starts an expedition for uid in zoneID and loops
|
// SimLogEntry is a JSONL-friendly projection of one dnd_expedition_log
|
||||||
// !expedition run until the autopilot stops advancing rooms. runCap
|
// row. We expose just the fields a post-hoc analyzer needs without
|
||||||
// bounds the total !expedition run invocations as a safety net against
|
// pulling the full ExpeditionEntry type.
|
||||||
// runaway loops (each invocation walks up to autopilotRoomCap rooms).
|
type SimLogEntry struct {
|
||||||
|
Day int
|
||||||
|
Type string
|
||||||
|
Summary string
|
||||||
|
Flavor string
|
||||||
|
TS time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 fast-forwards the day
|
||||||
|
// cycle so multi-day expeditions complete without real-time waits.
|
||||||
|
//
|
||||||
|
// walkCap bounds the number of autopilot bursts as a safety net. Each
|
||||||
|
// burst walks up to autopilotRoomCap rooms.
|
||||||
//
|
//
|
||||||
// Pre-state: uid must own a synthetic character via BuildCharacter and
|
// Pre-state: uid must own a synthetic character via BuildCharacter and
|
||||||
// have a coin balance sufficient for outfitting (caller's responsibility).
|
// have a coin balance sufficient for outfitting (caller's responsibility).
|
||||||
func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, runCap int) (*SimResult, error) {
|
func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, walkCap int) (*SimResult, error) {
|
||||||
c, err := LoadDnDCharacter(uid)
|
c, err := LoadDnDCharacter(uid)
|
||||||
if err != nil || c == nil {
|
if err != nil || c == nil {
|
||||||
return nil, fmt.Errorf("LoadDnDCharacter: %w", err)
|
return nil, fmt.Errorf("LoadDnDCharacter: %w", err)
|
||||||
@@ -146,37 +165,82 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, runCap int) (*Si
|
|||||||
}
|
}
|
||||||
res.SUStart = exp.Supplies.Current
|
res.SUStart = exp.Supplies.Current
|
||||||
|
|
||||||
priorRoom := -1
|
for i := 0; i < walkCap; i++ {
|
||||||
stalls := 0
|
walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true)
|
||||||
for i := 0; i < runCap; i++ {
|
if walk.initErr != "" {
|
||||||
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"
|
res.Outcome = "halted"
|
||||||
|
res.StopCode = "init:" + walk.initErr
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
} else {
|
res.Walks++
|
||||||
stalls = 0
|
res.Rooms += walk.rooms
|
||||||
priorRoom = room
|
res.StopCode = stopReasonLabel(walk.reason)
|
||||||
|
|
||||||
|
// Hard exits: walk says we're done.
|
||||||
|
switch walk.reason {
|
||||||
|
case stopEnded:
|
||||||
|
res.Outcome = "tpk"
|
||||||
|
i = walkCap // exit
|
||||||
|
case stopComplete:
|
||||||
|
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.autoResolveCombat(ctx)
|
||||||
|
if err != nil {
|
||||||
|
res.Outcome = "halted"
|
||||||
|
res.StopCode = "combat:" + err.Error()
|
||||||
|
i = walkCap
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !killed {
|
||||||
|
// Player lost or fled — autopilot can't continue past the
|
||||||
|
// gate. Record outcome and stop.
|
||||||
|
if c2, _ := LoadDnDCharacter(uid); c2 == nil || c2.HPCurrent <= 0 {
|
||||||
|
res.Outcome = "tpk"
|
||||||
|
} else {
|
||||||
|
res.Outcome = "fled"
|
||||||
|
}
|
||||||
|
i = walkCap
|
||||||
|
}
|
||||||
|
// 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: always take path 1. Real players
|
||||||
|
// pick based on intent; the sim just needs to make progress.
|
||||||
|
if err := s.P.handleDnDExpeditionCmd(ctx, "go 1"); err != nil {
|
||||||
|
res.Outcome = "halted"
|
||||||
|
res.StopCode = "fork:" + err.Error()
|
||||||
|
i = walkCap
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case stopBlocked:
|
||||||
|
res.Outcome = "blocked"
|
||||||
|
i = walkCap
|
||||||
|
default:
|
||||||
|
// stopOK / stopPreflight / stopHarvestCombat — soft stops.
|
||||||
|
// Fast-forward a day so the next walk has fresh supplies
|
||||||
|
// budgeted, HP that overnight camp may have healed, and
|
||||||
|
// threat drift recorded.
|
||||||
|
if exp, _ = getActiveExpedition(uid); exp == nil {
|
||||||
|
res.Outcome = "extracted"
|
||||||
|
i = walkCap
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := s.TickDay(exp); err != nil {
|
||||||
|
res.Outcome = "halted"
|
||||||
|
res.StopCode = "tick:" + err.Error()
|
||||||
|
i = walkCap
|
||||||
|
break
|
||||||
|
}
|
||||||
|
res.DayTicks++
|
||||||
|
// TickDay may have force-extracted (starvation). Re-check.
|
||||||
|
if exp, _ = getActiveExpedition(uid); exp == nil {
|
||||||
|
res.Outcome = "extracted"
|
||||||
|
i = walkCap
|
||||||
|
}
|
||||||
}
|
}
|
||||||
res.Rooms++
|
|
||||||
}
|
}
|
||||||
if res.Outcome == "" {
|
if res.Outcome == "" {
|
||||||
res.Outcome = "ongoing"
|
res.Outcome = "ongoing"
|
||||||
@@ -189,6 +253,161 @@ func (s *SimRunner) RunExpedition(uid id.UserID, zoneID ZoneID, runCap int) (*Si
|
|||||||
if exp2, _ := getActiveExpedition(uid); exp2 != nil {
|
if exp2, _ := getActiveExpedition(uid); exp2 != nil {
|
||||||
res.SUEnd = exp2.Supplies.Current
|
res.SUEnd = exp2.Supplies.Current
|
||||||
res.DaysAtEnd = exp2.CurrentDay
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// autoResolveCombat dispatches !fight at the current elite/boss gate,
|
||||||
|
// then loops !attack 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).
|
||||||
|
const autoCombatRoundCap = 200
|
||||||
|
|
||||||
|
func (s *SimRunner) autoResolveCombat(ctx MessageContext) (bool, error) {
|
||||||
|
if err := s.P.handleFightCmd(ctx); err != nil {
|
||||||
|
return false, fmt.Errorf("fight: %w", err)
|
||||||
|
}
|
||||||
|
sess, err := getActiveCombatSession(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
|
||||||
|
}
|
||||||
|
sessionID := sess.SessionID
|
||||||
|
for i := 0; i < autoCombatRoundCap; 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
|
||||||
|
}
|
||||||
|
if err := s.P.handleAttackCmd(ctx); err != nil {
|
||||||
|
return false, fmt.Errorf("attack iter %d: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("combat exceeded %d rounds", autoCombatRoundCap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user