From c729433353f31be74d5355333ab6420ff0ed8575 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Wed, 27 May 2026 18:42:57 -0700 Subject: [PATCH] Long expeditions D2-b: event-anchored day rollover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- gogobee_long_expedition_plan.md | 2 + internal/plugin/adv2_scenario_test.go | 7 +- internal/plugin/dnd_audit_phase_R7_test.go | 4 +- internal/plugin/dnd_expedition_camp.go | 37 +++ internal/plugin/dnd_expedition_cycle.go | 302 +++++++++++++----- internal/plugin/dnd_expedition_cycle_test.go | 86 +++++ .../plugin/dnd_expedition_temporal_test.go | 3 + internal/plugin/dnd_expedition_threat_test.go | 1 + internal/plugin/expedition_autocamp.go | 145 +++++++-- internal/plugin/expedition_autocamp_test.go | 95 ++++++ internal/plugin/holdem_tip_scenarios_test.go | 6 + 11 files changed, 579 insertions(+), 109 deletions(-) diff --git a/gogobee_long_expedition_plan.md b/gogobee_long_expedition_plan.md index c68ee6e..c714886 100644 --- a/gogobee_long_expedition_plan.md +++ b/gogobee_long_expedition_plan.md @@ -58,6 +58,8 @@ Built across multiple sessions; each phase ships independently with tests and a **D2-a (shipped 2026-05-27):** new `expedition_autocamp.go` with pure `decideAutopilotCamp` + `pitchAutopilotCamp` + dwell-window lifecycle (`shouldSkipAutoRunForCamp`, `breakAutoCampIfDue`). Wired into `tryAutoRun`: skip while inside `minAutoCampDwell` (4h), break + walk past it, post-walk scheduler pitches HP-low rest camps and region-boss base-camp waypoints. CampState gained `AutoPitched` so auto- vs player-pitched lifetimes diverge. Day rollover is still UTC-anchored; D2-b moves it event-anchored. +**D2-b (shipped 2026-05-27):** event-anchored day rollover. `dnd_expedition_cycle.go` adds `eventAnchoredCutoff` + `isEventAnchored`, splits the briefing body into `nightRolloverBurn` + `nightRolloverDrift` (+ a convenience `processNightCamp` that runs both back-to-back). For expeditions started ≥ cutoff, `deliverBriefing` skips mutators and posts a re-engagement DM; if `last_briefing_at` is older than `nightSafetyNet` (28h) it force-fires `processNightCamp` so a stalled autopilot doesn't freeze the expedition. The autopilot scheduler grew a `Night` flag — `decideAutopilotCamp` sets it when `time.Since(LastBriefingAt) ≥ nightCampWindow` (16h); `pitchAutopilotCamp` then runs the burn → camp → rest → drift sequence in one pitch. Manual `!camp ` runs the same flow when it's the first camp since the last rollover. Legacy UTC-anchored expeditions (start_date < cutoff) keep the original mutator flow via the same staged helpers, preserving rest-before-drift ordering through `processOvernightCamp`. Tests fence cutoff to year 9999 in `TestMain` so existing legacy assertions stand; new tests exercise the night decision, night-pitch rollover, event-anchored skip, and safety-net force. + **Files:** new `expedition_autocamp.go`; hooks in `expedition_autorun.go:tryAutoRun`; reuse `dnd_expedition_camp.go:campPitch` / `applyCampRest`. **Heuristics (in priority order):** diff --git a/internal/plugin/adv2_scenario_test.go b/internal/plugin/adv2_scenario_test.go index 3b619d3..b3bc812 100644 --- a/internal/plugin/adv2_scenario_test.go +++ b/internal/plugin/adv2_scenario_test.go @@ -221,10 +221,9 @@ func TestAdv2Scenario_ExpeditionCryptValdris(t *testing.T) { t.Errorf("expected outfitting to debit coins (%.2f → %.2f)", balBefore, balAfter) } - // Backdate start so deliverBriefing's same-day guard passes. - if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil { - t.Fatalf("backdate: %v", err) - } + // Backdate start so deliverBriefing's same-day guard passes, and to + // before eventAnchoredCutoff so the legacy mutator path still fires. + rewindToLegacyAnchor(t, exp) // Drive 3 daily briefings — verify supply burn + day advance. now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC) diff --git a/internal/plugin/dnd_audit_phase_R7_test.go b/internal/plugin/dnd_audit_phase_R7_test.go index d26c78e..6de9328 100644 --- a/internal/plugin/dnd_audit_phase_R7_test.go +++ b/internal/plugin/dnd_audit_phase_R7_test.go @@ -168,9 +168,7 @@ func TestDeliverBriefing_StarvationForcesExtraction(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil { - t.Fatalf("backdate: %v", err) - } + rewindToLegacyAnchor(t, exp) p := &AdventurePlugin{} now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC) diff --git a/internal/plugin/dnd_expedition_camp.go b/internal/plugin/dnd_expedition_camp.go index e34a6be..aa6a6bb 100644 --- a/internal/plugin/dnd_expedition_camp.go +++ b/internal/plugin/dnd_expedition_camp.go @@ -160,6 +160,30 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st cost, exp.Supplies.Current)) } + // D2-b: event-anchored manual !camp counts as the night camp if it's + // the first camp since the last rollover. Burn supplies *before* the + // camp cost so the burn lands against pre-pitch supplies (matches the + // legacy morning-burn ordering), then pitch, then drift. + nightCamp := false + var nightBurn float32 + var nightRoll nightRolloverResult + if isEventAnchored(exp) { + var since time.Duration + if exp.LastBriefingAt != nil { + since = time.Now().UTC().Sub(*exp.LastBriefingAt) + } else { + since = time.Now().UTC().Sub(exp.StartDate) + } + if since >= nightCampWindow { + nightCamp = true + burn, err := p.nightRolloverBurn(exp) + if err != nil { + return p.SendDM(ctx.Sender, "Couldn't burn night supplies: "+err.Error()) + } + nightBurn = burn + } + } + exp.Supplies.Current -= cost if err := updateSupplies(exp.ID, exp.Supplies); err != nil { return p.SendDM(ctx.Sender, "Couldn't deduct supplies: "+err.Error()) @@ -180,6 +204,10 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st // 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) + if nightCamp { + nightRoll = p.nightRolloverDrift(exp, time.Now().UTC()) + nightRoll.Burn = nightBurn + } camp.RestApplied = true if err := updateCamp(exp.ID, camp); err != nil { slog.Warn("camp: mark rest applied", "expedition", exp.ID, "err", err) @@ -221,6 +249,15 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st case CampTypeBase: b.WriteString("\n_Base camp — **waypoint persisted**. Camp here again at no eligibility cost on later returns._") } + if nightCamp { + b.WriteString(fmt.Sprintf("\n\n🌙 **Day %d.** _Overnight burn: %.1f SU._\n", exp.CurrentDay, nightRoll.Burn)) + for _, tl := range nightRoll.TemporalLines { + b.WriteString("\n🌀 " + tl + "\n") + } + for _, ml := range nightRoll.MilestoneLines { + b.WriteString("\n" + ml) + } + } return p.SendDM(ctx.Sender, b.String()) } diff --git a/internal/plugin/dnd_expedition_cycle.go b/internal/plugin/dnd_expedition_cycle.go index ddc7c2f..a929ca4 100644 --- a/internal/plugin/dnd_expedition_cycle.go +++ b/internal/plugin/dnd_expedition_cycle.go @@ -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. diff --git a/internal/plugin/dnd_expedition_cycle_test.go b/internal/plugin/dnd_expedition_cycle_test.go index da10d90..0ea7485 100644 --- a/internal/plugin/dnd_expedition_cycle_test.go +++ b/internal/plugin/dnd_expedition_cycle_test.go @@ -14,6 +14,20 @@ import ( // synthetic "now" and verify state transitions. Ticker scheduling is a // thin wrapper over those. +// rewindToLegacyAnchor backdates an expedition's start_date to before +// eventAnchoredCutoff so deliverBriefing exercises the legacy UTC-anchored +// mutator path. Tests of the D2-b event-anchored path should NOT call this. +func rewindToLegacyAnchor(t *testing.T, exp *Expedition) { + t.Helper() + before := eventAnchoredCutoff.Add(-24 * time.Hour) + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET start_date = ? WHERE expedition_id = ?`, + before, exp.ID); err != nil { + t.Fatal(err) + } + exp.StartDate = before +} + func TestPickMorningBriefing_DayBands(t *testing.T) { cases := []struct { day int @@ -57,6 +71,7 @@ func TestDeliverBriefing_AdvancesDayAndBurnsSupplies(t *testing.T) { if err != nil { t.Fatal(err) } + rewindToLegacyAnchor(t, exp) p := &AdventurePlugin{} now := time.Now().UTC() if err := p.deliverBriefing(exp, now); err != nil { @@ -87,6 +102,76 @@ func TestDeliverBriefing_AdvancesDayAndBurnsSupplies(t *testing.T) { } } +// useEventAnchored fences the eventAnchoredCutoff to before the given +// expedition's start_date so the D2-b path is taken. Test-scoped via t.Cleanup. +func useEventAnchored(t *testing.T, exp *Expedition) { + t.Helper() + saved := eventAnchoredCutoff + eventAnchoredCutoff = exp.StartDate.Add(-time.Minute) + t.Cleanup(func() { eventAnchoredCutoff = saved }) +} + +func TestDeliverBriefing_EventAnchoredSkipsMutators(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-evt-skip:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + useEventAnchored(t, exp) + + // last_briefing_at is NULL and start_date is "now-ish", so the safety + // net should NOT fire — sub-28h since start. + p := &AdventurePlugin{} + if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil { + t.Fatal(err) + } + got, _ := getExpedition(exp.ID) + if got.CurrentDay != 1 { + t.Errorf("day = %d, want 1 (event-anchored briefing should not advance day)", got.CurrentDay) + } + if got.Supplies.Current != 10 { + t.Errorf("supplies = %v, want 10 (event-anchored briefing should not burn)", got.Supplies.Current) + } +} + +func TestDeliverBriefing_EventAnchoredSafetyNetForces(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@exp-evt-safety:example") + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + useEventAnchored(t, exp) + + // Backdate start_date so > nightSafetyNet has elapsed with no rollover. + before := time.Now().UTC().Add(-(nightSafetyNet + time.Hour)) + if _, err := db.Get().Exec( + `UPDATE dnd_expedition SET start_date = ? WHERE expedition_id = ?`, + before, exp.ID); err != nil { + t.Fatal(err) + } + exp.StartDate = before + + p := &AdventurePlugin{} + if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil { + t.Fatal(err) + } + got, _ := getExpedition(exp.ID) + if got.CurrentDay != 2 { + t.Errorf("day = %d, want 2 (safety net should force rollover)", got.CurrentDay) + } + if got.Supplies.Current >= 10 { + t.Errorf("supplies = %v, want < 10 (safety net should burn)", got.Supplies.Current) + } +} + func TestDeliverBriefing_HarshConditionsBurnFaster(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@exp-cycle-harsh:example") @@ -97,6 +182,7 @@ func TestDeliverBriefing_HarshConditionsBurnFaster(t *testing.T) { if err != nil { t.Fatal(err) } + rewindToLegacyAnchor(t, exp) // Force harsh conditions via threat clock above 60. if err := applyThreatDelta(exp.ID, 70, "test"); err != nil { t.Fatal(err) diff --git a/internal/plugin/dnd_expedition_temporal_test.go b/internal/plugin/dnd_expedition_temporal_test.go index edff260..9aff30c 100644 --- a/internal/plugin/dnd_expedition_temporal_test.go +++ b/internal/plugin/dnd_expedition_temporal_test.go @@ -701,6 +701,7 @@ func TestAbyss_DailyInstabilityIncrements(t *testing.T) { if err != nil { t.Fatal(err) } + rewindToLegacyAnchor(t, exp) p := &AdventurePlugin{} // Three briefings → instability should hit 15. for i := 0; i < 3; i++ { @@ -732,6 +733,7 @@ func TestAbyss_UnravelingDoublesBurn(t *testing.T) { if err != nil { t.Fatal(err) } + rewindToLegacyAnchor(t, exp) // Set instability to 85 (unravel band). if _, err := db.Get().Exec( `UPDATE dnd_expedition SET temporal_stack = 85 WHERE expedition_id = ?`, @@ -760,6 +762,7 @@ func TestAbyss_CollapseFailsExpedition(t *testing.T) { if err != nil { t.Fatal(err) } + rewindToLegacyAnchor(t, exp) // Set instability to 95 — next daily +5 lands on 100 (collapse). if _, err := db.Get().Exec( `UPDATE dnd_expedition SET temporal_stack = 95 WHERE expedition_id = ?`, diff --git a/internal/plugin/dnd_expedition_threat_test.go b/internal/plugin/dnd_expedition_threat_test.go index b8abbe3..33d2f33 100644 --- a/internal/plugin/dnd_expedition_threat_test.go +++ b/internal/plugin/dnd_expedition_threat_test.go @@ -150,6 +150,7 @@ func TestDeliverBriefing_AppliesThreatDrift(t *testing.T) { if err != nil { t.Fatal(err) } + rewindToLegacyAnchor(t, exp) p := &AdventurePlugin{} if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil { t.Fatal(err) diff --git a/internal/plugin/expedition_autocamp.go b/internal/plugin/expedition_autocamp.go index 77d669e..0f27953 100644 --- a/internal/plugin/expedition_autocamp.go +++ b/internal/plugin/expedition_autocamp.go @@ -36,6 +36,15 @@ const ( // this fraction of max. Set above autopilotLowHPPct (0.30) so the // scheduler camps *before* the walk-preflight gives up. autoCampHPPct = 0.55 + + // nightCampWindow — D2-b. Event-anchored expeditions roll the day on + // autopilot night-camp pitch. After this many hours since the last + // rollover, the next eligible camp is treated as the night camp + // (Night=true) and triggers processNightCamp. 16h gives the player + // most of an active day before the engine starts wanting to bed + // down; a healthy party with no HP pressure still camps at the end + // of the day rather than walking forever. + nightCampWindow = 16 * time.Hour ) // autoCampInputs is the minimal snapshot decideAutopilotCamp needs. @@ -49,13 +58,21 @@ type autoCampInputs struct { BaseAlready bool HPCur, HPMax int Supplies ExpeditionSupplies + // D2-b: event-anchored rollover inputs. + Now time.Time + EventAnchored bool + LastBriefingAt *time.Time + StartDate time.Time } // autoCampDecision — what to pitch and why. Reason is a short log-line -// fragment ("region boss cleared", "HP low"). +// fragment ("region boss cleared", "HP low"). Night=true means the +// caller should run processNightCamp as part of the pitch so the day++/ +// supply burn / threat drift happen alongside the rest. type autoCampDecision struct { Kind string Reason string + Night bool } // decideAutopilotCamp returns the camp to pitch (or ok=false). Pure; @@ -79,21 +96,38 @@ func decideAutopilotCamp(in autoCampInputs) (autoCampDecision, bool) { } } + // D2-b: night-camp window — for event-anchored expeditions, the + // autopilot pitches the day's rollover camp when enough real time + // has elapsed since the last rollover. A flag, not a separate + // branch, so each pitch below can become a night camp. + night := false + if in.EventAnchored { + var since time.Duration + if in.LastBriefingAt != nil { + since = in.Now.Sub(*in.LastBriefingAt) + } else { + since = in.Now.Sub(in.StartDate) + } + night = since >= nightCampWindow + } + // Heuristic — region base-camp waypoint. Eager: pitch once per // eligible region after its boss is down. BaseAlready stops the // next tick from re-pitching the same waypoint. if in.Multi && in.RegionCleared && in.BaseSite && !in.BaseAlready && cleared { if in.Supplies.Current >= campSupplyCost[CampTypeBase] { - return autoCampDecision{Kind: CampTypeBase, Reason: "region boss cleared — pitching base camp waypoint"}, true + return autoCampDecision{ + Kind: CampTypeBase, Night: night, + Reason: "region boss cleared — pitching base camp waypoint", + }, true } } - // Heuristic — HP-low rest. Standard if cleared, rough otherwise. - // LowSU as a *trigger* would just dig the hole deeper (camps burn - // SU); the walk's preflight already pauses on low-SU so the - // player can extract or buy more time. + // Heuristic — HP-low rest OR end-of-day night camp. Standard if + // cleared, rough otherwise. LowSU as a *trigger* would just dig + // the hole deeper; the walk's preflight pauses on low-SU instead. lowHP := in.HPMax > 0 && float64(in.HPCur) <= float64(in.HPMax)*autoCampHPPct - if !lowHP { + if !lowHP && !night { return autoCampDecision{}, false } kind := CampTypeRough @@ -102,14 +136,20 @@ func decideAutopilotCamp(in autoCampInputs) (autoCampDecision, bool) { } cost := campSupplyCost[kind] if in.Supplies.Current < cost { - // Try the cheaper rough tier if standard doesn't fit. if kind == CampTypeStandard && in.Supplies.Current >= campSupplyCost[CampTypeRough] { kind = CampTypeRough } else { return autoCampDecision{}, false } } - return autoCampDecision{Kind: kind, Reason: "HP low — pitching rest camp"}, true + reason := "HP low — pitching rest camp" + switch { + case night && lowHP: + reason = "HP low + end of day — pitching night camp" + case night: + reason = "end of day — pitching night camp" + } + return autoCampDecision{Kind: kind, Reason: reason, Night: night}, true } // maybeAutoCamp gathers DB state, calls decideAutopilotCamp, and pitches @@ -139,15 +179,19 @@ func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) string { hpCur, hpMax := dndHPSnapshot(uid) in := autoCampInputs{ - Camp: exp.Camp, - Run: run, - Multi: multi, - RegionCleared: regionCleared, - BaseSite: baseSite, - BaseAlready: baseAlready, - HPCur: hpCur, - HPMax: hpMax, - Supplies: exp.Supplies, + Camp: exp.Camp, + Run: run, + Multi: multi, + RegionCleared: regionCleared, + BaseSite: baseSite, + BaseAlready: baseAlready, + HPCur: hpCur, + HPMax: hpMax, + Supplies: exp.Supplies, + Now: time.Now().UTC(), + EventAnchored: isEventAnchored(exp), + LastBriefingAt: exp.LastBriefingAt, + StartDate: exp.StartDate, } d, ok := decideAutopilotCamp(in) if !ok { @@ -164,8 +208,37 @@ func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) string { // pitchAutopilotCamp performs the same state mutations as the player // !camp path (supply debit, camp row, applyCampRest, log entry, base- // camp waypoint persist) without DMing — the autorun ticker bundles -// the returned block into the single auto-walk DM. +// the returned block into the single auto-walk DM. When d.Night is true +// (D2-b event-anchored rollover), the day++/burn/threat-drift fire here +// too: nightRolloverBurn before the camp cost (so the burn lands on +// pre-pitch supplies, matching the legacy morning-burn ordering), then +// applyCampRest, then nightRolloverDrift. func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision) (string, error) { + var ( + nightBurn float32 + nightTemp []string + nightMile []string + nightDid bool + nightForce bool + ) + if d.Night { + burn, err := p.nightRolloverBurn(exp) + if err != nil { + return "", err + } + nightBurn = burn + nightDid = true + if exp.Status != ExpeditionStatusActive { + // Starvation during burn is rare (burn alone doesn't trigger + // starvation — that needs supplies <= 0 *after* burn), but + // guard anyway so we don't pitch a camp on an abandoned exp. + drift := p.nightRolloverDrift(exp, time.Now().UTC()) + nightTemp = drift.TemporalLines + nightMile = drift.MilestoneLines + nightForce = true + return renderAutoCampBlock(exp, d, 0, "", "", nightBurn, nightTemp, nightMile, nightDid, nightForce), nil + } + } cost := campSupplyCost[d.Kind] if exp.Supplies.Current < cost { return "", fmt.Errorf("insufficient supplies (have %.1f, need %.1f)", exp.Supplies.Current, cost) @@ -211,13 +284,32 @@ func (p *AdventurePlugin) pitchAutopilotCamp(exp *Expedition, d autoCampDecision } } - return renderAutoCampBlock(exp, d, cost, line, restSummary), nil + if d.Night { + drift := p.nightRolloverDrift(exp, time.Now().UTC()) + nightTemp = drift.TemporalLines + nightMile = drift.MilestoneLines + } + + return renderAutoCampBlock(exp, d, cost, line, restSummary, + nightBurn, nightTemp, nightMile, nightDid, nightForce), nil } // renderAutoCampBlock formats the autopilot-camp section appended to // the auto-walk DM. Kept short — the autorun DM already opens with the -// walk narration. -func renderAutoCampBlock(exp *Expedition, d autoCampDecision, cost float32, flavorLine, restSummary string) string { +// walk narration. nightBurn/nightTemp/nightMile carry the D2-b rollover +// side effects when the pitch was a night camp. +func renderAutoCampBlock(exp *Expedition, d autoCampDecision, cost float32, flavorLine, restSummary string, + nightBurn float32, nightTemp []string, nightMile []string, nightDid bool, nightForce bool) string { + if nightForce { + out := fmt.Sprintf("\n\n🌙 **Day %d.** _Supplies burned (−%.1f SU); the rollover fired but the camp couldn't pitch._\n", exp.CurrentDay, nightBurn) + for _, tl := range nightTemp { + out += "\n🌀 " + tl + "\n" + } + for _, ml := range nightMile { + out += "\n" + ml + } + return out + } out := fmt.Sprintf("\n\n⛺ **Autopilot camp — %s.** _%s_\n", d.Kind, d.Reason) out += fmt.Sprintf("Supplies: %.1f / %.1f SU (−%.1f).\n", exp.Supplies.Current, exp.Supplies.Max, cost) @@ -230,6 +322,15 @@ func renderAutoCampBlock(exp *Expedition, d autoCampDecision, cost float32, flav if d.Kind == CampTypeBase { out += "\n_Base camp — **waypoint persisted**._" } + if nightDid { + out += fmt.Sprintf("\n\n🌙 **Day %d.** _Overnight burn: %.1f SU._\n", exp.CurrentDay, nightBurn) + for _, tl := range nightTemp { + out += "\n🌀 " + tl + "\n" + } + for _, ml := range nightMile { + out += "\n" + ml + } + } return out } diff --git a/internal/plugin/expedition_autocamp_test.go b/internal/plugin/expedition_autocamp_test.go index 6fec7b6..6951062 100644 --- a/internal/plugin/expedition_autocamp_test.go +++ b/internal/plugin/expedition_autocamp_test.go @@ -223,6 +223,101 @@ func TestBreakAutoCampIfDue_RespectsDwellWindow(t *testing.T) { } } +func TestDecideAutopilotCamp_NightTriggerOnEventAnchored(t *testing.T) { + now := time.Now().UTC() + stale := now.Add(-(nightCampWindow + time.Hour)) + in := autoCampInputs{ + Run: &DungeonRun{CurrentRoom: 1, RoomsCleared: []int{0, 1}}, + HPCur: 20, HPMax: 20, // healthy — only the night window should pitch + Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, + Now: now, + EventAnchored: true, + LastBriefingAt: &stale, + } + d, ok := decideAutopilotCamp(in) + if !ok { + t.Fatal("expected night camp pitch") + } + if !d.Night { + t.Errorf("Night = false, want true (end of day)") + } + if d.Kind != CampTypeStandard { + t.Errorf("Kind = %s, want standard (cleared room)", d.Kind) + } +} + +func TestDecideAutopilotCamp_NightSkippedOnLegacy(t *testing.T) { + now := time.Now().UTC() + stale := now.Add(-24 * time.Hour) + in := autoCampInputs{ + Run: &DungeonRun{CurrentRoom: 1, RoomsCleared: []int{0, 1}}, + HPCur: 20, HPMax: 20, + Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, + Now: now, + EventAnchored: false, // legacy — only HP-low triggers + LastBriefingAt: &stale, + } + if _, ok := decideAutopilotCamp(in); ok { + t.Error("legacy expedition should not pitch a night camp on time alone") + } +} + +func TestDecideAutopilotCamp_NightFlagSetEvenOnHPLow(t *testing.T) { + now := time.Now().UTC() + stale := now.Add(-(nightCampWindow + time.Hour)) + in := autoCampInputs{ + Run: &DungeonRun{CurrentRoom: 1, RoomsCleared: []int{0, 1}}, + HPCur: 5, HPMax: 20, // also low + Supplies: ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1}, + Now: now, + EventAnchored: true, + LastBriefingAt: &stale, + } + d, ok := decideAutopilotCamp(in) + if !ok || !d.Night { + t.Errorf("expected Night pitch even when HP also low, got %+v ok=%v", d, ok) + } +} + +func TestPitchAutopilotCamp_NightRunsProcessNightCamp(t *testing.T) { + setupZoneRunTestDB(t) + uid := id.UserID("@autocamp-night:example") + campTestCharacter(t, uid, 1) + defer cleanupExpeditions(uid) + + exp, err := startExpedition(uid, ZoneGoblinWarrens, "", + ExpeditionSupplies{Current: 5, Max: 5, DailyBurn: 1, HarshMod: 1}) + if err != nil { + t.Fatal(err) + } + // Force event-anchored for this test. + saved := eventAnchoredCutoff + eventAnchoredCutoff = exp.StartDate.Add(-time.Minute) + defer func() { eventAnchoredCutoff = saved }() + + startDay := exp.CurrentDay + startSU := exp.Supplies.Current + p := &AdventurePlugin{} + _, err = p.pitchAutopilotCamp(exp, autoCampDecision{ + Kind: CampTypeStandard, Reason: "night-camp test", Night: true, + }) + if err != nil { + t.Fatal(err) + } + got, _ := getActiveExpedition(uid) + if got.CurrentDay != startDay+1 { + t.Errorf("day = %d, want %d (night camp advances day)", got.CurrentDay, startDay+1) + } + // Night burn (0.5 SU) + camp cost (1.0 SU) = 1.5 SU; 5 - 1.5 = 3.5. + wantSU := startSU - 0.5 - campSupplyCost[CampTypeStandard] + if got.Supplies.Current != wantSU { + t.Errorf("supplies = %v, want %v (night burn + camp cost)", got.Supplies.Current, wantSU) + } + if got.LastBriefingAt == nil { + t.Error("LastBriefingAt should be stamped by night rollover") + } +} + func TestBreakAutoCampIfDue_LeavesPlayerCampAlone(t *testing.T) { setupZoneRunTestDB(t) uid := id.UserID("@autocamp-playercamp:example") diff --git a/internal/plugin/holdem_tip_scenarios_test.go b/internal/plugin/holdem_tip_scenarios_test.go index e42ef34..a1ebb82 100644 --- a/internal/plugin/holdem_tip_scenarios_test.go +++ b/internal/plugin/holdem_tip_scenarios_test.go @@ -5,6 +5,7 @@ import ( "os" "strings" "testing" + "time" "github.com/chehsunliu/poker" ) @@ -30,6 +31,11 @@ func loadSolverFixture() { func TestMain(m *testing.M) { loadSolverFixture() + // D2-b: shove eventAnchoredCutoff far into the future so existing tests + // keep exercising the legacy UTC-anchored briefing mutator path by + // default. New event-anchored tests opt in by overriding the cutoff + // (see useEventAnchored helpers). + eventAnchoredCutoff = time.Date(9999, 1, 1, 0, 0, 0, 0, time.UTC) os.Exit(m.Run()) }