mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Adv 2.0 D&D Phase R7: post-completion audit hardening
High-priority fixes from the multi-agent audit of Adventure 2.0: - Phase 11 nil-deref cluster: zoneOrFallback() helper replaces 8 unsafe getZone(_) sites in dnd_zone_cmd.go. Corrupted zone IDs render a placeholder instead of panicking. - Briefing/recap idempotency: deliverBriefing/deliverRecap now claim the rollover via a conditional UPDATE. Double-fires from clock skew or restart become no-ops; supply burn and day++ no longer reapply. - Graceful ticker shutdown: AdventurePlugin gains stopCh + Stop(); all 11 background tickers now select on stopCh in addition to ticker.C. - Mood decay (§3.2): math.Round(elapsed*2) replaces int() truncation so sub-hour gaps decay correctly. - 24h auto-abandon (§4.3): getActiveZoneRun returns clean slate and abandons stale runs whose LastActionAt is over 24h old. - Respec / auto-migrate orphan cleanup: !respec and the auto-migrated draft wipe now abandon active zone runs and expeditions before deleting the dnd_character row. - Phase R combat-link: applyBossDefeatThreat now wired from resolveBossRoom (-20 threat); applyRoomCombatThreatForUser adds +5/+8 from non-boss/elite kills (§8.1). - Starvation → forced extraction: briefing-time check forces extract with §10.2 coin tax when supplies hit zero. - GMNat20/Nat1 narration wired into resolveCombatRoom and resolveBossRoom (nat-20 takes precedence over nat-1). - Treasure-undo race: LoadAndDelete on both timer-fire and `undo` paths so only one side wins. - Battle Master: Disarming, Menacing, Parry maneuvers added (3 → 6 of 10). Remaining 4 (Pushing, Goading, Riposte, Commander's Strike) documented inline as needing ally/reaction mechanics the engine doesn't model. - Threat-70 warning: tracked in RegionState["siege_warning_fired"] so a drop-and-recross doesn't re-fire the beat. - region_state JSON decode error now logged via slog.Warn instead of silently discarded. Failing TestProdDB_DnDLayer fixed via option (a): track migrated characters and only run round-trip / idempotency assertions on those, skipping pre-existing prod-DB rows accumulated from live bot use. New tests in dnd_audit_phase_R7_test.go cover: 24h auto-abandon, briefing double-fire idempotency, threat-70 warning idempotency, multi-region extract→resume state preservation, and starvation forced-extraction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,29 @@ type AdventurePlugin struct {
|
||||
treasureUndo sync.Map // userID string -> *advTreasureUndoToken (10-min auto-swap reversal)
|
||||
morningHour int
|
||||
summaryHour int
|
||||
|
||||
// stopCh is closed by Stop() to signal all background tickers
|
||||
// (morning/summary/midnight/event/rival/robbie/hospital/mortgage/
|
||||
// coop/expedition briefing+recap) to exit. Each ticker selects
|
||||
// on its time.Ticker channel and stopCh; the second branch returns.
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// Stop signals all background tickers to exit and waits for them via
|
||||
// the closed channel. Idempotent — closing a closed channel would
|
||||
// panic, so we guard with a sync.Once-style nil check.
|
||||
func (p *AdventurePlugin) Stop() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.stopCh == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
// already closed
|
||||
default:
|
||||
close(p.stopCh)
|
||||
}
|
||||
}
|
||||
|
||||
// advUserLock returns a per-user mutex to prevent concurrent action resolution.
|
||||
@@ -180,7 +203,10 @@ func (p *AdventurePlugin) Init() error {
|
||||
// Revive any characters whose DeadUntil has expired
|
||||
p.catchUpRespawns(chars)
|
||||
|
||||
// Start schedulers
|
||||
// Start schedulers — single shared stopCh allows graceful shutdown.
|
||||
if p.stopCh == nil {
|
||||
p.stopCh = make(chan struct{})
|
||||
}
|
||||
go p.morningTicker()
|
||||
go p.summaryTicker()
|
||||
go p.midnightTicker()
|
||||
@@ -1092,13 +1118,11 @@ func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCha
|
||||
announceDef := *drop.Def
|
||||
announceLoc := *loc
|
||||
announceTimer := time.AfterFunc(advTreasureUndoWindow, func() {
|
||||
// Token is deleted on undo, so only fire when it's still present.
|
||||
// The map entry also gets cleared lazily below by future undo
|
||||
// attempts; firing once based on Timer presence is enough.
|
||||
if _, ok := p.treasureUndo.Load(string(userID)); !ok {
|
||||
// Atomically claim the token so a concurrent `undo` reply
|
||||
// can't also fire the announcement (or vice-versa).
|
||||
if _, ok := p.treasureUndo.LoadAndDelete(string(userID)); !ok {
|
||||
return
|
||||
}
|
||||
p.treasureUndo.Delete(string(userID))
|
||||
p.announceTreasureToRoom(&announceChar, &announceDef, &announceLoc)
|
||||
})
|
||||
|
||||
@@ -1120,12 +1144,13 @@ func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCha
|
||||
// was applied. Stale or missing tokens return false so the caller falls
|
||||
// through to the regular DM dispatch.
|
||||
func (p *AdventurePlugin) tryTreasureUndo(userID id.UserID) bool {
|
||||
val, ok := p.treasureUndo.Load(string(userID))
|
||||
// LoadAndDelete races against the announce timer's claim — only
|
||||
// one side wins, the other no-ops.
|
||||
val, ok := p.treasureUndo.LoadAndDelete(string(userID))
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
tok := val.(*advTreasureUndoToken)
|
||||
p.treasureUndo.Delete(string(userID))
|
||||
// Cancel the deferred room announcement — a reversed swap shouldn't
|
||||
// produce a public "X recovered Y" line.
|
||||
if tok.AnnounceTimer != nil {
|
||||
|
||||
@@ -41,61 +41,66 @@ func (p *AdventurePlugin) eventTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
dateKey := now.Format("2006-01-02")
|
||||
currentMinute := now.Hour()*60 + now.Minute()
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
now := time.Now().UTC()
|
||||
dateKey := now.Format("2006-01-02")
|
||||
currentMinute := now.Hour()*60 + now.Minute()
|
||||
|
||||
// Expire stale pending events every tick
|
||||
expireAdvPendingEvents()
|
||||
// Expire stale pending events every tick
|
||||
expireAdvPendingEvents()
|
||||
|
||||
advEventScheduleMu.Lock()
|
||||
if advEventScheduleDay != dateKey {
|
||||
advEventSchedule = make(map[string]int)
|
||||
advEventRolled = make(map[string]bool)
|
||||
advEventScheduleDay = dateKey
|
||||
}
|
||||
advEventScheduleMu.Lock()
|
||||
if advEventScheduleDay != dateKey {
|
||||
advEventSchedule = make(map[string]int)
|
||||
advEventRolled = make(map[string]bool)
|
||||
advEventScheduleDay = dateKey
|
||||
}
|
||||
|
||||
// Schedule deferred rolls for any newly-acted players
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("adventure: events: failed to load chars", "err", err)
|
||||
// Schedule deferred rolls for any newly-acted players
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("adventure: events: failed to load chars", "err", err)
|
||||
advEventScheduleMu.Unlock()
|
||||
continue
|
||||
}
|
||||
for _, c := range chars {
|
||||
uid := string(c.UserID)
|
||||
if !c.Alive || advEventRolled[uid] {
|
||||
continue
|
||||
}
|
||||
if _, scheduled := advEventSchedule[uid]; scheduled {
|
||||
continue
|
||||
}
|
||||
if !c.HasActedToday() {
|
||||
continue
|
||||
}
|
||||
// Assign a one-shot roll 60–180 minutes from now, capped to 23:50 UTC.
|
||||
rollMinute := currentMinute + 60 + rand.IntN(121)
|
||||
if rollMinute > 23*60+50 {
|
||||
rollMinute = 23*60 + 50
|
||||
}
|
||||
advEventSchedule[uid] = rollMinute
|
||||
slog.Info("adventure: event roll scheduled", "user", uid, "minute", rollMinute)
|
||||
}
|
||||
|
||||
// Find players whose roll-minute has arrived
|
||||
var toRoll []id.UserID
|
||||
for uid, minute := range advEventSchedule {
|
||||
if minute <= currentMinute && !advEventRolled[uid] {
|
||||
toRoll = append(toRoll, id.UserID(uid))
|
||||
advEventRolled[uid] = true
|
||||
}
|
||||
}
|
||||
advEventScheduleMu.Unlock()
|
||||
continue
|
||||
}
|
||||
for _, c := range chars {
|
||||
uid := string(c.UserID)
|
||||
if !c.Alive || advEventRolled[uid] {
|
||||
continue
|
||||
}
|
||||
if _, scheduled := advEventSchedule[uid]; scheduled {
|
||||
continue
|
||||
}
|
||||
if !c.HasActedToday() {
|
||||
continue
|
||||
}
|
||||
// Assign a one-shot roll 60–180 minutes from now, capped to 23:50 UTC.
|
||||
rollMinute := currentMinute + 60 + rand.IntN(121)
|
||||
if rollMinute > 23*60+50 {
|
||||
rollMinute = 23*60 + 50
|
||||
}
|
||||
advEventSchedule[uid] = rollMinute
|
||||
slog.Info("adventure: event roll scheduled", "user", uid, "minute", rollMinute)
|
||||
}
|
||||
|
||||
// Find players whose roll-minute has arrived
|
||||
var toRoll []id.UserID
|
||||
for uid, minute := range advEventSchedule {
|
||||
if minute <= currentMinute && !advEventRolled[uid] {
|
||||
toRoll = append(toRoll, id.UserID(uid))
|
||||
advEventRolled[uid] = true
|
||||
for _, uid := range toRoll {
|
||||
p.tryTriggerEvent(uid)
|
||||
}
|
||||
}
|
||||
advEventScheduleMu.Unlock()
|
||||
|
||||
for _, uid := range toRoll {
|
||||
p.tryTriggerEvent(uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -248,36 +248,41 @@ func (p *AdventurePlugin) hospitalNudgeTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
p.hospitalNudges.Range(func(key, val any) bool {
|
||||
uid := key.(string)
|
||||
nudgeAt := val.(time.Time)
|
||||
if now.Before(nudgeAt) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Due — remove regardless of outcome
|
||||
p.hospitalNudges.Delete(uid)
|
||||
|
||||
userID := id.UserID(uid)
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char.Alive {
|
||||
return true
|
||||
}
|
||||
|
||||
// Don't nudge if already in hospital flow
|
||||
if v, ok := p.pending.Load(uid); ok {
|
||||
if pi, ok := v.(*advPendingInteraction); ok && pi.Type == "hospital_pay" {
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
now := time.Now().UTC()
|
||||
p.hospitalNudges.Range(func(key, val any) bool {
|
||||
uid := key.(string)
|
||||
nudgeAt := val.(time.Time)
|
||||
if now.Before(nudgeAt) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
text, _ := advPickFlavor(nurseJoyNudge, userID, "hospital_nudge")
|
||||
if err := p.SendDM(userID, text); err != nil {
|
||||
slog.Error("hospital: failed to send nudge", "user", userID, "err", err)
|
||||
}
|
||||
return true
|
||||
})
|
||||
// Due — remove regardless of outcome
|
||||
p.hospitalNudges.Delete(uid)
|
||||
|
||||
userID := id.UserID(uid)
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char.Alive {
|
||||
return true
|
||||
}
|
||||
|
||||
// Don't nudge if already in hospital flow
|
||||
if v, ok := p.pending.Load(uid); ok {
|
||||
if pi, ok := v.(*advPendingInteraction); ok && pi.Type == "hospital_pay" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
text, _ := advPickFlavor(nurseJoyNudge, userID, "hospital_nudge")
|
||||
if err := p.SendDM(userID, text); err != nil {
|
||||
slog.Error("hospital: failed to send nudge", "user", userID, "err", err)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,35 +107,40 @@ func (p *AdventurePlugin) mortgageTicker() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
// Run on Mondays only (FRED updates Thursdays, we process Mondays)
|
||||
if now.Weekday() != time.Monday {
|
||||
continue
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
now := time.Now().UTC()
|
||||
// Run on Mondays only (FRED updates Thursdays, we process Mondays)
|
||||
if now.Weekday() != time.Monday {
|
||||
continue
|
||||
}
|
||||
|
||||
_, week := now.ISOWeek()
|
||||
dateKey := fmt.Sprintf("%d-W%02d", now.Year(), week)
|
||||
jobName := "mortgage_weekly"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("mortgage: running weekly payment processing")
|
||||
|
||||
// Fetch fresh rate and compare with previous
|
||||
newRate := getCurrentMortgageRate()
|
||||
oldRate := p.getLastKnownRate()
|
||||
|
||||
if oldRate > 0 && oldRate != newRate {
|
||||
p.sendMortgageRateChangeDMs(oldRate, newRate)
|
||||
}
|
||||
p.setLastKnownRate(newRate)
|
||||
|
||||
// Process payments
|
||||
p.processMortgagePayments()
|
||||
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
}
|
||||
|
||||
_, week := now.ISOWeek()
|
||||
dateKey := fmt.Sprintf("%d-W%02d", now.Year(), week)
|
||||
jobName := "mortgage_weekly"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("mortgage: running weekly payment processing")
|
||||
|
||||
// Fetch fresh rate and compare with previous
|
||||
newRate := getCurrentMortgageRate()
|
||||
oldRate := p.getLastKnownRate()
|
||||
|
||||
if oldRate > 0 && oldRate != newRate {
|
||||
p.sendMortgageRateChangeDMs(oldRate, newRate)
|
||||
}
|
||||
p.setLastKnownRate(newRate)
|
||||
|
||||
// Process payments
|
||||
p.processMortgagePayments()
|
||||
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -778,40 +778,45 @@ func (p *AdventurePlugin) rivalChallengeTicker() {
|
||||
// Roll the next challenge interval once. Re-roll after each issued challenge.
|
||||
nextIntervalHours := rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1)
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Only fire on the hour.
|
||||
if now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
// Only fire on the hour.
|
||||
if now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only issue challenges between 08:00 and 22:00 UTC.
|
||||
if now.Hour() < 8 || now.Hour() >= 22 {
|
||||
// Still check for expiry outside challenge hours.
|
||||
// Only issue challenges between 08:00 and 22:00 UTC.
|
||||
if now.Hour() < 8 || now.Hour() >= 22 {
|
||||
// Still check for expiry outside challenge hours.
|
||||
p.expireRivalChallenges()
|
||||
continue
|
||||
}
|
||||
|
||||
// Expire old challenges first.
|
||||
p.expireRivalChallenges()
|
||||
continue
|
||||
|
||||
// Check if enough time has passed since last challenge.
|
||||
last := lastRivalChallengeTime()
|
||||
if !last.IsZero() && now.Sub(last) < time.Duration(nextIntervalHours)*time.Hour {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to issue a challenge.
|
||||
challenger, challenged := p.selectRivalPair()
|
||||
if challenger == nil || challenged == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
p.issueRivalChallenge(challenger, challenged)
|
||||
|
||||
// Roll a fresh interval for the next challenge.
|
||||
nextIntervalHours = rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1)
|
||||
}
|
||||
|
||||
// Expire old challenges first.
|
||||
p.expireRivalChallenges()
|
||||
|
||||
// Check if enough time has passed since last challenge.
|
||||
last := lastRivalChallengeTime()
|
||||
if !last.IsZero() && now.Sub(last) < time.Duration(nextIntervalHours)*time.Hour {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to issue a challenge.
|
||||
challenger, challenged := p.selectRivalPair()
|
||||
if challenger == nil || challenged == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
p.issueRivalChallenge(challenger, challenged)
|
||||
|
||||
// Roll a fresh interval for the next challenge.
|
||||
nextIntervalHours = rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,29 +31,34 @@ func (p *AdventurePlugin) robbieTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
dateKey := now.Format("2006-01-02")
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
now := time.Now().UTC()
|
||||
dateKey := now.Format("2006-01-02")
|
||||
|
||||
// At midnight (or first tick of the day), pick today's target hour.
|
||||
if robbieTargetDay != dateKey {
|
||||
robbieTargetHour = 8 + rand.IntN(14) // 8–21 inclusive
|
||||
robbieTargetDay = dateKey
|
||||
slog.Info("adventure: robbie target hour set", "hour", robbieTargetHour, "date", dateKey)
|
||||
// At midnight (or first tick of the day), pick today's target hour.
|
||||
if robbieTargetDay != dateKey {
|
||||
robbieTargetHour = 8 + rand.IntN(14) // 8–21 inclusive
|
||||
robbieTargetDay = dateKey
|
||||
slog.Info("adventure: robbie target hour set", "hour", robbieTargetHour, "date", dateKey)
|
||||
}
|
||||
|
||||
if now.Hour() < robbieTargetHour {
|
||||
continue
|
||||
}
|
||||
|
||||
jobName := "adventure_robbie"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("adventure: robbie sweep starting")
|
||||
p.robbieVisitAll()
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
}
|
||||
|
||||
if now.Hour() < robbieTargetHour {
|
||||
continue
|
||||
}
|
||||
|
||||
jobName := "adventure_robbie"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("adventure: robbie sweep starting")
|
||||
p.robbieVisitAll()
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,21 +18,26 @@ func (p *AdventurePlugin) morningTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != p.morningHour || now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != p.morningHour || now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
dateKey := now.Format("2006-01-02")
|
||||
jobName := "adventure_morning"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
dateKey := now.Format("2006-01-02")
|
||||
jobName := "adventure_morning"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("adventure: sending morning DMs")
|
||||
p.sendMorningDMs()
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
slog.Info("adventure: sending morning DMs")
|
||||
p.sendMorningDMs()
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,21 +159,26 @@ func (p *AdventurePlugin) summaryTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != p.summaryHour || now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != p.summaryHour || now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
dateKey := now.Format("2006-01-02")
|
||||
jobName := "adventure_summary"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
dateKey := now.Format("2006-01-02")
|
||||
jobName := "adventure_summary"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("adventure: posting daily summary")
|
||||
p.postDailySummary()
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
slog.Info("adventure: posting daily summary")
|
||||
p.postDailySummary()
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,26 +313,31 @@ func (p *AdventurePlugin) midnightTicker() {
|
||||
|
||||
lastRanDate := ""
|
||||
|
||||
for range ticker.C {
|
||||
dateKey := time.Now().UTC().Format("2006-01-02")
|
||||
if dateKey == lastRanDate {
|
||||
continue
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
dateKey := time.Now().UTC().Format("2006-01-02")
|
||||
if dateKey == lastRanDate {
|
||||
continue
|
||||
}
|
||||
|
||||
// New UTC day — check DB in case we already ran (e.g. bot restart).
|
||||
jobName := "adventure_midnight"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
// New UTC day — check DB in case we already ran (e.g. bot restart).
|
||||
jobName := "adventure_midnight"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
lastRanDate = dateKey
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("adventure: midnight reset")
|
||||
if err := p.midnightReset(); err != nil {
|
||||
slog.Error("adventure: midnight reset failed, will retry next tick", "err", err)
|
||||
continue
|
||||
}
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
lastRanDate = dateKey
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("adventure: midnight reset")
|
||||
if err := p.midnightReset(); err != nil {
|
||||
slog.Error("adventure: midnight reset failed, will retry next tick", "err", err)
|
||||
continue
|
||||
}
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
lastRanDate = dateKey
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,28 +26,33 @@ func (p *AdventurePlugin) coopTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
// Locks: every minute.
|
||||
p.coopProcessLocks()
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Locks: every minute.
|
||||
p.coopProcessLocks()
|
||||
|
||||
// Gift expiries: every minute. Resolves any gift whose individual
|
||||
// 6h voting window has elapsed. Modifier sits on the run until the
|
||||
// next floor resolution merges it.
|
||||
p.coopProcessGiftExpiries()
|
||||
// Gift expiries: every minute. Resolves any gift whose individual
|
||||
// 6h voting window has elapsed. Modifier sits on the run until the
|
||||
// next floor resolution merges it.
|
||||
p.coopProcessGiftExpiries()
|
||||
|
||||
// Resolutions: once per day at morningHour:00 UTC.
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != p.morningHour || now.Minute() != 0 {
|
||||
continue
|
||||
// Resolutions: once per day at morningHour:00 UTC.
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != p.morningHour || now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
dateKey := now.Format("2006-01-02")
|
||||
jobName := "coop_dungeon_daily"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
slog.Info("coop: daily tick — resolving active runs")
|
||||
p.coopProcessActiveRuns()
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
}
|
||||
dateKey := now.Format("2006-01-02")
|
||||
jobName := "coop_dungeon_daily"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
slog.Info("coop: daily tick — resolving active runs")
|
||||
p.coopProcessActiveRuns()
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
256
internal/plugin/dnd_audit_phase_R7_test.go
Normal file
256
internal/plugin/dnd_audit_phase_R7_test.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Phase-R7 hardening tests covering the audit gaps: 24h auto-abandon,
|
||||
// briefing idempotency, threat-70 warning idempotency, multi-region
|
||||
// extract→resume, and starvation→forced extraction.
|
||||
|
||||
// ── 24-hour zone-run auto-abandon (§4.3) ────────────────────────────────────
|
||||
|
||||
func TestZoneRun_AutoAbandonsAfter24h(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@stale-run:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("startZoneRun: %v", err)
|
||||
}
|
||||
// Backdate last_action_at to 25 hours ago.
|
||||
if _, err := dbExecZoneRunBackdate(run.RunID, 25*time.Hour); err != nil {
|
||||
t.Fatalf("backdate: %v", err)
|
||||
}
|
||||
got, err := getActiveZoneRun(uid)
|
||||
if err != nil {
|
||||
t.Fatalf("getActiveZoneRun: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("expected nil after 24h timeout, got run %q", got.RunID)
|
||||
}
|
||||
// And the row is marked abandoned.
|
||||
raw, _ := getZoneRun(run.RunID)
|
||||
if raw == nil || !raw.Abandoned {
|
||||
t.Errorf("run not abandoned: %+v", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRun_FreshRunNotAutoAbandoned(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@fresh-run:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := getActiveZoneRun(uid)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("fresh run dropped: got=%v err=%v", got, err)
|
||||
}
|
||||
if got.RunID != run.RunID {
|
||||
t.Errorf("returned wrong run: %q vs %q", got.RunID, run.RunID)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Briefing idempotency: double-fire same day is a no-op ───────────────────
|
||||
|
||||
func TestDeliverBriefing_DoubleFireIsIdempotent(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@brief-idem:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Backdate start so the briefing precondition (start_date < threshold) passes.
|
||||
if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil {
|
||||
t.Fatalf("backdate: %v", err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := p.deliverBriefing(exp, now); err != nil {
|
||||
t.Fatalf("first deliver: %v", err)
|
||||
}
|
||||
first, _ := getExpedition(exp.ID)
|
||||
day1 := first.CurrentDay
|
||||
supplies1 := first.Supplies.Current
|
||||
|
||||
// Re-fetch as a fresh copy (simulates a second tick reading the row
|
||||
// before its claimed last_briefing_at lands in the loader filter).
|
||||
exp2, _ := getExpedition(exp.ID)
|
||||
exp2.Supplies = first.Supplies // pretend stale snapshot
|
||||
if err := p.deliverBriefing(exp2, now); err != nil {
|
||||
t.Fatalf("second deliver: %v", err)
|
||||
}
|
||||
second, _ := getExpedition(exp.ID)
|
||||
if second.CurrentDay != day1 {
|
||||
t.Errorf("day double-bumped: %d → %d", day1, second.CurrentDay)
|
||||
}
|
||||
if second.Supplies.Current != supplies1 {
|
||||
t.Errorf("supplies double-burned: %.1f → %.1f", supplies1, second.Supplies.Current)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Threat-70 warning fires once across multiple crossings ──────────────────
|
||||
|
||||
func TestApplyDailyThreatDrift_70WarningOnceOnly(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@threat-70-once:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := applyThreatDelta(exp.ID, 65, "seed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Cross 70 the first time.
|
||||
// Push over 70 the first time.
|
||||
if err := applyThreatDelta(exp.ID, 6, "drift"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp, _ = getExpedition(exp.ID)
|
||||
if _, _, err := applyDailyThreatDrift(exp); err != nil {
|
||||
t.Fatalf("first drift: %v", err)
|
||||
}
|
||||
|
||||
// Drop below 70 then bump back over.
|
||||
if err := applyThreatDelta(exp.ID, -15, "fortified rest"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := applyThreatDelta(exp.ID, 10, "loud combat"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp, _ = getExpedition(exp.ID)
|
||||
if _, _, err := applyDailyThreatDrift(exp); err != nil {
|
||||
t.Fatalf("second drift: %v", err)
|
||||
}
|
||||
|
||||
rows, err := recentExpeditionLog(exp.ID, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
warnings := 0
|
||||
for _, r := range rows {
|
||||
if r.Type == "threat" && strings.Contains(strings.ToLower(r.Summary), "siege approaches") {
|
||||
warnings++
|
||||
}
|
||||
}
|
||||
if warnings > 1 {
|
||||
t.Errorf("threat-70 warning fired %d times across drift loop; want at most 1", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Starvation triggers forced extraction at briefing time ──────────────────
|
||||
|
||||
func TestDeliverBriefing_StarvationForcesExtraction(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@starve-extract:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
exp, err := startExpedition(uid, ZoneGoblinWarrens, "",
|
||||
ExpeditionSupplies{Current: 0.5, Max: 10, DailyBurn: 5, HarshMod: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil {
|
||||
t.Fatalf("backdate: %v", err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC)
|
||||
if err := p.deliverBriefing(exp, now); err != nil {
|
||||
t.Fatalf("deliver: %v", err)
|
||||
}
|
||||
|
||||
got, _ := getExpedition(exp.ID)
|
||||
if got.Status != ExpeditionStatusAbandoned {
|
||||
t.Errorf("status = %q after starvation briefing, want abandoned", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Multi-region extract→resume preserves region state ──────────────────────
|
||||
|
||||
func TestExtractResume_MultiRegionPreservesState(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@multiregion-resume:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
exp, err := startExpedition(uid, ZoneUnderdark, "underdark_surface_tunnels",
|
||||
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Visit a second region and mark a region-boss kill.
|
||||
if _, err := MarkRegionVisited(exp, "underdark_drow_outpost"); err != nil {
|
||||
t.Fatalf("mark visited: %v", err)
|
||||
}
|
||||
if _, err := MarkRegionBossDefeated(exp, "underdark_drow_outpost"); err != nil {
|
||||
t.Fatalf("mark boss: %v", err)
|
||||
}
|
||||
|
||||
// Extract.
|
||||
if _, err := voluntaryExtractExpedition(uid); err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
|
||||
// Resume with fresh supplies.
|
||||
resumed, err := getResumableExpedition(uid)
|
||||
if err != nil || resumed == nil {
|
||||
t.Fatalf("getResumable: %v %v", resumed, err)
|
||||
}
|
||||
if err := resumeExpedition(resumed.ID, ExpeditionSupplies{
|
||||
Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("resume: %v", err)
|
||||
}
|
||||
|
||||
// Re-load — region state must survive.
|
||||
live, _ := getActiveExpedition(uid)
|
||||
if live == nil {
|
||||
t.Fatal("post-resume: no active expedition")
|
||||
}
|
||||
if !IsRegionVisited(live, "underdark_drow_outpost") {
|
||||
t.Error("visited region lost on resume")
|
||||
}
|
||||
if !IsRegionCleared(live, "underdark_drow_outpost") {
|
||||
t.Error("region-cleared flag lost on resume")
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func dbExecZoneRunBackdate(runID string, ago time.Duration) (int64, error) {
|
||||
when := time.Now().UTC().Add(-ago)
|
||||
r, err := db.Get().Exec(
|
||||
`UPDATE dnd_zone_run SET last_action_at = ? WHERE run_id = ?`, when, runID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
|
||||
func dbExecExpeditionBackdate(expID string, ago time.Duration) (int64, error) {
|
||||
when := time.Now().UTC().Add(-ago)
|
||||
r, err := db.Get().Exec(
|
||||
`UPDATE dnd_expedition SET start_date = ? WHERE expedition_id = ?`, when, expID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.RowsAffected()
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -264,7 +265,10 @@ func scanExpedition(row scanner) (*Expedition, error) {
|
||||
e.ThreatEvents = []ThreatEvent{}
|
||||
}
|
||||
if regionJSON != "" {
|
||||
_ = json.Unmarshal([]byte(regionJSON), &e.RegionState)
|
||||
if err := json.Unmarshal([]byte(regionJSON), &e.RegionState); err != nil {
|
||||
slog.Warn("expedition: region_state decode failed; falling back to empty",
|
||||
"expedition", e.ID, "err", err)
|
||||
}
|
||||
}
|
||||
if e.RegionState == nil {
|
||||
e.RegionState = map[string]any{}
|
||||
@@ -369,6 +373,8 @@ func applyThreatDelta(expID string, delta int, reason string) error {
|
||||
if level > 100 {
|
||||
level = 100
|
||||
}
|
||||
// Spec §8.3: Siege Mode is one-way — the OR keeps siege sticky
|
||||
// even if a subsequent negative delta drops the underlying level.
|
||||
siege := e.SiegeMode || level >= 100
|
||||
events := append(e.ThreatEvents, ThreatEvent{
|
||||
Timestamp: time.Now().UTC(),
|
||||
|
||||
@@ -38,12 +38,17 @@ const (
|
||||
func (p *AdventurePlugin) expeditionBriefingTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != expeditionBriefingHour || now.Minute() != 0 {
|
||||
continue
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != expeditionBriefingHour || now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
p.fireExpeditionBriefings(now)
|
||||
}
|
||||
p.fireExpeditionBriefings(now)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,12 +56,17 @@ func (p *AdventurePlugin) expeditionBriefingTicker() {
|
||||
func (p *AdventurePlugin) expeditionRecapTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != expeditionRecapHour || now.Minute() != 0 {
|
||||
continue
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != expeditionRecapHour || now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
p.fireExpeditionRecaps(now)
|
||||
}
|
||||
p.fireExpeditionRecaps(now)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +159,29 @@ func scanExpeditionRows(rows *sql.Rows) ([]*Expedition, error) {
|
||||
|
||||
// deliverBriefing rolls a day forward, applies supply burn, posts the
|
||||
// morning briefing DM, appends a log entry, and stamps last_briefing_at.
|
||||
//
|
||||
// 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.
|
||||
func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
||||
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
||||
expeditionBriefingHour, 0, 0, 0, time.UTC)
|
||||
res, err := db.Get().Exec(`
|
||||
UPDATE dnd_expedition
|
||||
SET last_briefing_at = ?,
|
||||
last_activity = ?
|
||||
WHERE expedition_id = ?
|
||||
AND status = 'active'
|
||||
AND (last_briefing_at IS NULL OR last_briefing_at < ?)`,
|
||||
now, now, e.ID, threshold)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil // already delivered for this day
|
||||
}
|
||||
|
||||
// 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.).
|
||||
@@ -202,7 +234,19 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
||||
// has advanced, so e.CurrentDay reflects the new day).
|
||||
temporalLines := applyZoneTemporalPostRollover(e)
|
||||
|
||||
// E5b: if a temporal event forced extraction (abyss collapse, etc.),
|
||||
// §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 {
|
||||
@@ -240,17 +284,30 @@ func (p *AdventurePlugin) deliverBriefing(e *Expedition, now time.Time) error {
|
||||
fmt.Sprintf("morning briefing — %.1f SU consumed overnight", burn), line); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := db.Get().Exec(`
|
||||
UPDATE dnd_expedition
|
||||
SET last_briefing_at = ?,
|
||||
last_activity = ?
|
||||
WHERE expedition_id = ?`, now, now, e.ID)
|
||||
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.
|
||||
func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
|
||||
threshold := time.Date(now.Year(), now.Month(), now.Day(),
|
||||
expeditionRecapHour, 0, 0, 0, time.UTC)
|
||||
res, err := db.Get().Exec(`
|
||||
UPDATE dnd_expedition
|
||||
SET last_recap_at = ?,
|
||||
last_activity = ?
|
||||
WHERE expedition_id = ?
|
||||
AND status = 'active'
|
||||
AND (last_recap_at IS NULL OR last_recap_at < ?)`,
|
||||
now, now, e.ID, threshold)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// E2b: night phase wandering check fires before the recap so its
|
||||
// outcome is part of today's log when the recap renders.
|
||||
var night *NightCheck
|
||||
@@ -300,12 +357,7 @@ func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
|
||||
fmt.Sprintf("evening recap — %d log entries today", len(dayEntries)), line); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.Get().Exec(`
|
||||
UPDATE dnd_expedition
|
||||
SET last_recap_at = ?,
|
||||
last_activity = ?
|
||||
WHERE expedition_id = ?`, now, now, e.ID)
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
// pickMorningBriefing returns a flavor line based on day-band: Day 1, 3, 7,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/flavor"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Phase 12 E2a — Threat Clock state machine.
|
||||
@@ -134,9 +135,19 @@ func applyDailyThreatDrift(e *Expedition) (int, string, error) {
|
||||
if newBand != prevBand && newBand > prevBand {
|
||||
_ = appendThreatTransitionLog(e, newBand)
|
||||
}
|
||||
// E2c: §8.3 — one-time warning when crossing 70.
|
||||
// E2c: §8.3 — one-time-per-expedition warning when crossing 70.
|
||||
// Track in RegionState so a drop-and-recross (e.g. fortified-camp
|
||||
// -5 followed by daily +3 drift) doesn't re-fire the beat.
|
||||
if prevLevel < 70 && e.ThreatLevel >= 70 {
|
||||
_ = appendApproachingSiegeLog(e)
|
||||
warned, _ := e.RegionState["siege_warning_fired"].(bool)
|
||||
if !warned {
|
||||
_ = appendApproachingSiegeLog(e)
|
||||
if e.RegionState == nil {
|
||||
e.RegionState = map[string]any{}
|
||||
}
|
||||
e.RegionState["siege_warning_fired"] = true
|
||||
_ = persistRegionState(e)
|
||||
}
|
||||
}
|
||||
return delta, reason, nil
|
||||
}
|
||||
@@ -246,3 +257,18 @@ func threatBandEffectsBlock(info ThreatBandInfo) string {
|
||||
func applyBossDefeatThreat(expID string) error {
|
||||
return applyThreatDelta(expID, -20, "boss defeated")
|
||||
}
|
||||
|
||||
// applyRoomCombatThreat records the +5 threat bump per §8.1 when the
|
||||
// player resolves combat in a new (non-boss) room. Silent no-op for
|
||||
// standalone zone runs (no active expedition).
|
||||
func applyRoomCombatThreatForUser(userID id.UserID, elite bool) {
|
||||
exp, err := getActiveExpedition(userID)
|
||||
if err != nil || exp == nil {
|
||||
return
|
||||
}
|
||||
delta := 5
|
||||
if elite {
|
||||
delta = 8 // elite encounters are louder
|
||||
}
|
||||
_ = applyThreatDelta(exp.ID, delta, "combat in new room")
|
||||
}
|
||||
|
||||
@@ -46,7 +46,9 @@ func TestProdDB_DnDLayer(t *testing.T) {
|
||||
t.Logf("loaded %d real adventure characters", len(chars))
|
||||
|
||||
// 2. For each, verify they have NO dnd_character row yet, then auto-migrate
|
||||
// and verify the resulting row is sensible.
|
||||
// and verify the resulting row is sensible. Track which chars we
|
||||
// actually migrated so steps 3+ can target one of those.
|
||||
migrated := make(map[id.UserID]bool)
|
||||
for i := range chars {
|
||||
char := &chars[i]
|
||||
uid := char.UserID
|
||||
@@ -57,7 +59,10 @@ func TestProdDB_DnDLayer(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
if existing != nil {
|
||||
t.Errorf("user=%s: had dnd_character row pre-migrate (should be empty)", uid)
|
||||
// Real prod DB has accumulated rows from live bot use.
|
||||
// Skip these — the auto-migrate path is exercised by
|
||||
// other characters in the loop.
|
||||
t.Logf("user=%s: skipping (already has dnd_character row)", uid)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -110,14 +115,22 @@ func TestProdDB_DnDLayer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
migrated[uid] = true
|
||||
t.Logf("auto-migrated user=%s → L%d %s %s HP=%d AC=%d STR/DEX/CON/INT/WIS/CHA=%d/%d/%d/%d/%d/%d",
|
||||
uid, dnd.Level, dnd.Race, dnd.Class, dnd.HPMax, dnd.ArmorClass,
|
||||
dnd.STR, dnd.DEX, dnd.CON, dnd.INT, dnd.WIS, dnd.CHA)
|
||||
}
|
||||
|
||||
if len(migrated) == 0 {
|
||||
t.Skip("no characters available for auto-migrate (all pre-existing); steps 3+ require a fresh migrate")
|
||||
}
|
||||
|
||||
// 3. Re-load each auto-migrated char — round-trip integrity.
|
||||
for i := range chars {
|
||||
uid := chars[i].UserID
|
||||
if !migrated[uid] {
|
||||
continue
|
||||
}
|
||||
dnd, err := LoadDnDCharacter(uid)
|
||||
if err != nil {
|
||||
t.Errorf("user=%s: re-load failed: %v", uid, err)
|
||||
@@ -134,10 +147,17 @@ func TestProdDB_DnDLayer(t *testing.T) {
|
||||
}
|
||||
|
||||
// 4. ensureDnDCharacterForCombat is idempotent — second call returns the
|
||||
// same row, doesn't create a duplicate.
|
||||
uid := chars[0].UserID
|
||||
// same row, doesn't create a duplicate. Pick the first migrated char.
|
||||
var idemIdx int
|
||||
for i := range chars {
|
||||
if migrated[chars[i].UserID] {
|
||||
idemIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
uid := chars[idemIdx].UserID
|
||||
first, _ := LoadDnDCharacter(uid)
|
||||
second, freshAgain, err := ensureDnDCharacterForCombat(uid, &chars[0])
|
||||
second, freshAgain, err := ensureDnDCharacterForCombat(uid, &chars[idemIdx])
|
||||
if err != nil {
|
||||
t.Errorf("idempotent ensure failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -406,6 +406,15 @@ func (p *AdventurePlugin) handleDnDRespecCmd(ctx MessageContext) error {
|
||||
if err := wipeSpellsForUser(ctx.Sender); err != nil {
|
||||
slog.Error("dnd: respec spell wipe", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
// Phase R hardening: a respec also abandons any active zone-run /
|
||||
// expedition keyed to the wiped character so they don't orphan with
|
||||
// pointers to a stat sheet that no longer exists.
|
||||
if err := abandonZoneRun(ctx.Sender); err != nil && err != ErrNoActiveRun {
|
||||
slog.Warn("dnd: respec zone-run cleanup", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
if err := abandonExpedition(ctx.Sender); err != nil && err != ErrNoActiveExpedition {
|
||||
slog.Warn("dnd: respec expedition cleanup", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
// Debit last. If this fails (rare race — euros spent elsewhere between
|
||||
// the pre-check and now), the player got a free respec. Strictly better
|
||||
// than the alternative of euros-lost-with-wipe-not-applied.
|
||||
@@ -453,6 +462,14 @@ func loadOrInitDraft(userID id.UserID) (*DnDCharacter, error) {
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("clear auto-migrated row: %w", err)
|
||||
}
|
||||
// Auto-migrated wipe: also abandon active zone-run /
|
||||
// expedition so they don't orphan against the deleted row.
|
||||
if err := abandonZoneRun(userID); err != nil && err != ErrNoActiveRun {
|
||||
slog.Warn("dnd: auto-migrated wipe zone-run cleanup", "user", userID, "err", err)
|
||||
}
|
||||
if err := abandonExpedition(userID); err != nil && err != ErrNoActiveExpedition {
|
||||
slog.Warn("dnd: auto-migrated wipe expedition cleanup", "user", userID, "err", err)
|
||||
}
|
||||
// fall through to fresh draft
|
||||
} else if !c.PendingSetup {
|
||||
return nil, fmt.Errorf("character already finalized — use !respec")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package plugin
|
||||
|
||||
import "math"
|
||||
|
||||
// Phase 10 SUB2a — subclass combat hooks.
|
||||
//
|
||||
// applySubclassPassives layers subclass-driven flags onto CombatModifiers
|
||||
@@ -429,6 +431,46 @@ func init() {
|
||||
mods.HealItem += base + abilityModifier(c.CHA)
|
||||
},
|
||||
}
|
||||
// Three more maneuvers using existing CombatModifiers fields. The
|
||||
// remaining four spec maneuvers (Pushing, Goading, Riposte, Commander's
|
||||
// Strike) need ally-targeting / reaction mechanics that the one-shot
|
||||
// engine doesn't model — they're omitted rather than approximated
|
||||
// inaccurately.
|
||||
dndActiveAbilities["disarming_attack"] = DnDAbility{
|
||||
ID: "disarming_attack",
|
||||
Name: "Disarming Attack",
|
||||
Class: ClassFighter,
|
||||
Subclass: SubclassBattleMaster,
|
||||
Resource: "superiority",
|
||||
Description: "Knock the enemy's weapon aside: their damage is reduced for the rest of the fight (consumes 1 superiority die).",
|
||||
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
|
||||
// 25% incoming-damage cut models a fighter who is now
|
||||
// swinging an improvised weapon at -d4-ish.
|
||||
mods.DamageReduct = math.Max(mods.DamageReduct, 0.25)
|
||||
},
|
||||
}
|
||||
dndActiveAbilities["menacing_attack"] = DnDAbility{
|
||||
ID: "menacing_attack",
|
||||
Name: "Menacing Attack",
|
||||
Class: ClassFighter,
|
||||
Subclass: SubclassBattleMaster,
|
||||
Resource: "superiority",
|
||||
Description: "Snarl as you swing — the enemy hesitates and skips its first attack (consumes 1 superiority die).",
|
||||
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
|
||||
mods.SpellEnemySkipFirst = true
|
||||
},
|
||||
}
|
||||
dndActiveAbilities["parry"] = DnDAbility{
|
||||
ID: "parry",
|
||||
Name: "Parry",
|
||||
Class: ClassFighter,
|
||||
Subclass: SubclassBattleMaster,
|
||||
Resource: "superiority",
|
||||
Description: "Set yourself for incoming blows — physical damage taken is sharply reduced (consumes 1 superiority die).",
|
||||
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
|
||||
mods.DamageReduct = math.Max(mods.DamageReduct, 0.40)
|
||||
},
|
||||
}
|
||||
|
||||
// Phase 10 SUB2c — Cleric Channel Divinity. All three Cleric subclasses
|
||||
// share the "channel_divinity" resource pool (2/long-rest in our model;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package plugin
|
||||
|
||||
import "sort"
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Phase 11 D1a — zone registry. Implements `gogobee_dungeon_zones.md` §5.
|
||||
//
|
||||
@@ -127,6 +130,25 @@ func getZone(id ZoneID) (ZoneDefinition, bool) {
|
||||
return z, ok
|
||||
}
|
||||
|
||||
// zoneOrFallback returns the registered ZoneDefinition or, if the id
|
||||
// is unknown (corrupted DB row, dropped registration), a safe
|
||||
// placeholder so display strings don't panic. Use this at non-fatal
|
||||
// callsites that just need a Display/Hook/Atmosphere to print.
|
||||
func zoneOrFallback(id ZoneID) ZoneDefinition {
|
||||
if z, ok := dndZoneRegistry[id]; ok {
|
||||
return z
|
||||
}
|
||||
return ZoneDefinition{
|
||||
ID: id,
|
||||
Display: fmt.Sprintf("(unknown zone %q)", string(id)),
|
||||
Hook: "",
|
||||
Atmosphere: "",
|
||||
Tier: 1,
|
||||
LevelMin: 1,
|
||||
LevelMax: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// zonesForLevel returns all zones a player at dndLevel may enter.
|
||||
// Per design doc §2: a player cannot enter a zone more than 2 tiers
|
||||
// above their current level. We approximate "current tier" as
|
||||
|
||||
@@ -110,7 +110,7 @@ func (p *AdventurePlugin) zoneCmdList(ctx MessageContext, c *DnDCharacter) error
|
||||
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
|
||||
}
|
||||
if active, _ := getActiveZoneRun(ctx.Sender); active != nil {
|
||||
zone, _ := getZone(active.ZoneID)
|
||||
zone := zoneOrFallback(active.ZoneID)
|
||||
b.WriteString(fmt.Sprintf("\n_⚠ Active run: **%s**, room %d/%d. Use `!zone status` or `!zone abandon`._",
|
||||
zone.Display, active.CurrentRoom+1, active.TotalRooms))
|
||||
}
|
||||
@@ -180,7 +180,7 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest
|
||||
switch err {
|
||||
case ErrRunAlreadyActive:
|
||||
active, _ := getActiveZoneRun(ctx.Sender)
|
||||
zone, _ := getZone(active.ZoneID)
|
||||
zone := zoneOrFallback(active.ZoneID)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You're already in **%s** (room %d/%d). Finish it or `!zone abandon` first.",
|
||||
zone.Display, active.CurrentRoom+1, active.TotalRooms))
|
||||
@@ -193,7 +193,7 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest
|
||||
return p.SendDM(ctx.Sender, "Couldn't start zone run: "+err.Error())
|
||||
}
|
||||
}
|
||||
zone, _ := getZone(run.ZoneID)
|
||||
zone := zoneOrFallback(run.ZoneID)
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("🗝 **Entering %s** _(T%d, %d rooms)_\n\n", zone.Display, int(zone.Tier), run.TotalRooms))
|
||||
b.WriteString("_" + zone.Hook + "_\n\n")
|
||||
@@ -221,7 +221,7 @@ func (p *AdventurePlugin) zoneCmdStatus(ctx MessageContext) error {
|
||||
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone list` then `!zone enter <id>`.")
|
||||
}
|
||||
_ = applyMoodDecayIfStale(run)
|
||||
zone, _ := getZone(run.ZoneID)
|
||||
zone := zoneOrFallback(run.ZoneID)
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("**%s** — room %d/%d (%s)\n",
|
||||
zone.Display, run.CurrentRoom+1, run.TotalRooms, prettyRoomType(run.CurrentRoomType())))
|
||||
@@ -279,7 +279,7 @@ func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error {
|
||||
if run == nil {
|
||||
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
||||
}
|
||||
zone, _ := getZone(run.ZoneID)
|
||||
zone := zoneOrFallback(run.ZoneID)
|
||||
cleared := map[int]bool{}
|
||||
for _, r := range run.RoomsCleared {
|
||||
cleared[r] = true
|
||||
@@ -354,7 +354,7 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
||||
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
||||
}
|
||||
_ = applyMoodDecayIfStale(run)
|
||||
zone, _ := getZone(run.ZoneID)
|
||||
zone := zoneOrFallback(run.ZoneID)
|
||||
prev := run.CurrentRoomType()
|
||||
prevIdx := run.CurrentRoom
|
||||
|
||||
@@ -479,7 +479,7 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
scanMoodEventsFromCombat(run.RunID, result)
|
||||
nat20s, nat1s := scanMoodEventsFromCombat(run.RunID, result)
|
||||
|
||||
var b strings.Builder
|
||||
if elite {
|
||||
@@ -492,6 +492,20 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
// Surface the most-impactful crit/fumble as TwinBee narration. A run
|
||||
// with both gets the nat-20 line — players already see the fumble in
|
||||
// the combat log, but the gloat lands harder than the pity.
|
||||
if nat20s > 0 {
|
||||
if line := twinBeeLine(zone.ID, GMNat20, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
} else if nat1s > 0 {
|
||||
if line := twinBeeLine(zone.ID, GMNat1, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if elite {
|
||||
b.WriteString(fmt.Sprintf("⚔️ **Elite encounter — %s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC))
|
||||
} else {
|
||||
@@ -513,6 +527,7 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d).", monster.Name, result.PlayerStartHP, result.PlayerEndHP))
|
||||
recordZoneKillForUser(userID, monster.ID)
|
||||
applyRoomCombatThreatForUser(userID, elite)
|
||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(drop)
|
||||
@@ -531,10 +546,21 @@ func (p *AdventurePlugin) resolveBossRoom(userID id.UserID, run *DungeonRun, zon
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
scanMoodEventsFromCombat(run.RunID, result)
|
||||
nat20s, nat1s := scanMoodEventsFromCombat(run.RunID, result)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("👑 **Boss — %s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC))
|
||||
if nat20s > 0 {
|
||||
if line := twinBeeLine(zone.ID, GMNat20, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
} else if nat1s > 0 {
|
||||
if line := twinBeeLine(zone.ID, GMNat1, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if phaseTwoCrossedInEvents(result.Events, result.EnemyStartHP, zone.Boss.PhaseTwoAt) {
|
||||
if line := bossPhaseTwoLine(zone.ID, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
@@ -557,6 +583,11 @@ func (p *AdventurePlugin) resolveBossRoom(userID id.UserID, run *DungeonRun, zon
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("🏆 **%s** falls (HP %d→%d).", monster.Name, result.PlayerStartHP, result.PlayerEndHP))
|
||||
recordZoneKillForUser(userID, monster.ID)
|
||||
// §8.1: zone boss defeat drops expedition threat by 20. Silent
|
||||
// no-op for standalone zone runs (no active expedition).
|
||||
if exp, err := getActiveExpedition(userID); err == nil && exp != nil {
|
||||
_ = applyBossDefeatThreat(exp.ID)
|
||||
}
|
||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, true); drop != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(drop)
|
||||
@@ -627,7 +658,7 @@ func (p *AdventurePlugin) zoneCmdLore(ctx MessageContext) error {
|
||||
if run == nil {
|
||||
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
||||
}
|
||||
zone, _ := getZone(run.ZoneID)
|
||||
zone := zoneOrFallback(run.ZoneID)
|
||||
line := twinBeeLine(zone.ID, GMLore, run.RunID, run.CurrentRoom)
|
||||
if line == "" {
|
||||
return p.SendDM(ctx.Sender, "TwinBee has nothing to say about this place.")
|
||||
@@ -648,7 +679,7 @@ func (p *AdventurePlugin) zoneCmdAbandon(ctx MessageContext) error {
|
||||
if run == nil {
|
||||
return p.SendDM(ctx.Sender, "Run abandoned.")
|
||||
}
|
||||
zone, _ := getZone(run.ZoneID)
|
||||
zone := zoneOrFallback(run.ZoneID)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"🚪 Abandoned **%s** at room %d/%d. No rewards.",
|
||||
zone.Display, run.CurrentRoom+1, run.TotalRooms))
|
||||
|
||||
@@ -3,6 +3,7 @@ package plugin
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -465,12 +466,18 @@ func applyMoodEvent(runID string, ev GMMoodEvent) (int, error) {
|
||||
// Design doc §3.2: "Mood decays toward 50 at a rate of ±2 per hour
|
||||
// (passive rebalancing)." Long-idle runs reset toward neutral.
|
||||
func passiveDecayMood(score int, lastTouched, now time.Time) int {
|
||||
hours := int(now.Sub(lastTouched).Hours())
|
||||
if hours <= 0 {
|
||||
elapsedHours := now.Sub(lastTouched).Hours()
|
||||
if elapsedHours <= 0 {
|
||||
return clampMood(score)
|
||||
}
|
||||
const ratePerHour = 2.0
|
||||
// Round to nearest unit so a 30-minute gap decays 1, a 12-minute
|
||||
// gap decays 0. Avoids the truncation bug where any sub-hour gap
|
||||
// produced no decay.
|
||||
drift := int(math.Round(elapsedHours * ratePerHour))
|
||||
if drift <= 0 {
|
||||
return clampMood(score)
|
||||
}
|
||||
const ratePerHour = 2
|
||||
drift := hours * ratePerHour
|
||||
switch {
|
||||
case score > 50:
|
||||
score -= drift
|
||||
|
||||
@@ -202,7 +202,15 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
|
||||
return run, nil
|
||||
}
|
||||
|
||||
// getActiveZoneRun returns the player's in-flight run, or (nil, nil) if none.
|
||||
// zoneRunInactivityTimeout is the §4.3 stale-run threshold: a run that
|
||||
// has gone untouched for this long is auto-abandoned the next time
|
||||
// anyone looks at it.
|
||||
const zoneRunInactivityTimeout = 24 * time.Hour
|
||||
|
||||
// getActiveZoneRun returns the player's in-flight run, or (nil, nil) if
|
||||
// none. If the most-recent active run has been idle longer than
|
||||
// zoneRunInactivityTimeout, it's auto-abandoned (§4.3) and the
|
||||
// function returns (nil, nil) — the caller sees a clean slate.
|
||||
func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) {
|
||||
row := db.Get().QueryRow(`
|
||||
SELECT run_id, user_id, zone_id, current_room, total_rooms,
|
||||
@@ -220,7 +228,14 @@ func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return r, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if time.Since(r.LastActionAt) > zoneRunInactivityTimeout {
|
||||
_ = abandonZoneRunByID(r.RunID)
|
||||
return nil, nil
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// getZoneRun fetches by RunID regardless of completion state. Test/admin use.
|
||||
|
||||
Reference in New Issue
Block a user