From a8f8be6f407de738dc96db7cc8a07a1d84d37abc Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sun, 17 May 2026 13:12:34 -0700 Subject: [PATCH] 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. --- internal/plugin/expedition_sim.go | 295 ++++++++++++++++++++++++++---- 1 file changed, 257 insertions(+), 38 deletions(-) diff --git a/internal/plugin/expedition_sim.go b/internal/plugin/expedition_sim.go index 19b6607..be00914 100644 --- a/internal/plugin/expedition_sim.go +++ b/internal/plugin/expedition_sim.go @@ -6,12 +6,14 @@ package plugin // // 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. +// 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 ( "fmt" + "time" "gogobee/internal/db" @@ -95,16 +97,17 @@ func applyClassBaselineStats(c *DnDCharacter) { } } -// 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. +// SimResult is the per-expedition summary the runner emits. type SimResult struct { UserID string Class string Level int Zone string - Outcome string // "extracted" | "abandoned" | "tpk" | "halted" | "ongoing" - Rooms int + Outcome string // "extracted" | "cleared" | "tpk" | "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 @@ -112,16 +115,32 @@ type SimResult struct { SUStart float32 SUEnd float32 DaysAtEnd int + Threat int + Log []SimLogEntry } -// 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). +// 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 +} + +// 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 // 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) if err != nil || c == nil { 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 - 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" + for i := 0; i < walkCap; i++ { + walk := s.P.runAutopilotWalk(ctx, autopilotRoomCap, true) + if walk.initErr != "" { + res.Outcome = "halted" + res.StopCode = "init:" + walk.initErr 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.Walks++ + res.Rooms += walk.rooms + 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 } - } else { - stalls = 0 - priorRoom = room + 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 == "" { 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 { 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) + } } 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" + } +}