diff --git a/internal/plugin/dnd_expedition.go b/internal/plugin/dnd_expedition.go index 5d7f1f1..b39c33b 100644 --- a/internal/plugin/dnd_expedition.go +++ b/internal/plugin/dnd_expedition.go @@ -53,6 +53,10 @@ type CampState struct { RoomIndex int `json:"room_index"` EstablishedAt time.Time `json:"established_at"` NightEvents []string `json:"night_events"` + // RestApplied is set when the long-rest effects (HP refill, spell slots, + // threat -5 etc.) have already been applied at pitch time. processOvernightCamp + // uses it to skip re-applying so the night cycle just breaks the camp. + RestApplied bool `json:"rest_applied,omitempty"` } // ThreatEvent — §8.4. diff --git a/internal/plugin/dnd_expedition_camp.go b/internal/plugin/dnd_expedition_camp.go index f7e1861..e34a6be 100644 --- a/internal/plugin/dnd_expedition_camp.go +++ b/internal/plugin/dnd_expedition_camp.go @@ -174,6 +174,16 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st if err := updateCamp(exp.ID, camp); err != nil { return p.SendDM(ctx.Sender, "Couldn't pitch camp: "+err.Error()) } + exp.Camp = camp + + // Apply the long-rest effects immediately so the player isn't waiting + // until the next 06:00 UTC briefing for HP and spell slots to come + // back. The flag tells processOvernightCamp not to re-apply at briefing. + restSummary := applyCampRest(exp, kind) + camp.RestApplied = true + if err := updateCamp(exp.ID, camp); err != nil { + slog.Warn("camp: mark rest applied", "expedition", exp.ID, "err", err) + } // E4d: pick the BaseCampEstablished pool for base camps; otherwise // the generic camp pool. Both already handle [N] day interpolation @@ -204,40 +214,24 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st if line != "" { b.WriteString("\n" + line + "\n") } + if restSummary != "" { + b.WriteString("\n" + restSummary + "\n") + } switch kind { - case CampTypeRough: - b.WriteString("\n_Rough camp — partial rest. HP recovers to 50%, no spell slots restored._") - case CampTypeFortified: - b.WriteString("\n_Fortified camp — long rest + 1d6 HP bonus on wake; threat clock −5; wandering rolls −4._") case CampTypeBase: - b.WriteString("\n_Base camp — long rest + 1d6 HP bonus; threat clock −5; wandering rolls −6. **Waypoint persisted** — camp here again at no eligibility cost on later returns._") - default: - b.WriteString("\n_Standard camp — full long rest at the next morning briefing._") + b.WriteString("\n_Base camp — **waypoint persisted**. Camp here again at no eligibility cost on later returns._") } return p.SendDM(ctx.Sender, b.String()) } -// processOvernightCamp applies the overnight long-rest effects of an -// active camp at briefing time (§3, §5.1, §8.1). Auto-breaks the camp -// after the rest. Returns a one-line summary for the briefing body. -// -// Effects: -// - rough: HP recovered to at least 50% of max. -// - standard: HP fully restored, spell slots refreshed, exhaustion -1. -// - fortified: standard + 1d6 HP bonus on top, threat -5. -// - base: same as fortified for the rest itself; persistent waypoint -// mechanics land in E4. -// -// Returns "" if the expedition wasn't camped overnight. -func processOvernightCamp(e *Expedition) string { - if e.Camp == nil || !e.Camp.Active { - return "" - } +// applyCampRest runs the long-rest effects (HP/spells/threat/heat) for the +// given camp kind and returns a player-facing summary. Shared by camp pitch +// (immediate rest) and overnight rollover (legacy deferred path). Does NOT +// break the camp — callers decide that. +func applyCampRest(e *Expedition, kind string) string { uid := id.UserID(e.UserID) c, _ := LoadDnDCharacter(uid) if c == nil { - // No character to apply HP/spells to; just break the camp. - _ = updateCamp(e.ID, nil) return "" } // Babysit safe-rest: an active subscription promotes a Standard camp @@ -245,7 +239,6 @@ func processOvernightCamp(e *Expedition) string { // Rough/Base are unchanged — Rough still implies no shelter, and Base // already exceeds Fortified. babysitUpgraded := false - kind := e.Camp.Type if kind == CampTypeStandard && BabysitSafeRest(uid) { kind = CampTypeFortified babysitUpgraded = true @@ -297,11 +290,6 @@ func processOvernightCamp(e *Expedition) string { heatReduced = before - after } - // Auto-break the camp now that the rest has been applied. - _ = updateCamp(e.ID, nil) - e.Camp = nil - - // Pretty summary for the briefing body. switch kind { case CampTypeRough: if c.HPCurrent > prevHP { @@ -325,6 +313,24 @@ func processOvernightCamp(e *Expedition) string { return "" } +// processOvernightCamp handles the briefing-time half of the camp lifecycle: +// auto-breaks the camp, and applies the long rest only if it wasn't already +// applied at pitch time. Returns a one-line summary for the briefing body, or +// "" if there was nothing to do. +func processOvernightCamp(e *Expedition) string { + if e.Camp == nil || !e.Camp.Active { + return "" + } + kind := e.Camp.Type + alreadyApplied := e.Camp.RestApplied + _ = updateCamp(e.ID, nil) + e.Camp = nil + if alreadyApplied { + return "" + } + return applyCampRest(e, kind) +} + func (p *AdventurePlugin) campBreak(ctx MessageContext, exp *Expedition) error { if exp.Camp == nil || !exp.Camp.Active { return p.SendDM(ctx.Sender, "No camp to break.") diff --git a/internal/plugin/expedition_ambient.go b/internal/plugin/expedition_ambient.go index 185e78c..2e28e64 100644 --- a/internal/plugin/expedition_ambient.go +++ b/internal/plugin/expedition_ambient.go @@ -35,9 +35,9 @@ import ( const ( // ambientCooldown — minimum time between ambient events for one - // expedition. Tuned so a player offline for a workday sees ~3 hits, - // not a flood, but a multi-day expedition feels alive. - ambientCooldown = 3 * time.Hour + // expedition. Tuned so a player offline for a workday sees ~1–2 hits, + // not a flood, but a multi-day expedition still feels alive. + ambientCooldown = 6 * time.Hour // ambientNearScheduleWindow — skip if we're within this many minutes // of a scheduled briefing (06:00 UTC) or recap (21:00 UTC).