mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 00:52: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)
|
treasureUndo sync.Map // userID string -> *advTreasureUndoToken (10-min auto-swap reversal)
|
||||||
morningHour int
|
morningHour int
|
||||||
summaryHour 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.
|
// 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
|
// Revive any characters whose DeadUntil has expired
|
||||||
p.catchUpRespawns(chars)
|
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.morningTicker()
|
||||||
go p.summaryTicker()
|
go p.summaryTicker()
|
||||||
go p.midnightTicker()
|
go p.midnightTicker()
|
||||||
@@ -1092,13 +1118,11 @@ func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCha
|
|||||||
announceDef := *drop.Def
|
announceDef := *drop.Def
|
||||||
announceLoc := *loc
|
announceLoc := *loc
|
||||||
announceTimer := time.AfterFunc(advTreasureUndoWindow, func() {
|
announceTimer := time.AfterFunc(advTreasureUndoWindow, func() {
|
||||||
// Token is deleted on undo, so only fire when it's still present.
|
// Atomically claim the token so a concurrent `undo` reply
|
||||||
// The map entry also gets cleared lazily below by future undo
|
// can't also fire the announcement (or vice-versa).
|
||||||
// attempts; firing once based on Timer presence is enough.
|
if _, ok := p.treasureUndo.LoadAndDelete(string(userID)); !ok {
|
||||||
if _, ok := p.treasureUndo.Load(string(userID)); !ok {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p.treasureUndo.Delete(string(userID))
|
|
||||||
p.announceTreasureToRoom(&announceChar, &announceDef, &announceLoc)
|
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
|
// was applied. Stale or missing tokens return false so the caller falls
|
||||||
// through to the regular DM dispatch.
|
// through to the regular DM dispatch.
|
||||||
func (p *AdventurePlugin) tryTreasureUndo(userID id.UserID) bool {
|
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 {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
tok := val.(*advTreasureUndoToken)
|
tok := val.(*advTreasureUndoToken)
|
||||||
p.treasureUndo.Delete(string(userID))
|
|
||||||
// Cancel the deferred room announcement — a reversed swap shouldn't
|
// Cancel the deferred room announcement — a reversed swap shouldn't
|
||||||
// produce a public "X recovered Y" line.
|
// produce a public "X recovered Y" line.
|
||||||
if tok.AnnounceTimer != nil {
|
if tok.AnnounceTimer != nil {
|
||||||
|
|||||||
@@ -41,7 +41,11 @@ func (p *AdventurePlugin) eventTicker() {
|
|||||||
ticker := time.NewTicker(1 * time.Minute)
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for range ticker.C {
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
dateKey := now.Format("2006-01-02")
|
dateKey := now.Format("2006-01-02")
|
||||||
currentMinute := now.Hour()*60 + now.Minute()
|
currentMinute := now.Hour()*60 + now.Minute()
|
||||||
@@ -98,6 +102,7 @@ func (p *AdventurePlugin) eventTicker() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
|
func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
|
||||||
// Load character — must be alive and have acted today
|
// Load character — must be alive and have acted today
|
||||||
|
|||||||
@@ -248,7 +248,11 @@ func (p *AdventurePlugin) hospitalNudgeTicker() {
|
|||||||
ticker := time.NewTicker(1 * time.Minute)
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for range ticker.C {
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
p.hospitalNudges.Range(func(key, val any) bool {
|
p.hospitalNudges.Range(func(key, val any) bool {
|
||||||
uid := key.(string)
|
uid := key.(string)
|
||||||
@@ -281,3 +285,4 @@ func (p *AdventurePlugin) hospitalNudgeTicker() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,7 +107,11 @@ func (p *AdventurePlugin) mortgageTicker() {
|
|||||||
ticker := time.NewTicker(1 * time.Hour)
|
ticker := time.NewTicker(1 * time.Hour)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for range ticker.C {
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
// Run on Mondays only (FRED updates Thursdays, we process Mondays)
|
// Run on Mondays only (FRED updates Thursdays, we process Mondays)
|
||||||
if now.Weekday() != time.Monday {
|
if now.Weekday() != time.Monday {
|
||||||
@@ -138,6 +142,7 @@ func (p *AdventurePlugin) mortgageTicker() {
|
|||||||
db.MarkJobCompleted(jobName, dateKey)
|
db.MarkJobCompleted(jobName, dateKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const lastKnownRateCacheKey = "mortgage_last_known_rate"
|
const lastKnownRateCacheKey = "mortgage_last_known_rate"
|
||||||
|
|
||||||
|
|||||||
@@ -778,7 +778,11 @@ func (p *AdventurePlugin) rivalChallengeTicker() {
|
|||||||
// Roll the next challenge interval once. Re-roll after each issued challenge.
|
// Roll the next challenge interval once. Re-roll after each issued challenge.
|
||||||
nextIntervalHours := rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1)
|
nextIntervalHours := rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1)
|
||||||
|
|
||||||
for range ticker.C {
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|
||||||
// Only fire on the hour.
|
// Only fire on the hour.
|
||||||
@@ -814,6 +818,7 @@ func (p *AdventurePlugin) rivalChallengeTicker() {
|
|||||||
nextIntervalHours = rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1)
|
nextIntervalHours = rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Rivals Command ───────────────────────────────────────────────────────────
|
// ── Rivals Command ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ func (p *AdventurePlugin) robbieTicker() {
|
|||||||
ticker := time.NewTicker(1 * time.Minute)
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for range ticker.C {
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
dateKey := now.Format("2006-01-02")
|
dateKey := now.Format("2006-01-02")
|
||||||
|
|
||||||
@@ -56,6 +60,7 @@ func (p *AdventurePlugin) robbieTicker() {
|
|||||||
db.MarkJobCompleted(jobName, dateKey)
|
db.MarkJobCompleted(jobName, dateKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Visit All Players ────────────────────────────────────────────────────────
|
// ── Visit All Players ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ func (p *AdventurePlugin) morningTicker() {
|
|||||||
ticker := time.NewTicker(1 * time.Minute)
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for range ticker.C {
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
if now.Hour() != p.morningHour || now.Minute() != 0 {
|
if now.Hour() != p.morningHour || now.Minute() != 0 {
|
||||||
continue
|
continue
|
||||||
@@ -35,6 +39,7 @@ func (p *AdventurePlugin) morningTicker() {
|
|||||||
db.MarkJobCompleted(jobName, dateKey)
|
db.MarkJobCompleted(jobName, dateKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *AdventurePlugin) sendMorningDMs() {
|
func (p *AdventurePlugin) sendMorningDMs() {
|
||||||
chars, err := loadAllAdvCharacters()
|
chars, err := loadAllAdvCharacters()
|
||||||
@@ -154,7 +159,11 @@ func (p *AdventurePlugin) summaryTicker() {
|
|||||||
ticker := time.NewTicker(1 * time.Minute)
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for range ticker.C {
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
if now.Hour() != p.summaryHour || now.Minute() != 0 {
|
if now.Hour() != p.summaryHour || now.Minute() != 0 {
|
||||||
continue
|
continue
|
||||||
@@ -171,6 +180,7 @@ func (p *AdventurePlugin) summaryTicker() {
|
|||||||
db.MarkJobCompleted(jobName, dateKey)
|
db.MarkJobCompleted(jobName, dateKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *AdventurePlugin) postDailySummary() {
|
func (p *AdventurePlugin) postDailySummary() {
|
||||||
gr := gamesRoom()
|
gr := gamesRoom()
|
||||||
@@ -303,7 +313,11 @@ func (p *AdventurePlugin) midnightTicker() {
|
|||||||
|
|
||||||
lastRanDate := ""
|
lastRanDate := ""
|
||||||
|
|
||||||
for range ticker.C {
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
dateKey := time.Now().UTC().Format("2006-01-02")
|
dateKey := time.Now().UTC().Format("2006-01-02")
|
||||||
if dateKey == lastRanDate {
|
if dateKey == lastRanDate {
|
||||||
continue
|
continue
|
||||||
@@ -325,6 +339,7 @@ func (p *AdventurePlugin) midnightTicker() {
|
|||||||
lastRanDate = dateKey
|
lastRanDate = dateKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p *AdventurePlugin) midnightReset() error {
|
func (p *AdventurePlugin) midnightReset() error {
|
||||||
// Send idle shame DMs to players who didn't act
|
// Send idle shame DMs to players who didn't act
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ func (p *AdventurePlugin) coopTicker() {
|
|||||||
ticker := time.NewTicker(1 * time.Minute)
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for range ticker.C {
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
// Locks: every minute.
|
// Locks: every minute.
|
||||||
p.coopProcessLocks()
|
p.coopProcessLocks()
|
||||||
|
|
||||||
@@ -50,6 +54,7 @@ func (p *AdventurePlugin) coopTicker() {
|
|||||||
db.MarkJobCompleted(jobName, dateKey)
|
db.MarkJobCompleted(jobName, dateKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Lock Phase ──────────────────────────────────────────────────────────────
|
// ── Lock Phase ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
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"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
@@ -264,7 +265,10 @@ func scanExpedition(row scanner) (*Expedition, error) {
|
|||||||
e.ThreatEvents = []ThreatEvent{}
|
e.ThreatEvents = []ThreatEvent{}
|
||||||
}
|
}
|
||||||
if regionJSON != "" {
|
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 {
|
if e.RegionState == nil {
|
||||||
e.RegionState = map[string]any{}
|
e.RegionState = map[string]any{}
|
||||||
@@ -369,6 +373,8 @@ func applyThreatDelta(expID string, delta int, reason string) error {
|
|||||||
if level > 100 {
|
if level > 100 {
|
||||||
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
|
siege := e.SiegeMode || level >= 100
|
||||||
events := append(e.ThreatEvents, ThreatEvent{
|
events := append(e.ThreatEvents, ThreatEvent{
|
||||||
Timestamp: time.Now().UTC(),
|
Timestamp: time.Now().UTC(),
|
||||||
|
|||||||
@@ -38,7 +38,11 @@ const (
|
|||||||
func (p *AdventurePlugin) expeditionBriefingTicker() {
|
func (p *AdventurePlugin) expeditionBriefingTicker() {
|
||||||
ticker := time.NewTicker(1 * time.Minute)
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for range ticker.C {
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
if now.Hour() != expeditionBriefingHour || now.Minute() != 0 {
|
if now.Hour() != expeditionBriefingHour || now.Minute() != 0 {
|
||||||
continue
|
continue
|
||||||
@@ -46,12 +50,17 @@ func (p *AdventurePlugin) expeditionBriefingTicker() {
|
|||||||
p.fireExpeditionBriefings(now)
|
p.fireExpeditionBriefings(now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// expeditionRecapTicker — 21:00 UTC daily recap.
|
// expeditionRecapTicker — 21:00 UTC daily recap.
|
||||||
func (p *AdventurePlugin) expeditionRecapTicker() {
|
func (p *AdventurePlugin) expeditionRecapTicker() {
|
||||||
ticker := time.NewTicker(1 * time.Minute)
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for range ticker.C {
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
if now.Hour() != expeditionRecapHour || now.Minute() != 0 {
|
if now.Hour() != expeditionRecapHour || now.Minute() != 0 {
|
||||||
continue
|
continue
|
||||||
@@ -59,6 +68,7 @@ func (p *AdventurePlugin) expeditionRecapTicker() {
|
|||||||
p.fireExpeditionRecaps(now)
|
p.fireExpeditionRecaps(now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// fireExpeditionBriefings finds every active expedition without a briefing
|
// fireExpeditionBriefings finds every active expedition without a briefing
|
||||||
// for today's UTC date and fires one per. Public-on-package for testing.
|
// for today's UTC date and fires one per. Public-on-package for testing.
|
||||||
@@ -149,7 +159,29 @@ func scanExpeditionRows(rows *sql.Rows) ([]*Expedition, error) {
|
|||||||
|
|
||||||
// deliverBriefing rolls a day forward, applies supply burn, posts the
|
// deliverBriefing rolls a day forward, applies supply burn, posts the
|
||||||
// morning briefing DM, appends a log entry, and stamps last_briefing_at.
|
// 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 {
|
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
|
// E3: zone-specific temporal events fire BEFORE applyDailyBurn and
|
||||||
// can override the entire burn calculation with a fixed multiplier
|
// can override the entire burn calculation with a fixed multiplier
|
||||||
// (Sunken Temple tidal 2.0×, Feywild half-day 0.5×, etc.).
|
// (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).
|
// has advanced, so e.CurrentDay reflects the new day).
|
||||||
temporalLines := applyZoneTemporalPostRollover(e)
|
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
|
// apply the §10.2 coin tax. The temporal layer flips the row to
|
||||||
// 'abandoned'; the cycle holds the euro handle to do the debit.
|
// 'abandoned'; the cycle holds the euro handle to do the debit.
|
||||||
if e.Status == ExpeditionStatusAbandoned && p.euro != nil {
|
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 {
|
fmt.Sprintf("morning briefing — %.1f SU consumed overnight", burn), line); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err := db.Get().Exec(`
|
return nil
|
||||||
UPDATE dnd_expedition
|
|
||||||
SET last_briefing_at = ?,
|
|
||||||
last_activity = ?
|
|
||||||
WHERE expedition_id = ?`, now, now, e.ID)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
func (p *AdventurePlugin) deliverRecap(e *Expedition, now time.Time) error {
|
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
|
// E2b: night phase wandering check fires before the recap so its
|
||||||
// outcome is part of today's log when the recap renders.
|
// outcome is part of today's log when the recap renders.
|
||||||
var night *NightCheck
|
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 {
|
fmt.Sprintf("evening recap — %d log entries today", len(dayEntries)), line); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = db.Get().Exec(`
|
return nil
|
||||||
UPDATE dnd_expedition
|
|
||||||
SET last_recap_at = ?,
|
|
||||||
last_activity = ?
|
|
||||||
WHERE expedition_id = ?`, now, now, e.ID)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickMorningBriefing returns a flavor line based on day-band: Day 1, 3, 7,
|
// pickMorningBriefing returns a flavor line based on day-band: Day 1, 3, 7,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gogobee/internal/flavor"
|
"gogobee/internal/flavor"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Phase 12 E2a — Threat Clock state machine.
|
// Phase 12 E2a — Threat Clock state machine.
|
||||||
@@ -134,9 +135,19 @@ func applyDailyThreatDrift(e *Expedition) (int, string, error) {
|
|||||||
if newBand != prevBand && newBand > prevBand {
|
if newBand != prevBand && newBand > prevBand {
|
||||||
_ = appendThreatTransitionLog(e, newBand)
|
_ = 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 {
|
if prevLevel < 70 && e.ThreatLevel >= 70 {
|
||||||
|
warned, _ := e.RegionState["siege_warning_fired"].(bool)
|
||||||
|
if !warned {
|
||||||
_ = appendApproachingSiegeLog(e)
|
_ = appendApproachingSiegeLog(e)
|
||||||
|
if e.RegionState == nil {
|
||||||
|
e.RegionState = map[string]any{}
|
||||||
|
}
|
||||||
|
e.RegionState["siege_warning_fired"] = true
|
||||||
|
_ = persistRegionState(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return delta, reason, nil
|
return delta, reason, nil
|
||||||
}
|
}
|
||||||
@@ -246,3 +257,18 @@ func threatBandEffectsBlock(info ThreatBandInfo) string {
|
|||||||
func applyBossDefeatThreat(expID string) error {
|
func applyBossDefeatThreat(expID string) error {
|
||||||
return applyThreatDelta(expID, -20, "boss defeated")
|
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))
|
t.Logf("loaded %d real adventure characters", len(chars))
|
||||||
|
|
||||||
// 2. For each, verify they have NO dnd_character row yet, then auto-migrate
|
// 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 {
|
for i := range chars {
|
||||||
char := &chars[i]
|
char := &chars[i]
|
||||||
uid := char.UserID
|
uid := char.UserID
|
||||||
@@ -57,7 +59,10 @@ func TestProdDB_DnDLayer(t *testing.T) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if existing != nil {
|
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
|
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",
|
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,
|
uid, dnd.Level, dnd.Race, dnd.Class, dnd.HPMax, dnd.ArmorClass,
|
||||||
dnd.STR, dnd.DEX, dnd.CON, dnd.INT, dnd.WIS, dnd.CHA)
|
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.
|
// 3. Re-load each auto-migrated char — round-trip integrity.
|
||||||
for i := range chars {
|
for i := range chars {
|
||||||
uid := chars[i].UserID
|
uid := chars[i].UserID
|
||||||
|
if !migrated[uid] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
dnd, err := LoadDnDCharacter(uid)
|
dnd, err := LoadDnDCharacter(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("user=%s: re-load failed: %v", uid, err)
|
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
|
// 4. ensureDnDCharacterForCombat is idempotent — second call returns the
|
||||||
// same row, doesn't create a duplicate.
|
// same row, doesn't create a duplicate. Pick the first migrated char.
|
||||||
uid := chars[0].UserID
|
var idemIdx int
|
||||||
|
for i := range chars {
|
||||||
|
if migrated[chars[i].UserID] {
|
||||||
|
idemIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uid := chars[idemIdx].UserID
|
||||||
first, _ := LoadDnDCharacter(uid)
|
first, _ := LoadDnDCharacter(uid)
|
||||||
second, freshAgain, err := ensureDnDCharacterForCombat(uid, &chars[0])
|
second, freshAgain, err := ensureDnDCharacterForCombat(uid, &chars[idemIdx])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("idempotent ensure failed: %v", err)
|
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 {
|
if err := wipeSpellsForUser(ctx.Sender); err != nil {
|
||||||
slog.Error("dnd: respec spell wipe", "user", ctx.Sender, "err", err)
|
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
|
// Debit last. If this fails (rare race — euros spent elsewhere between
|
||||||
// the pre-check and now), the player got a free respec. Strictly better
|
// the pre-check and now), the player got a free respec. Strictly better
|
||||||
// than the alternative of euros-lost-with-wipe-not-applied.
|
// than the alternative of euros-lost-with-wipe-not-applied.
|
||||||
@@ -453,6 +462,14 @@ func loadOrInitDraft(userID id.UserID) (*DnDCharacter, error) {
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("clear auto-migrated row: %w", err)
|
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
|
// fall through to fresh draft
|
||||||
} else if !c.PendingSetup {
|
} else if !c.PendingSetup {
|
||||||
return nil, fmt.Errorf("character already finalized — use !respec")
|
return nil, fmt.Errorf("character already finalized — use !respec")
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
|
import "math"
|
||||||
|
|
||||||
// Phase 10 SUB2a — subclass combat hooks.
|
// Phase 10 SUB2a — subclass combat hooks.
|
||||||
//
|
//
|
||||||
// applySubclassPassives layers subclass-driven flags onto CombatModifiers
|
// applySubclassPassives layers subclass-driven flags onto CombatModifiers
|
||||||
@@ -429,6 +431,46 @@ func init() {
|
|||||||
mods.HealItem += base + abilityModifier(c.CHA)
|
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
|
// Phase 10 SUB2c — Cleric Channel Divinity. All three Cleric subclasses
|
||||||
// share the "channel_divinity" resource pool (2/long-rest in our model;
|
// share the "channel_divinity" resource pool (2/long-rest in our model;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import "sort"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
// Phase 11 D1a — zone registry. Implements `gogobee_dungeon_zones.md` §5.
|
// Phase 11 D1a — zone registry. Implements `gogobee_dungeon_zones.md` §5.
|
||||||
//
|
//
|
||||||
@@ -127,6 +130,25 @@ func getZone(id ZoneID) (ZoneDefinition, bool) {
|
|||||||
return z, ok
|
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.
|
// zonesForLevel returns all zones a player at dndLevel may enter.
|
||||||
// Per design doc §2: a player cannot enter a zone more than 2 tiers
|
// Per design doc §2: a player cannot enter a zone more than 2 tiers
|
||||||
// above their current level. We approximate "current tier" as
|
// 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))
|
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
|
||||||
}
|
}
|
||||||
if active, _ := getActiveZoneRun(ctx.Sender); active != nil {
|
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`._",
|
b.WriteString(fmt.Sprintf("\n_⚠ Active run: **%s**, room %d/%d. Use `!zone status` or `!zone abandon`._",
|
||||||
zone.Display, active.CurrentRoom+1, active.TotalRooms))
|
zone.Display, active.CurrentRoom+1, active.TotalRooms))
|
||||||
}
|
}
|
||||||
@@ -180,7 +180,7 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest
|
|||||||
switch err {
|
switch err {
|
||||||
case ErrRunAlreadyActive:
|
case ErrRunAlreadyActive:
|
||||||
active, _ := getActiveZoneRun(ctx.Sender)
|
active, _ := getActiveZoneRun(ctx.Sender)
|
||||||
zone, _ := getZone(active.ZoneID)
|
zone := zoneOrFallback(active.ZoneID)
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
"You're already in **%s** (room %d/%d). Finish it or `!zone abandon` first.",
|
"You're already in **%s** (room %d/%d). Finish it or `!zone abandon` first.",
|
||||||
zone.Display, active.CurrentRoom+1, active.TotalRooms))
|
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())
|
return p.SendDM(ctx.Sender, "Couldn't start zone run: "+err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
zone, _ := getZone(run.ZoneID)
|
zone := zoneOrFallback(run.ZoneID)
|
||||||
var b strings.Builder
|
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(fmt.Sprintf("🗝 **Entering %s** _(T%d, %d rooms)_\n\n", zone.Display, int(zone.Tier), run.TotalRooms))
|
||||||
b.WriteString("_" + zone.Hook + "_\n\n")
|
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>`.")
|
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone list` then `!zone enter <id>`.")
|
||||||
}
|
}
|
||||||
_ = applyMoodDecayIfStale(run)
|
_ = applyMoodDecayIfStale(run)
|
||||||
zone, _ := getZone(run.ZoneID)
|
zone := zoneOrFallback(run.ZoneID)
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(fmt.Sprintf("**%s** — room %d/%d (%s)\n",
|
b.WriteString(fmt.Sprintf("**%s** — room %d/%d (%s)\n",
|
||||||
zone.Display, run.CurrentRoom+1, run.TotalRooms, prettyRoomType(run.CurrentRoomType())))
|
zone.Display, run.CurrentRoom+1, run.TotalRooms, prettyRoomType(run.CurrentRoomType())))
|
||||||
@@ -279,7 +279,7 @@ func (p *AdventurePlugin) zoneCmdMap(ctx MessageContext) error {
|
|||||||
if run == nil {
|
if run == nil {
|
||||||
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
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{}
|
cleared := map[int]bool{}
|
||||||
for _, r := range run.RoomsCleared {
|
for _, r := range run.RoomsCleared {
|
||||||
cleared[r] = true
|
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>`.")
|
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
||||||
}
|
}
|
||||||
_ = applyMoodDecayIfStale(run)
|
_ = applyMoodDecayIfStale(run)
|
||||||
zone, _ := getZone(run.ZoneID)
|
zone := zoneOrFallback(run.ZoneID)
|
||||||
prev := run.CurrentRoomType()
|
prev := run.CurrentRoomType()
|
||||||
prevIdx := run.CurrentRoom
|
prevIdx := run.CurrentRoom
|
||||||
|
|
||||||
@@ -479,7 +479,7 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, err
|
return "", false, err
|
||||||
}
|
}
|
||||||
scanMoodEventsFromCombat(run.RunID, result)
|
nat20s, nat1s := scanMoodEventsFromCombat(run.RunID, result)
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
if elite {
|
if elite {
|
||||||
@@ -492,6 +492,20 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z
|
|||||||
b.WriteString(line)
|
b.WriteString(line)
|
||||||
b.WriteString("\n")
|
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 {
|
if elite {
|
||||||
b.WriteString(fmt.Sprintf("⚔️ **Elite encounter — %s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC))
|
b.WriteString(fmt.Sprintf("⚔️ **Elite encounter — %s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC))
|
||||||
} else {
|
} 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))
|
b.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d).", monster.Name, result.PlayerStartHP, result.PlayerEndHP))
|
||||||
recordZoneKillForUser(userID, monster.ID)
|
recordZoneKillForUser(userID, monster.ID)
|
||||||
|
applyRoomCombatThreatForUser(userID, elite)
|
||||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" {
|
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" {
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(drop)
|
b.WriteString(drop)
|
||||||
@@ -531,10 +546,21 @@ func (p *AdventurePlugin) resolveBossRoom(userID id.UserID, run *DungeonRun, zon
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, err
|
return "", false, err
|
||||||
}
|
}
|
||||||
scanMoodEventsFromCombat(run.RunID, result)
|
nat20s, nat1s := scanMoodEventsFromCombat(run.RunID, result)
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(fmt.Sprintf("👑 **Boss — %s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC))
|
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 phaseTwoCrossedInEvents(result.Events, result.EnemyStartHP, zone.Boss.PhaseTwoAt) {
|
||||||
if line := bossPhaseTwoLine(zone.ID, run.RunID, run.CurrentRoom); line != "" {
|
if line := bossPhaseTwoLine(zone.ID, run.RunID, run.CurrentRoom); line != "" {
|
||||||
b.WriteString(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))
|
b.WriteString(fmt.Sprintf("🏆 **%s** falls (HP %d→%d).", monster.Name, result.PlayerStartHP, result.PlayerEndHP))
|
||||||
recordZoneKillForUser(userID, monster.ID)
|
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 != "" {
|
if drop := p.dropZoneLoot(userID, zone.ID, monster, true); drop != "" {
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(drop)
|
b.WriteString(drop)
|
||||||
@@ -627,7 +658,7 @@ func (p *AdventurePlugin) zoneCmdLore(ctx MessageContext) error {
|
|||||||
if run == nil {
|
if run == nil {
|
||||||
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
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)
|
line := twinBeeLine(zone.ID, GMLore, run.RunID, run.CurrentRoom)
|
||||||
if line == "" {
|
if line == "" {
|
||||||
return p.SendDM(ctx.Sender, "TwinBee has nothing to say about this place.")
|
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 {
|
if run == nil {
|
||||||
return p.SendDM(ctx.Sender, "Run abandoned.")
|
return p.SendDM(ctx.Sender, "Run abandoned.")
|
||||||
}
|
}
|
||||||
zone, _ := getZone(run.ZoneID)
|
zone := zoneOrFallback(run.ZoneID)
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
"🚪 Abandoned **%s** at room %d/%d. No rewards.",
|
"🚪 Abandoned **%s** at room %d/%d. No rewards.",
|
||||||
zone.Display, run.CurrentRoom+1, run.TotalRooms))
|
zone.Display, run.CurrentRoom+1, run.TotalRooms))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package plugin
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"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
|
// Design doc §3.2: "Mood decays toward 50 at a rate of ±2 per hour
|
||||||
// (passive rebalancing)." Long-idle runs reset toward neutral.
|
// (passive rebalancing)." Long-idle runs reset toward neutral.
|
||||||
func passiveDecayMood(score int, lastTouched, now time.Time) int {
|
func passiveDecayMood(score int, lastTouched, now time.Time) int {
|
||||||
hours := int(now.Sub(lastTouched).Hours())
|
elapsedHours := now.Sub(lastTouched).Hours()
|
||||||
if hours <= 0 {
|
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)
|
return clampMood(score)
|
||||||
}
|
}
|
||||||
const ratePerHour = 2
|
|
||||||
drift := hours * ratePerHour
|
|
||||||
switch {
|
switch {
|
||||||
case score > 50:
|
case score > 50:
|
||||||
score -= drift
|
score -= drift
|
||||||
|
|||||||
@@ -202,7 +202,15 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
|
|||||||
return run, nil
|
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) {
|
func getActiveZoneRun(userID id.UserID) (*DungeonRun, error) {
|
||||||
row := db.Get().QueryRow(`
|
row := db.Get().QueryRow(`
|
||||||
SELECT run_id, user_id, zone_id, current_room, total_rooms,
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, nil
|
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.
|
// getZoneRun fetches by RunID regardless of completion state. Test/admin use.
|
||||||
|
|||||||
Reference in New Issue
Block a user