diff --git a/internal/plugin/dnd_expedition_camp.go b/internal/plugin/dnd_expedition_camp.go index 0b8f876..f7e1861 100644 --- a/internal/plugin/dnd_expedition_camp.go +++ b/internal/plugin/dnd_expedition_camp.go @@ -384,6 +384,41 @@ func campLocationCheck(exp *Expedition) (cleared bool, problem string) { return true, "" } +// autoBreakCampOnMove strikes an active camp when the player has moved +// to a different room than the one camp was pitched in. Camp is a +// stationary intent (long-rest at this spot until the next briefing); +// once the party walks on — whether by autopilot, manual !advance, or +// fork pick — the tent stops mattering. Overnight rest effects are not +// applied here (those only land at briefing time via +// processOvernightCamp). Returns the camp type that was struck, or "" +// if no break happened. Safe to call when there's no expedition. +func autoBreakCampOnMove(userID id.UserID) string { + exp, err := getActiveExpedition(userID) + if err != nil || exp == nil { + return "" + } + if exp.Camp == nil || !exp.Camp.Active { + return "" + } + if exp.RunID == "" { + return "" + } + run, err := getZoneRun(exp.RunID) + if err != nil || run == nil { + return "" + } + if run.CurrentRoom == exp.Camp.RoomIndex { + return "" + } + kind := exp.Camp.Type + if err := updateCamp(exp.ID, nil); err != nil { + slog.Warn("camp: auto-break on move failed", "expedition", exp.ID, "err", err) + return "" + } + _ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "camp struck (party moved on)", "") + return kind +} + // campCurrentRoomIndex returns 0 (entry) when no room context exists. func campCurrentRoomIndex(exp *Expedition) int { if exp.RunID == "" { diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 5e101a2..6990fd2 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -606,6 +606,10 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) if gerr != nil { return advanceResult{}, fmt.Errorf("Couldn't advance: %s", gerr.Error()) } + var campStruck string + if kind := autoBreakCampOnMove(ctx.Sender); kind != "" { + campStruck = fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind) + } if complete { _, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete) var b strings.Builder @@ -613,6 +617,9 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) b.WriteString(outcome) b.WriteString("\n\n") } + if campStruck != "" { + b.WriteString(campStruck) + } // A "complete" run is only a full zone clear when it isn't a mid-zone // region clear of a multi-region zone. For the latter, name the region // and point at the next one — "Cleared {zone}. Run complete." reads @@ -658,6 +665,9 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) b.WriteString("\n\n") } b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev))) + if campStruck != "" { + b.WriteString(campStruck) + } b.WriteString(forkMsg) return advanceResult{ preStream: preStream, @@ -668,6 +678,9 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool) }, nil } finalMsg := p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next) + if campStruck != "" { + finalMsg = campStruck + finalMsg + } // H2 — auto-harvest the room the player just walked into. Only fires // for Exploration rooms (Entry/Trap/Elite/Boss self-skip via diff --git a/internal/plugin/dnd_zone_cmd_graph.go b/internal/plugin/dnd_zone_cmd_graph.go index 924e641..3b99dd1 100644 --- a/internal/plugin/dnd_zone_cmd_graph.go +++ b/internal/plugin/dnd_zone_cmd_graph.go @@ -196,6 +196,9 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error { nextRoom := nodeKindToRoomType(nextNode.Kind) nextIdx := run.CurrentRoom + 1 var b strings.Builder + if kind := autoBreakCampOnMove(ctx.Sender); kind != "" { + b.WriteString(fmt.Sprintf("⛺ Camp struck (**%s**) — the party moved on.\n\n", kind)) + } b.WriteString(fmt.Sprintf("➡ You take the path: **%s**.\n\n", chosen.Label)) if nextRoom == RoomBoss { if line := composeBossEntry(zone.ID, run.RunID, nextIdx); line != "" {