Camp: auto-break when party moves to a different room

Camp was a stationary "rest here until the next briefing" intent, but
nothing struck it when the autopilot / !zone advance / fork picks walked
the party into a new room. The stale CampState then blocked !camp <type>
with "already camped" even though the player was several rooms away.

autoBreakCampOnMove clears the camp (no rest bonuses — those only land
at briefing time via processOvernightCamp) whenever Camp.RoomIndex no
longer matches run.CurrentRoom. Wired into advanceOnceWithOpts (covers
advance/autopilot, complete/fork/next-room branches all surface a
" Camp struck" banner) and zoneCmdGo (fork commit).
This commit is contained in:
prosolis
2026-05-23 10:05:19 -07:00
parent 2ce56cf76a
commit 9eed921e4b
3 changed files with 51 additions and 0 deletions

View File

@@ -384,6 +384,41 @@ func campLocationCheck(exp *Expedition) (cleared bool, problem string) {
return true, "" 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. // campCurrentRoomIndex returns 0 (entry) when no room context exists.
func campCurrentRoomIndex(exp *Expedition) int { func campCurrentRoomIndex(exp *Expedition) int {
if exp.RunID == "" { if exp.RunID == "" {

View File

@@ -606,6 +606,10 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
if gerr != nil { if gerr != nil {
return advanceResult{}, fmt.Errorf("Couldn't advance: %s", gerr.Error()) 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 { if complete {
_, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete) _, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete)
var b strings.Builder var b strings.Builder
@@ -613,6 +617,9 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
b.WriteString(outcome) b.WriteString(outcome)
b.WriteString("\n\n") 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 // 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 // region clear of a multi-region zone. For the latter, name the region
// and point at the next one — "Cleared {zone}. Run complete." reads // 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("\n\n")
} }
b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev))) b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev)))
if campStruck != "" {
b.WriteString(campStruck)
}
b.WriteString(forkMsg) b.WriteString(forkMsg)
return advanceResult{ return advanceResult{
preStream: preStream, preStream: preStream,
@@ -668,6 +678,9 @@ func (p *AdventurePlugin) advanceOnceWithOpts(ctx MessageContext, compact bool)
}, nil }, nil
} }
finalMsg := p.formatNextRoomMessage(run, zone, prev, prevIdx, outcome, next) 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 // H2 — auto-harvest the room the player just walked into. Only fires
// for Exploration rooms (Entry/Trap/Elite/Boss self-skip via // for Exploration rooms (Entry/Trap/Elite/Boss self-skip via

View File

@@ -196,6 +196,9 @@ func (p *AdventurePlugin) zoneCmdGo(ctx MessageContext, rest string) error {
nextRoom := nodeKindToRoomType(nextNode.Kind) nextRoom := nodeKindToRoomType(nextNode.Kind)
nextIdx := run.CurrentRoom + 1 nextIdx := run.CurrentRoom + 1
var b strings.Builder 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)) b.WriteString(fmt.Sprintf("➡ You take the path: **%s**.\n\n", chosen.Label))
if nextRoom == RoomBoss { if nextRoom == RoomBoss {
if line := composeBossEntry(zone.ID, run.RunID, nextIdx); line != "" { if line := composeBossEntry(zone.ID, run.RunID, nextIdx); line != "" {