mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +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 {
|
||||
|
||||
Reference in New Issue
Block a user