Camp: heal on pitch; ambient cooldown 3h → 6h

!camp now applies long-rest effects (HP, spell slots, exhaustion,
threat -5, Underforge heat) immediately instead of waiting for the
06:00 UTC briefing. New CampState.RestApplied flag stops
processOvernightCamp from re-applying at briefing — it just strikes
the camp.

Also dials ambient cooldown 3h → 6h so a workday gives ~1–2 hits
instead of ~3.
This commit is contained in:
prosolis
2026-05-27 16:57:57 -07:00
parent 0f72484653
commit 92b99a0399
3 changed files with 44 additions and 34 deletions

View File

@@ -53,6 +53,10 @@ type CampState struct {
RoomIndex int `json:"room_index"` RoomIndex int `json:"room_index"`
EstablishedAt time.Time `json:"established_at"` EstablishedAt time.Time `json:"established_at"`
NightEvents []string `json:"night_events"` 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. // ThreatEvent — §8.4.

View File

@@ -174,6 +174,16 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
if err := updateCamp(exp.ID, camp); err != nil { if err := updateCamp(exp.ID, camp); err != nil {
return p.SendDM(ctx.Sender, "Couldn't pitch camp: "+err.Error()) 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 // E4d: pick the BaseCampEstablished pool for base camps; otherwise
// the generic camp pool. Both already handle [N] day interpolation // 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 != "" { if line != "" {
b.WriteString("\n" + line + "\n") b.WriteString("\n" + line + "\n")
} }
if restSummary != "" {
b.WriteString("\n" + restSummary + "\n")
}
switch kind { 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: 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._") b.WriteString("\n_Base camp — **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._")
} }
return p.SendDM(ctx.Sender, b.String()) return p.SendDM(ctx.Sender, b.String())
} }
// processOvernightCamp applies the overnight long-rest effects of an // applyCampRest runs the long-rest effects (HP/spells/threat/heat) for the
// active camp at briefing time (§3, §5.1, §8.1). Auto-breaks the camp // given camp kind and returns a player-facing summary. Shared by camp pitch
// after the rest. Returns a one-line summary for the briefing body. // (immediate rest) and overnight rollover (legacy deferred path). Does NOT
// // break the camp — callers decide that.
// Effects: func applyCampRest(e *Expedition, kind string) string {
// - 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 ""
}
uid := id.UserID(e.UserID) uid := id.UserID(e.UserID)
c, _ := LoadDnDCharacter(uid) c, _ := LoadDnDCharacter(uid)
if c == nil { if c == nil {
// No character to apply HP/spells to; just break the camp.
_ = updateCamp(e.ID, nil)
return "" return ""
} }
// Babysit safe-rest: an active subscription promotes a Standard camp // 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 // Rough/Base are unchanged — Rough still implies no shelter, and Base
// already exceeds Fortified. // already exceeds Fortified.
babysitUpgraded := false babysitUpgraded := false
kind := e.Camp.Type
if kind == CampTypeStandard && BabysitSafeRest(uid) { if kind == CampTypeStandard && BabysitSafeRest(uid) {
kind = CampTypeFortified kind = CampTypeFortified
babysitUpgraded = true babysitUpgraded = true
@@ -297,11 +290,6 @@ func processOvernightCamp(e *Expedition) string {
heatReduced = before - after 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 { switch kind {
case CampTypeRough: case CampTypeRough:
if c.HPCurrent > prevHP { if c.HPCurrent > prevHP {
@@ -325,6 +313,24 @@ func processOvernightCamp(e *Expedition) string {
return "" 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 { func (p *AdventurePlugin) campBreak(ctx MessageContext, exp *Expedition) error {
if exp.Camp == nil || !exp.Camp.Active { if exp.Camp == nil || !exp.Camp.Active {
return p.SendDM(ctx.Sender, "No camp to break.") return p.SendDM(ctx.Sender, "No camp to break.")

View File

@@ -35,9 +35,9 @@ import (
const ( const (
// ambientCooldown — minimum time between ambient events for one // ambientCooldown — minimum time between ambient events for one
// expedition. Tuned so a player offline for a workday sees ~3 hits, // expedition. Tuned so a player offline for a workday sees ~12 hits,
// not a flood, but a multi-day expedition feels alive. // not a flood, but a multi-day expedition still feels alive.
ambientCooldown = 3 * time.Hour ambientCooldown = 6 * time.Hour
// ambientNearScheduleWindow — skip if we're within this many minutes // ambientNearScheduleWindow — skip if we're within this many minutes
// of a scheduled briefing (06:00 UTC) or recap (21:00 UTC). // of a scheduled briefing (06:00 UTC) or recap (21:00 UTC).