mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user