Long expeditions D2-b: event-anchored day rollover

Splits the legacy briefing body into nightRolloverBurn + nightRolloverDrift,
plus a processNightCamp convenience. For expeditions started after the new
eventAnchoredCutoff, the 06:00 UTC briefing stops mutating: it posts a
re-engagement DM, and only force-fires processNightCamp itself after a 28h
safety-net window. Day++/burn/threat-drift now ride along the autopilot
night-camp pitch (decideAutopilotCamp sets Night=true when ≥16h since the
last rollover) and on the first player !camp since the last rollover. The
legacy UTC-anchored flow still works via the same staged helpers, with
processOvernightCamp interleaved to preserve the rest-before-drift ordering.

Tests pin eventAnchoredCutoff to year 9999 in TestMain so the existing
legacy assertions still run; new tests cover the night decision, the
night-camp pitch advancing the day, the event-anchored skip, and the
safety-net force-rollover.
This commit is contained in:
prosolis
2026-05-27 18:42:57 -07:00
parent 7115c536ef
commit c729433353
11 changed files with 579 additions and 109 deletions

View File

@@ -32,8 +32,128 @@ import (
const (
expeditionBriefingHour = 6
expeditionRecapHour = 21
// nightSafetyNet — if an event-anchored expedition's last rollover is
// older than this, the 06:00 UTC briefing ticker force-fires
// processNightCamp itself. Without this, an expedition whose autopilot
// stalled (long combat, missed tick, manual halt) would drift forever
// without burning supplies or advancing days. 28h gives the autopilot
// 4h of slop past a normal day before we step in.
nightSafetyNet = 28 * time.Hour
)
// eventAnchoredCutoff — expeditions started at or after this timestamp
// use the D2-b event-anchored day-rollover model: day++/burn/threat-drift
// fire when the autopilot (or a player !camp) pitches a night camp, and
// the 06:00 UTC briefing becomes a re-engagement DM with a safety-net
// force. Expeditions started before this stay on the legacy UTC-anchored
// briefing rollover until they end.
var eventAnchoredCutoff = time.Date(2026, 5, 27, 18, 0, 0, 0, time.UTC)
// isEventAnchored — true when this expedition uses the D2-b model.
func isEventAnchored(e *Expedition) bool {
if e == nil {
return false
}
return !e.StartDate.Before(eventAnchoredCutoff)
}
// nightRolloverResult — the side effects processNightCamp produced, so
// callers (autopilot pitch, manual !camp, safety-net briefing) can render
// the same numbers into their own DM block.
type nightRolloverResult struct {
Burn float32
TemporalLines []string
MilestoneLines []string
Starved bool
}
// nightRolloverBurn — stage 1 of the day rollover: zone temporal pre-burn
// + daily supply burn + current_day++. Returns the burn applied. Callers
// follow this with applyCampRest (if pitching) and then nightRolloverDrift
// to finish the rollover; legacy deliverBriefing interleaves processOvernightCamp
// between the two so a fortified camp's 5 lands before drift's +3.
func (p *AdventurePlugin) nightRolloverBurn(e *Expedition) (float32, error) {
burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1)
var newSupplies ExpeditionSupplies
var burn float32
if burnOverride.Multiplier > 0 {
burn = e.Supplies.DailyBurn * burnOverride.Multiplier * float32(phase5BDailyBurnRatePct) / 100
newSupplies = e.Supplies
newSupplies.Current -= burn
if newSupplies.Current < 0 {
newSupplies.Current = 0
}
newSupplies.ForagedToday = false
} else {
harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e)
newSupplies, burn = applyDailyBurn(e.Supplies, harsh, e.SiegeMode)
}
if err := updateSupplies(e.ID, newSupplies); err != nil {
return 0, err
}
if err := advanceExpeditionDay(e.ID); err != nil {
return 0, err
}
e.Supplies = newSupplies
e.CurrentDay++
return burn, nil
}
// nightRolloverDrift — stage 2: daily threat drift, zone temporal post-
// rollover, starvation check, max-threat record, milestones. Stamps
// last_briefing_at = now so the UTC briefing ticker treats today as
// already-rolled. `now` is the wallclock to stamp; callers that already
// did the stamp via a CAS (deliverBriefing) pass time.Time{} to skip.
func (p *AdventurePlugin) nightRolloverDrift(e *Expedition, now time.Time) nightRolloverResult {
var out nightRolloverResult
if _, _, err := applyDailyThreatDrift(e); err != nil {
slog.Warn("expedition: threat drift", "expedition", e.ID, "err", err)
}
out.TemporalLines = applyZoneTemporalPostRollover(e)
if supplyDepletion(e.Supplies) == SupplyStarvation && e.Status == ExpeditionStatusActive {
_, _, _ = forcedExtractExpedition(e.ID, "starvation")
e.Status = ExpeditionStatusAbandoned
line := flavor.Pick(flavor.ExtractionForced)
_ = appendExpeditionLog(e.ID, e.CurrentDay, "narrative",
"forced extraction: starvation", line)
out.Starved = true
}
if e.Status == ExpeditionStatusAbandoned && p.euro != nil {
tax := int(float64(e.CoinsEarned) * forcedExtractCoinTaxFrac)
if tax > 0 {
p.euro.Debit(id.UserID(e.UserID), float64(tax),
"forced extraction tax")
}
}
_ = recordMaxThreat(e)
out.MilestoneLines = p.checkDailyMilestones(e)
if !now.IsZero() {
if _, err := db.Get().Exec(`
UPDATE dnd_expedition
SET last_briefing_at = ?
WHERE expedition_id = ?`, now, e.ID); err == nil {
e.LastBriefingAt = &now
}
}
return out
}
// processNightCamp — burn + drift in one go, no rest in between. Used by
// callers (autopilot pitch, manual !camp, event-anchored safety net) that
// either apply their own rest separately or don't apply one at all.
func (p *AdventurePlugin) processNightCamp(e *Expedition) (nightRolloverResult, error) {
burn, err := p.nightRolloverBurn(e)
if err != nil {
return nightRolloverResult{}, err
}
out := p.nightRolloverDrift(e, time.Now().UTC())
out.Burn = burn
return out, nil
}
// expeditionBriefingTicker — 06:00 UTC daily briefing.
func (p *AdventurePlugin) expeditionBriefingTicker() {
ticker := time.NewTicker(1 * time.Minute)
@@ -176,14 +296,17 @@ func scanExpeditionRows(rows *sql.Rows) ([]*Expedition, error) {
return out, rows.Err()
}
// deliverBriefing rolls a day forward, applies supply burn, posts the
// morning briefing DM, appends a log entry, and stamps last_briefing_at.
// deliverBriefing posts the morning briefing DM. For legacy UTC-anchored
// expeditions it also drives the day rollover (supply burn, day++, threat
// drift) via processNightCamp. For event-anchored expeditions (D2-b) the
// rollover is owned by the autopilot's night-camp pitch; the briefing
// ticker only re-engages the player and force-fires the rollover after a
// safety-net window.
//
// Idempotency: the first thing we do is an atomic compare-and-set on
// last_briefing_at. If another invocation (clock skew, restart, double
// fire) already claimed today's rollover, rowsAffected == 0 and we bail
// without re-applying supply burn / day++ / threat drift.
// Idempotency: atomic compare-and-set on last_briefing_at gates the body.
// A double-fire on the same expedition is a no-op.
func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
priorBriefing := e.LastBriefingAt
threshold := time.Date(now.Year(), now.Month(), now.Day(),
expeditionBriefingHour, 0, 0, 0, time.UTC)
res, err := db.Get().Exec(`
@@ -200,45 +323,24 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
if n, _ := res.RowsAffected(); n == 0 {
return nil // already delivered for this day
}
e.LastBriefingAt = &now
// E3: zone-specific temporal events fire BEFORE applyDailyBurn and
// can override the entire burn calculation with a fixed multiplier
// (Sunken Temple tidal 2.0×, Feywild half-day 0.5×, etc.).
burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1)
// Advance day + supply burn happen together at the morning rollover.
var newSupplies ExpeditionSupplies
var burn float32
if burnOverride.Multiplier > 0 {
// Phase 5-B: temporal overrides bypass applyDailyBurn, so apply
// the same global burn-rate multiplier explicitly here. Without
// this, tidal/unraveling days would burn at pre-Phase-5-B rates
// while normal days burn at half — an inconsistency the user
// would feel as "tidal days are now disproportionately harsh."
burn = e.Supplies.DailyBurn * burnOverride.Multiplier * float32(phase5BDailyBurnRatePct) / 100
newSupplies = e.Supplies
newSupplies.Current -= burn
if newSupplies.Current < 0 {
newSupplies.Current = 0
}
newSupplies.ForagedToday = false
} else {
harsh := e.ThreatLevel > 60 || zoneTemporalHarsh(e)
newSupplies, burn = applyDailyBurn(e.Supplies, harsh, e.SiegeMode)
// D2-b: event-anchored expeditions own the day rollover via the
// autopilot night camp. The 06:00 ticker either posts a re-engagement
// DM (rollover happened recently) or force-fires processNightCamp
// itself (safety net for stalled autopilots).
if isEventAnchored(e) {
return p.deliverBriefingEventAnchored(e, priorBriefing)
}
if err := updateSupplies(e.ID, newSupplies); err != nil {
burn, err := p.nightRolloverBurn(e)
if err != nil {
return err
}
if err := advanceExpeditionDay(e.ID); err != nil {
return err
}
e.Supplies = newSupplies
e.CurrentDay++
// E2d: overnight camp rest effects (HP/spells/threat). Auto-breaks
// the camp after applying. Run before threat drift so a fortified
// camp's 5 lands first and a same-day +3 drift can't push back over
// a threshold the rest just dropped.
// E2d: overnight camp rest. Runs after burn/day++ but before drift so
// a fortified camp's 5 lands first and a same-day +3 drift can't push
// back over a threshold the rest just dropped.
restSummary := processOvernightCamp(e)
if restSummary != "" {
if fresh, err := getExpedition(e.ID); err == nil && fresh != nil {
@@ -248,54 +350,20 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
}
}
// E2a: daily threat drift (+3 base, GM-mood modifier). No-op after
// boss kill. May cross a threshold and append a flavor-bearing log.
if _, _, err := applyDailyThreatDrift(e); err != nil {
slog.Warn("expedition: threat drift", "expedition", e.ID, "err", err)
}
// E3: zone temporal events post-rollover narration (after the day
// has advanced, so e.CurrentDay reflects the new day).
temporalLines := applyZoneTemporalPostRollover(e)
// §4.3: starvation triggers forced extraction. With no CON tracking
// in this layer, the briefing-time check is the practical equivalent
// of "CON reaches 0" — a starvation morning means the player can't
// reasonably press on. Apply the §10.2 coin tax and flip status.
if supplyDepletion(e.Supplies) == SupplyStarvation && e.Status == ExpeditionStatusActive {
_, _, _ = forcedExtractExpedition(e.ID, "starvation")
e.Status = ExpeditionStatusAbandoned
line := flavor.Pick(flavor.ExtractionForced)
_ = appendExpeditionLog(e.ID, e.CurrentDay, "narrative",
"forced extraction: starvation", line)
}
// E5b: if a temporal event (or starvation above) forced extraction,
// apply the §10.2 coin tax. The temporal layer flips the row to
// 'abandoned'; the cycle holds the euro handle to do the debit.
if e.Status == ExpeditionStatusAbandoned && p.euro != nil {
tax := int(float64(e.CoinsEarned) * forcedExtractCoinTaxFrac)
if tax > 0 {
p.euro.Debit(id.UserID(e.UserID), float64(tax),
"forced extraction tax")
}
}
// E6b: sample today's threat into RegionState["max_threat_seen"] before
// any milestone check reads it; then award daily milestones reached by
// the new day count (First Night day 2, Week One day 8, Two Weeks day 15).
_ = recordMaxThreat(e)
milestoneLines := p.checkDailyMilestones(e)
// Pass time.Time{} — the CAS at the top of deliverBriefing already
// stamped last_briefing_at with the synthetic now; don't overwrite it.
roll := p.nightRolloverDrift(e, time.Time{})
roll.Burn = burn
line := pickMorningBriefing(e.CurrentDay)
body := renderMorningBriefing(e, line, burn)
if restSummary != "" {
body += "\n💤 _" + restSummary + "_\n"
}
for _, tl := range temporalLines {
for _, tl := range roll.TemporalLines {
body += "\n🌀 " + tl + "\n"
}
for _, ml := range milestoneLines {
for _, ml := range roll.MilestoneLines {
body += "\n" + ml
}
@@ -335,6 +403,80 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
return nil
}
// deliverBriefingEventAnchored — D2-b 06:00 UTC ticker for event-anchored
// expeditions. The autopilot's night-camp pitch owns day++/burn/threat-
// drift; the ticker just re-engages the player. If the autopilot has
// stalled past nightSafetyNet we force-fire processNightCamp ourselves so
// the expedition doesn't sit frozen.
//
// priorBriefing is the last_briefing_at value as of entry into deliverBriefing
// (before the CAS clobbered it). nil means day-1 or genuinely never rolled.
func (p *AdventurePlugin) deliverBriefingEventAnchored(e *Expedition, priorBriefing *time.Time) error {
now := time.Now().UTC()
var since time.Duration
if priorBriefing != nil {
since = now.Sub(*priorBriefing)
} else {
since = now.Sub(e.StartDate)
}
forced := since > nightSafetyNet
var (
burn float32
temporalLines []string
mileLines []string
)
if forced {
roll, err := p.processNightCamp(e)
if err != nil {
return err
}
burn = roll.Burn
temporalLines = roll.TemporalLines
mileLines = roll.MilestoneLines
}
line := pickMorningBriefing(e.CurrentDay)
body := renderMorningBriefing(e, line, burn)
if forced {
body += "\n_The autopilot stalled overnight; the day rolled over without rest._\n"
}
for _, tl := range temporalLines {
body += "\n🌀 " + tl + "\n"
}
for _, ml := range mileLines {
body += "\n" + ml
}
if uid := id.UserID(e.UserID); uid != "" {
if pet, perr := loadPetState(uid); perr == nil {
if petEvent := petMorningEvent(pet); petEvent != "" {
if char, cerr := loadAdvCharacter(uid); cerr == nil {
char.PetMorningDefense = true
if serr := saveAdvCharacter(char); serr != nil {
slog.Warn("expedition: save pet morning defense", "user", uid, "err", serr)
}
}
body = fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, body)
}
}
if err := p.SendDM(uid, body); err != nil {
slog.Warn("expedition: send briefing DM", "user", uid, "err", err)
}
if forced && e.Status == ExpeditionStatusAbandoned {
p.maybeRollPetArrivalOnEmerge(uid)
}
}
summary := "morning re-engagement"
if forced {
summary = fmt.Sprintf("safety-net rollover — %.1f SU consumed overnight", burn)
}
if err := appendExpeditionLog(e.ID, e.CurrentDay, "briefing", summary, line); err != nil {
return err
}
return nil
}
// deliverRecap posts the evening recap DM, appends a log entry, and stamps
// last_recap_at. No supply burn here — that's the briefing's job.
// Idempotency: same pattern as deliverBriefing — claim last_recap_at first.