From 01d232999379edb95f429019c31831917c9c07b7 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Mon, 18 May 2026 17:16:04 -0700 Subject: [PATCH] Auto-walk: gate on fork/rest, slow cadence, credit DnD streak Four fixes for the auto-walk loop that was re-clearing the same room every 15 min while a fork was pending, ignoring the rest lockout, and not counting expedition activity toward the daily streak: - advanceOnceWithOpts / runAutopilotWalk now short-circuit to stopFork when NodeChoices has a pending fork. Stops phantom kills + duplicate loot drops + fork re-prompts on the same cleared room. - fireExpeditionAutoRuns honors restingLockoutRemaining so the background ticker no longer walks through a long rest. - autoRunCooldown 15m -> 2h, autoRunTickInterval 5m -> 15m. Auto-walk DMs are now a once-in-a-while ping, not a steady drip. - markActedToday + HasActedToday recognise LastActionDate. Wired into !rest short/long, !expedition start/abandon, !extract, foreground !zone advance, and !zone go so DnD-side activity credits the streak even when the expedition ends before midnight. (cherry picked from commit 9e27fd8257a4c92150ad584b393bf5a72270b82c) --- internal/plugin/adventure_character.go | 25 ++++++++++++++++++++++- internal/plugin/dnd_expedition_cmd.go | 17 +++++++++++++++ internal/plugin/dnd_expedition_extract.go | 1 + internal/plugin/dnd_rest.go | 2 ++ internal/plugin/dnd_zone_cmd.go | 18 ++++++++++++++++ internal/plugin/dnd_zone_cmd_graph.go | 1 + internal/plugin/expedition_autorun.go | 17 +++++++++++---- 7 files changed, 76 insertions(+), 5 deletions(-) diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index e094946..3838d67 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -311,7 +311,30 @@ func (c *AdventureCharacter) CanDoHarvest(isHoliday bool) bool { } func (c *AdventureCharacter) HasActedToday() bool { - return c.CombatActionsUsed > 0 || c.HarvestActionsUsed > 0 + if c.CombatActionsUsed > 0 || c.HarvestActionsUsed > 0 { + return true + } + // DnD-side flows (expedition / zone / rest / autopilot) don't touch + // the legacy action counters; they credit the day via LastActionDate + // instead. Honor that so a player who ran an expedition all day and + // extracted before midnight still counts as having acted. + return c.LastActionDate == time.Now().UTC().Format("2006-01-02") +} + +// markActedToday stamps the player's LastActionDate to today so the +// midnight reset credits the day. Safe to call on every DnD-side action; +// no-ops if the date is already today. +func markActedToday(userID id.UserID) { + today := time.Now().UTC().Format("2006-01-02") + c, err := loadAdvCharacter(userID) + if err != nil || c == nil { + return + } + if c.LastActionDate == today { + return + } + c.LastActionDate = today + _ = saveAdvCharacter(c) } func (c *AdventureCharacter) AllActionsUsed(isHoliday bool) bool { diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index cba58cf..5feed25 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -261,6 +261,7 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter // Log the start with prewritten flavor. startLine := flavor.Pick(flavor.ExpeditionStart) _ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine) + markActedToday(ctx.Sender) var b strings.Builder b.WriteString(fmt.Sprintf("๐Ÿ—บ **Expedition begins โ€” %s** _(T%d)_\n\n", zone.Display, int(zone.Tier))) @@ -484,6 +485,7 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error { if err := abandonExpedition(ctx.Sender); err != nil { return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error()) } + markActedToday(ctx.Sender) _ = retireAllRegionRuns(exp) _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "") if err := p.SendDM(ctx.Sender, fmt.Sprintf( @@ -583,6 +585,21 @@ func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, com return autopilotWalkResult{initErr: "No active region run. Try `!region` to refresh."} } + // Already standing at a pending fork: autopilot can't pick for the + // player. Re-emit the prompt with rooms=0 so the background DM + // suppression keeps quiet and we don't tick the rooms counter on a + // no-op walk. + if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil { + if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil { + zone := zoneOrFallback(run.ZoneID) + return autopilotWalkResult{ + finalMsg: renderForkPrompt(zone, *pf), + rooms: 0, + reason: stopFork, + } + } + } + var stream []string var finalMsg string rooms := 0 diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index b5290c5..20a59e5 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -252,6 +252,7 @@ func (p *AdventurePlugin) handleExtractCmd(ctx MessageContext, _ string) error { line := flavor.Pick(flavor.ExtractionVoluntary) _ = appendExpeditionLog(updated.ID, updated.CurrentDay, "narrative", "voluntary extraction", line) + markActedToday(ctx.Sender) var b strings.Builder b.WriteString(fmt.Sprintf("๐Ÿšช **Extraction โ€” %s, Day %d**\n\n", diff --git a/internal/plugin/dnd_rest.go b/internal/plugin/dnd_rest.go index d57f324..6ef20d6 100644 --- a/internal/plugin/dnd_rest.go +++ b/internal/plugin/dnd_rest.go @@ -114,6 +114,7 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error { if err := SaveDnDCharacter(c); err != nil { return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error()) } + markActedToday(ctx.Sender) var msg string if c.HPCurrent > before { @@ -208,6 +209,7 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error { if err := SaveDnDCharacter(c); err != nil { return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error()) } + markActedToday(ctx.Sender) _ = refreshAllResources(ctx.Sender) // Phase 9: spell slots refresh on long rest. _ = refreshSpellSlots(ctx.Sender) diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 509c3bb..f9e15c4 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -308,6 +308,9 @@ func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error { b.WriteString(renderZoneGraphMap(g, run)) b.WriteString("\n```\n") b.WriteString("_E=Entry ?=Exploration T=Trap โ˜…=Elite โ˜ =Boss โšฟ=Secret ยท โœ“ cleared โ–ถ here ยท pending โ•ณ locked_") + if path := renderVisitedPath(g, run); path != "" { + b.WriteString("\n**Path:** " + path) + } return p.SendDM(ctx.Sender, b.String()) } // No registered graph (defensive โ€” every zone has one post-G8). @@ -455,8 +458,23 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) reason: stopBlocked, }, nil } + // compact==true is the background auto-walk path; only credit + // player-initiated advances toward the daily streak. + if !compact { + markActedToday(ctx.Sender) + } _ = applyMoodDecayIfStale(run) zone := zoneOrFallback(run.ZoneID) + // A pending fork means advanceTransitionGraph already cleared the + // current room and stopped โ€” re-running resolveRoom would re-fire + // combat and re-drop loot on the same room. Re-emit the fork prompt + // and let the caller surface it; the player commits via !zone go . + if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil { + return advanceResult{ + final: renderForkPrompt(zone, *pf), + reason: stopFork, + }, nil + } prev := run.CurrentRoomType() prevIdx := run.CurrentRoom diff --git a/internal/plugin/dnd_zone_cmd_graph.go b/internal/plugin/dnd_zone_cmd_graph.go index 43d7a55..924e641 100644 --- a/internal/plugin/dnd_zone_cmd_graph.go +++ b/internal/plugin/dnd_zone_cmd_graph.go @@ -187,6 +187,7 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error { if aerr := advanceZoneRunNode(run.RunID, chosen.To); aerr != nil { return p.SendDM(ctx.Sender, "Couldn't advance: "+aerr.Error()) } + markActedToday(ctx.Sender) g, _ := loadZoneGraph(run.ZoneID) zone := zoneOrFallback(run.ZoneID) fromNode := g.Nodes[run.CurrentNode] diff --git a/internal/plugin/expedition_autorun.go b/internal/plugin/expedition_autorun.go index ae251ba..0acf346 100644 --- a/internal/plugin/expedition_autorun.go +++ b/internal/plugin/expedition_autorun.go @@ -34,12 +34,13 @@ const ( // autoRunTickInterval โ€” how often the ticker wakes. The per-expedition // cooldown is what actually paces; the tick just has to be frequent // enough that the cooldown is enforced cleanly. - autoRunTickInterval = 5 * time.Minute + autoRunTickInterval = 15 * time.Minute // autoRunCooldown โ€” minimum gap between background walks for one - // expedition. 15 min keeps the dungeon moving while leaving room for - // the player to step in and steer if they want. - autoRunCooldown = 15 * time.Minute + // expedition. 2h is the slow cadence: the dungeon still moves on its + // own while the player is away, but the auto-walk DM stays a + // once-in-a-while ping instead of a steady drip. + autoRunCooldown = 2 * time.Hour // autoRunMinExpeditionAge โ€” don't auto-walk a brand-new expedition; // let the player walk the first beat manually so the opening reads @@ -93,6 +94,14 @@ func (p *AdventurePlugin) fireExpeditionAutoRuns(now time.Time) { if hasActiveCombatSession(uid) { continue } + // Honor the rest lockout โ€” short/long rest set RestingUntil and + // foreground !expedition run checks it; background must too, or + // the auto-walk ticker fires through a long rest. + if c, cerr := LoadDnDCharacter(uid); cerr == nil && c != nil { + if restingLockoutRemaining(c) > 0 { + continue + } + } if err := p.tryAutoRun(e, now); err != nil { slog.Warn("expedition: autorun step", "expedition", e.ID, "err", err) }