mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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-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 <type>` 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`.
|
**Files:** new `expedition_autocamp.go`; hooks in `expedition_autorun.go:tryAutoRun`; reuse `dnd_expedition_camp.go:campPitch` / `applyCampRest`.
|
||||||
**Heuristics (in priority order):**
|
**Heuristics (in priority order):**
|
||||||
|
|||||||
@@ -221,10 +221,9 @@ func TestAdv2Scenario_ExpeditionCryptValdris(t *testing.T) {
|
|||||||
t.Errorf("expected outfitting to debit coins (%.2f → %.2f)", balBefore, balAfter)
|
t.Errorf("expected outfitting to debit coins (%.2f → %.2f)", balBefore, balAfter)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backdate start so deliverBriefing's same-day guard passes.
|
// Backdate start so deliverBriefing's same-day guard passes, and to
|
||||||
if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil {
|
// before eventAnchoredCutoff so the legacy mutator path still fires.
|
||||||
t.Fatalf("backdate: %v", err)
|
rewindToLegacyAnchor(t, exp)
|
||||||
}
|
|
||||||
|
|
||||||
// Drive 3 daily briefings — verify supply burn + day advance.
|
// Drive 3 daily briefings — verify supply burn + day advance.
|
||||||
now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC)
|
||||||
|
|||||||
@@ -168,9 +168,7 @@ func TestDeliverBriefing_StarvationForcesExtraction(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil {
|
rewindToLegacyAnchor(t, exp)
|
||||||
t.Fatalf("backdate: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
p := &AdventurePlugin{}
|
p := &AdventurePlugin{}
|
||||||
now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC)
|
||||||
|
|||||||
@@ -160,6 +160,30 @@ func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind st
|
|||||||
cost, exp.Supplies.Current))
|
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
|
exp.Supplies.Current -= cost
|
||||||
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
|
if err := updateSupplies(exp.ID, exp.Supplies); err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Couldn't deduct supplies: "+err.Error())
|
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
|
// 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.
|
// back. The flag tells processOvernightCamp not to re-apply at briefing.
|
||||||
restSummary := applyCampRest(exp, kind)
|
restSummary := applyCampRest(exp, kind)
|
||||||
|
if nightCamp {
|
||||||
|
nightRoll = p.nightRolloverDrift(exp, time.Now().UTC())
|
||||||
|
nightRoll.Burn = nightBurn
|
||||||
|
}
|
||||||
camp.RestApplied = true
|
camp.RestApplied = true
|
||||||
if err := updateCamp(exp.ID, camp); err != nil {
|
if err := updateCamp(exp.ID, camp); err != nil {
|
||||||
slog.Warn("camp: mark rest applied", "expedition", exp.ID, "err", err)
|
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:
|
case CampTypeBase:
|
||||||
b.WriteString("\n_Base camp — **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._")
|
||||||
}
|
}
|
||||||
|
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())
|
return p.SendDM(ctx.Sender, b.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,128 @@ import (
|
|||||||
const (
|
const (
|
||||||
expeditionBriefingHour = 6
|
expeditionBriefingHour = 6
|
||||||
expeditionRecapHour = 21
|
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.
|
// expeditionBriefingTicker — 06:00 UTC daily briefing.
|
||||||
func (p *AdventurePlugin) expeditionBriefingTicker() {
|
func (p *AdventurePlugin) expeditionBriefingTicker() {
|
||||||
ticker := time.NewTicker(1 * time.Minute)
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
@@ -176,14 +296,17 @@ func scanExpeditionRows(rows *sql.Rows) ([]*Expedition, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// deliverBriefing rolls a day forward, applies supply burn, posts the
|
// deliverBriefing posts the morning briefing DM. For legacy UTC-anchored
|
||||||
// morning briefing DM, appends a log entry, and stamps last_briefing_at.
|
// 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
|
// Idempotency: atomic compare-and-set on last_briefing_at gates the body.
|
||||||
// last_briefing_at. If another invocation (clock skew, restart, double
|
// A double-fire on the same expedition is a no-op.
|
||||||
// fire) already claimed today's rollover, rowsAffected == 0 and we bail
|
|
||||||
// without re-applying supply burn / day++ / threat drift.
|
|
||||||
func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
||||||
|
priorBriefing := e.LastBriefingAt
|
||||||
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
||||||
expeditionBriefingHour, 0, 0, 0, time.UTC)
|
expeditionBriefingHour, 0, 0, 0, time.UTC)
|
||||||
res, err := db.Get().Exec(`
|
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 {
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
return nil // already delivered for this day
|
return nil // already delivered for this day
|
||||||
}
|
}
|
||||||
|
e.LastBriefingAt = &now
|
||||||
|
|
||||||
// E3: zone-specific temporal events fire BEFORE applyDailyBurn and
|
// D2-b: event-anchored expeditions own the day rollover via the
|
||||||
// can override the entire burn calculation with a fixed multiplier
|
// autopilot night camp. The 06:00 ticker either posts a re-engagement
|
||||||
// (Sunken Temple tidal 2.0×, Feywild half-day 0.5×, etc.).
|
// DM (rollover happened recently) or force-fires processNightCamp
|
||||||
burnOverride := applyZoneTemporalPreBurn(e, e.CurrentDay+1)
|
// itself (safety net for stalled autopilots).
|
||||||
|
if isEventAnchored(e) {
|
||||||
// Advance day + supply burn happen together at the morning rollover.
|
return p.deliverBriefingEventAnchored(e, priorBriefing)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
if err := updateSupplies(e.ID, newSupplies); err != nil {
|
|
||||||
|
burn, err := p.nightRolloverBurn(e)
|
||||||
|
if err != nil {
|
||||||
return err
|
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
|
// E2d: overnight camp rest. Runs after burn/day++ but before drift so
|
||||||
// the camp after applying. Run before threat drift so a fortified
|
// a fortified camp's −5 lands first and a same-day +3 drift can't push
|
||||||
// camp's −5 lands first and a same-day +3 drift can't push back over
|
// back over a threshold the rest just dropped.
|
||||||
// a threshold the rest just dropped.
|
|
||||||
restSummary := processOvernightCamp(e)
|
restSummary := processOvernightCamp(e)
|
||||||
if restSummary != "" {
|
if restSummary != "" {
|
||||||
if fresh, err := getExpedition(e.ID); err == nil && fresh != nil {
|
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
|
// Pass time.Time{} — the CAS at the top of deliverBriefing already
|
||||||
// boss kill. May cross a threshold and append a flavor-bearing log.
|
// stamped last_briefing_at with the synthetic now; don't overwrite it.
|
||||||
if _, _, err := applyDailyThreatDrift(e); err != nil {
|
roll := p.nightRolloverDrift(e, time.Time{})
|
||||||
slog.Warn("expedition: threat drift", "expedition", e.ID, "err", err)
|
roll.Burn = burn
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
line := pickMorningBriefing(e.CurrentDay)
|
line := pickMorningBriefing(e.CurrentDay)
|
||||||
body := renderMorningBriefing(e, line, burn)
|
body := renderMorningBriefing(e, line, burn)
|
||||||
if restSummary != "" {
|
if restSummary != "" {
|
||||||
body += "\n💤 _" + restSummary + "_\n"
|
body += "\n💤 _" + restSummary + "_\n"
|
||||||
}
|
}
|
||||||
for _, tl := range temporalLines {
|
for _, tl := range roll.TemporalLines {
|
||||||
body += "\n🌀 " + tl + "\n"
|
body += "\n🌀 " + tl + "\n"
|
||||||
}
|
}
|
||||||
for _, ml := range milestoneLines {
|
for _, ml := range roll.MilestoneLines {
|
||||||
body += "\n" + ml
|
body += "\n" + ml
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,6 +403,80 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
|||||||
return nil
|
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
|
// 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.
|
// last_recap_at. No supply burn here — that's the briefing's job.
|
||||||
// Idempotency: same pattern as deliverBriefing — claim last_recap_at first.
|
// Idempotency: same pattern as deliverBriefing — claim last_recap_at first.
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ import (
|
|||||||
// synthetic "now" and verify state transitions. Ticker scheduling is a
|
// synthetic "now" and verify state transitions. Ticker scheduling is a
|
||||||
// thin wrapper over those.
|
// 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) {
|
func TestPickMorningBriefing_DayBands(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
day int
|
day int
|
||||||
@@ -57,6 +71,7 @@ func TestDeliverBriefing_AdvancesDayAndBurnsSupplies(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
rewindToLegacyAnchor(t, exp)
|
||||||
p := &AdventurePlugin{}
|
p := &AdventurePlugin{}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
if err := p.deliverBriefing(exp, now); err != nil {
|
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) {
|
func TestDeliverBriefing_HarshConditionsBurnFaster(t *testing.T) {
|
||||||
setupZoneRunTestDB(t)
|
setupZoneRunTestDB(t)
|
||||||
uid := id.UserID("@exp-cycle-harsh:example")
|
uid := id.UserID("@exp-cycle-harsh:example")
|
||||||
@@ -97,6 +182,7 @@ func TestDeliverBriefing_HarshConditionsBurnFaster(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
rewindToLegacyAnchor(t, exp)
|
||||||
// Force harsh conditions via threat clock above 60.
|
// Force harsh conditions via threat clock above 60.
|
||||||
if err := applyThreatDelta(exp.ID, 70, "test"); err != nil {
|
if err := applyThreatDelta(exp.ID, 70, "test"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -701,6 +701,7 @@ func TestAbyss_DailyInstabilityIncrements(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
rewindToLegacyAnchor(t, exp)
|
||||||
p := &AdventurePlugin{}
|
p := &AdventurePlugin{}
|
||||||
// Three briefings → instability should hit 15.
|
// Three briefings → instability should hit 15.
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
@@ -732,6 +733,7 @@ func TestAbyss_UnravelingDoublesBurn(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
rewindToLegacyAnchor(t, exp)
|
||||||
// Set instability to 85 (unravel band).
|
// Set instability to 85 (unravel band).
|
||||||
if _, err := db.Get().Exec(
|
if _, err := db.Get().Exec(
|
||||||
`UPDATE dnd_expedition SET temporal_stack = 85 WHERE expedition_id = ?`,
|
`UPDATE dnd_expedition SET temporal_stack = 85 WHERE expedition_id = ?`,
|
||||||
@@ -760,6 +762,7 @@ func TestAbyss_CollapseFailsExpedition(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
rewindToLegacyAnchor(t, exp)
|
||||||
// Set instability to 95 — next daily +5 lands on 100 (collapse).
|
// Set instability to 95 — next daily +5 lands on 100 (collapse).
|
||||||
if _, err := db.Get().Exec(
|
if _, err := db.Get().Exec(
|
||||||
`UPDATE dnd_expedition SET temporal_stack = 95 WHERE expedition_id = ?`,
|
`UPDATE dnd_expedition SET temporal_stack = 95 WHERE expedition_id = ?`,
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ func TestDeliverBriefing_AppliesThreatDrift(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
rewindToLegacyAnchor(t, exp)
|
||||||
p := &AdventurePlugin{}
|
p := &AdventurePlugin{}
|
||||||
if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil {
|
if err := p.deliverBriefing(exp, time.Now().UTC()); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ const (
|
|||||||
// this fraction of max. Set above autopilotLowHPPct (0.30) so the
|
// this fraction of max. Set above autopilotLowHPPct (0.30) so the
|
||||||
// scheduler camps *before* the walk-preflight gives up.
|
// scheduler camps *before* the walk-preflight gives up.
|
||||||
autoCampHPPct = 0.55
|
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.
|
// autoCampInputs is the minimal snapshot decideAutopilotCamp needs.
|
||||||
@@ -49,13 +58,21 @@ type autoCampInputs struct {
|
|||||||
BaseAlready bool
|
BaseAlready bool
|
||||||
HPCur, HPMax int
|
HPCur, HPMax int
|
||||||
Supplies ExpeditionSupplies
|
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
|
// 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 {
|
type autoCampDecision struct {
|
||||||
Kind string
|
Kind string
|
||||||
Reason string
|
Reason string
|
||||||
|
Night bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// decideAutopilotCamp returns the camp to pitch (or ok=false). Pure;
|
// 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
|
// Heuristic — region base-camp waypoint. Eager: pitch once per
|
||||||
// eligible region after its boss is down. BaseAlready stops the
|
// eligible region after its boss is down. BaseAlready stops the
|
||||||
// next tick from re-pitching the same waypoint.
|
// next tick from re-pitching the same waypoint.
|
||||||
if in.Multi && in.RegionCleared && in.BaseSite && !in.BaseAlready && cleared {
|
if in.Multi && in.RegionCleared && in.BaseSite && !in.BaseAlready && cleared {
|
||||||
if in.Supplies.Current >= campSupplyCost[CampTypeBase] {
|
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.
|
// Heuristic — HP-low rest OR end-of-day night camp. Standard if
|
||||||
// LowSU as a *trigger* would just dig the hole deeper (camps burn
|
// cleared, rough otherwise. LowSU as a *trigger* would just dig
|
||||||
// SU); the walk's preflight already pauses on low-SU so the
|
// the hole deeper; the walk's preflight pauses on low-SU instead.
|
||||||
// player can extract or buy more time.
|
|
||||||
lowHP := in.HPMax > 0 && float64(in.HPCur) <= float64(in.HPMax)*autoCampHPPct
|
lowHP := in.HPMax > 0 && float64(in.HPCur) <= float64(in.HPMax)*autoCampHPPct
|
||||||
if !lowHP {
|
if !lowHP && !night {
|
||||||
return autoCampDecision{}, false
|
return autoCampDecision{}, false
|
||||||
}
|
}
|
||||||
kind := CampTypeRough
|
kind := CampTypeRough
|
||||||
@@ -102,14 +136,20 @@ func decideAutopilotCamp(in autoCampInputs) (autoCampDecision, bool) {
|
|||||||
}
|
}
|
||||||
cost := campSupplyCost[kind]
|
cost := campSupplyCost[kind]
|
||||||
if in.Supplies.Current < cost {
|
if in.Supplies.Current < cost {
|
||||||
// Try the cheaper rough tier if standard doesn't fit.
|
|
||||||
if kind == CampTypeStandard && in.Supplies.Current >= campSupplyCost[CampTypeRough] {
|
if kind == CampTypeStandard && in.Supplies.Current >= campSupplyCost[CampTypeRough] {
|
||||||
kind = CampTypeRough
|
kind = CampTypeRough
|
||||||
} else {
|
} else {
|
||||||
return autoCampDecision{}, false
|
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
|
// maybeAutoCamp gathers DB state, calls decideAutopilotCamp, and pitches
|
||||||
@@ -139,15 +179,19 @@ func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) string {
|
|||||||
|
|
||||||
hpCur, hpMax := dndHPSnapshot(uid)
|
hpCur, hpMax := dndHPSnapshot(uid)
|
||||||
in := autoCampInputs{
|
in := autoCampInputs{
|
||||||
Camp: exp.Camp,
|
Camp: exp.Camp,
|
||||||
Run: run,
|
Run: run,
|
||||||
Multi: multi,
|
Multi: multi,
|
||||||
RegionCleared: regionCleared,
|
RegionCleared: regionCleared,
|
||||||
BaseSite: baseSite,
|
BaseSite: baseSite,
|
||||||
BaseAlready: baseAlready,
|
BaseAlready: baseAlready,
|
||||||
HPCur: hpCur,
|
HPCur: hpCur,
|
||||||
HPMax: hpMax,
|
HPMax: hpMax,
|
||||||
Supplies: exp.Supplies,
|
Supplies: exp.Supplies,
|
||||||
|
Now: time.Now().UTC(),
|
||||||
|
EventAnchored: isEventAnchored(exp),
|
||||||
|
LastBriefingAt: exp.LastBriefingAt,
|
||||||
|
StartDate: exp.StartDate,
|
||||||
}
|
}
|
||||||
d, ok := decideAutopilotCamp(in)
|
d, ok := decideAutopilotCamp(in)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -164,8 +208,37 @@ func (p *AdventurePlugin) maybeAutoCamp(exp *Expedition) string {
|
|||||||
// pitchAutopilotCamp performs the same state mutations as the player
|
// pitchAutopilotCamp performs the same state mutations as the player
|
||||||
// !camp path (supply debit, camp row, applyCampRest, log entry, base-
|
// !camp path (supply debit, camp row, applyCampRest, log entry, base-
|
||||||
// camp waypoint persist) without DMing — the autorun ticker bundles
|
// 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) {
|
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]
|
cost := campSupplyCost[d.Kind]
|
||||||
if exp.Supplies.Current < cost {
|
if exp.Supplies.Current < cost {
|
||||||
return "", fmt.Errorf("insufficient supplies (have %.1f, need %.1f)", 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
|
// renderAutoCampBlock formats the autopilot-camp section appended to
|
||||||
// the auto-walk DM. Kept short — the autorun DM already opens with the
|
// the auto-walk DM. Kept short — the autorun DM already opens with the
|
||||||
// walk narration.
|
// walk narration. nightBurn/nightTemp/nightMile carry the D2-b rollover
|
||||||
func renderAutoCampBlock(exp *Expedition, d autoCampDecision, cost float32, flavorLine, restSummary string) string {
|
// 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("\n\n⛺ **Autopilot camp — %s.** _%s_\n", d.Kind, d.Reason)
|
||||||
out += fmt.Sprintf("Supplies: %.1f / %.1f SU (−%.1f).\n",
|
out += fmt.Sprintf("Supplies: %.1f / %.1f SU (−%.1f).\n",
|
||||||
exp.Supplies.Current, exp.Supplies.Max, cost)
|
exp.Supplies.Current, exp.Supplies.Max, cost)
|
||||||
@@ -230,6 +322,15 @@ func renderAutoCampBlock(exp *Expedition, d autoCampDecision, cost float32, flav
|
|||||||
if d.Kind == CampTypeBase {
|
if d.Kind == CampTypeBase {
|
||||||
out += "\n_Base camp — **waypoint persisted**._"
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
func TestBreakAutoCampIfDue_LeavesPlayerCampAlone(t *testing.T) {
|
||||||
setupZoneRunTestDB(t)
|
setupZoneRunTestDB(t)
|
||||||
uid := id.UserID("@autocamp-playercamp:example")
|
uid := id.UserID("@autocamp-playercamp:example")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/chehsunliu/poker"
|
"github.com/chehsunliu/poker"
|
||||||
)
|
)
|
||||||
@@ -30,6 +31,11 @@ func loadSolverFixture() {
|
|||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
loadSolverFixture()
|
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())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user