mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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>
369 lines
11 KiB
Go
369 lines
11 KiB
Go
package plugin
|
||
|
||
import (
|
||
"database/sql"
|
||
"fmt"
|
||
"log/slog"
|
||
"math/rand/v2"
|
||
"sync"
|
||
"time"
|
||
|
||
"gogobee/internal/db"
|
||
|
||
"maunium.net/go/mautrix/id"
|
||
)
|
||
|
||
// ── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
type advActiveEvent struct {
|
||
ID int64
|
||
UserID id.UserID
|
||
EventKey string
|
||
TriggeredAt time.Time
|
||
ExpiresAt time.Time
|
||
}
|
||
|
||
// ── In-memory schedule ───────────────────────────────────────────────────────
|
||
// When a player acts on a given day, the next ticker iteration assigns them
|
||
// a one-shot roll-minute 60–180 minutes in the future. At that minute, the
|
||
// 0.5% trigger roll fires. Each player rolls at most once per UTC day.
|
||
|
||
var (
|
||
advEventScheduleMu sync.Mutex
|
||
advEventSchedule map[string]int // userID -> minute-of-day for the deferred roll
|
||
advEventRolled map[string]bool // userID -> already rolled today
|
||
advEventScheduleDay string // "2006-01-02" the maps belong to
|
||
)
|
||
|
||
// ── Event Ticker ─────────────────────────────────────────────────────────────
|
||
|
||
func (p *AdventurePlugin) eventTicker() {
|
||
ticker := time.NewTicker(1 * time.Minute)
|
||
defer ticker.Stop()
|
||
|
||
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()
|
||
|
||
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)
|
||
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()
|
||
|
||
for _, uid := range toRoll {
|
||
p.tryTriggerEvent(uid)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
|
||
// Load character — must be alive and have acted today
|
||
char, err := loadAdvCharacter(userID)
|
||
if err != nil || char == nil || !char.Alive || !char.HasActedToday() {
|
||
return
|
||
}
|
||
|
||
// Already has an active event?
|
||
active, _ := loadAdvActiveEvent(userID)
|
||
if active != nil {
|
||
return
|
||
}
|
||
|
||
// 0.5% chance
|
||
if rand.Float64() >= 0.005 {
|
||
return
|
||
}
|
||
|
||
// Determine today's activity for filtering
|
||
activityType := advPlayerTodayActivity(userID)
|
||
|
||
// Pick an event
|
||
event := advPickRandomEvent(userID, activityType)
|
||
if event == nil {
|
||
return
|
||
}
|
||
|
||
// Insert into DB
|
||
now := time.Now().UTC()
|
||
expiresAt := now.Add(2 * time.Hour)
|
||
eventID, err := insertAdvEvent(userID, event.Key, expiresAt)
|
||
if err != nil {
|
||
slog.Error("adventure: events: failed to insert event", "user", userID, "err", err)
|
||
return
|
||
}
|
||
|
||
slog.Info("adventure: mid-day event triggered", "user", userID, "event", event.Key, "id", eventID)
|
||
|
||
// DM the player
|
||
triggerDM := advSubstituteFlavor(event.TriggerDM, map[string]string{
|
||
"{name}": char.DisplayName,
|
||
})
|
||
if err := p.SendDM(userID, triggerDM); err != nil {
|
||
slog.Error("adventure: events: failed to send trigger DM", "user", userID, "err", err)
|
||
}
|
||
|
||
// Post to game room
|
||
gr := gamesRoom()
|
||
if gr != "" {
|
||
roomLine := advSubstituteFlavor(advEventRoomTriggerWrapper, map[string]string{
|
||
"{trigger_room_line}": advSubstituteFlavor(event.TriggerRoomLine, map[string]string{"{name}": char.DisplayName}),
|
||
})
|
||
_ = p.SendMessage(id.RoomID(gr), roomLine)
|
||
}
|
||
}
|
||
|
||
// handleEventRespond processes `!adventure respond`.
|
||
func (p *AdventurePlugin) handleEventRespond(ctx MessageContext) error {
|
||
mu := p.advUserLock(ctx.Sender)
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
|
||
active, err := loadAdvActiveEvent(ctx.Sender)
|
||
if err != nil {
|
||
slog.Error("adventure: events: failed to load active event", "user", ctx.Sender, "err", err)
|
||
return p.SendDM(ctx.Sender, "Something went wrong checking for events. Try again in a moment.")
|
||
}
|
||
if active == nil {
|
||
return p.SendDM(ctx.Sender, "You don't have an active event to respond to.")
|
||
}
|
||
|
||
// Look up event definition
|
||
event := advFindEventByKey(active.EventKey)
|
||
if event == nil {
|
||
slog.Error("adventure: events: unknown event key in DB", "key", active.EventKey)
|
||
return p.SendDM(ctx.Sender, "Something went wrong — the event couldn't be found. This shouldn't happen.")
|
||
}
|
||
|
||
// Roll gold reward
|
||
gold := event.GoldMin
|
||
if event.GoldMax > event.GoldMin {
|
||
gold += rand.Int64N(event.GoldMax - event.GoldMin + 1)
|
||
}
|
||
|
||
// Credit gold (5% community tax)
|
||
net, _ := communityTax(ctx.Sender, float64(gold), 0.05)
|
||
p.euro.Credit(ctx.Sender, net, "adventure_event_"+event.Key)
|
||
|
||
// Apply XP if applicable
|
||
xpSkill := event.XPSkill
|
||
if xpSkill == "" && event.XP > 0 {
|
||
// For "any" events, apply XP to whatever they did today
|
||
activityType := advPlayerTodayActivity(ctx.Sender)
|
||
xpSkill = advXPSkill(AdvActivityType(activityType))
|
||
}
|
||
|
||
if event.XP > 0 && xpSkill != "" {
|
||
char, err := loadAdvCharacter(ctx.Sender)
|
||
if err == nil && char != nil {
|
||
switch xpSkill {
|
||
case "combat":
|
||
char.CombatXP += event.XP
|
||
case "mining":
|
||
char.MiningXP += event.XP
|
||
case "foraging":
|
||
char.ForagingXP += event.XP
|
||
}
|
||
checkAdvLevelUp(char, xpSkill)
|
||
_ = saveAdvCharacter(char)
|
||
}
|
||
}
|
||
|
||
// Mark responded in DB
|
||
markAdvEventResponded(active.ID, gold, event.XP)
|
||
|
||
// Load display name for substitutions
|
||
displayName := p.DisplayName(ctx.Sender)
|
||
|
||
// Send outcome DM
|
||
goldStr := fmt.Sprintf("%d", gold)
|
||
xpStr := fmt.Sprintf("%d", event.XP)
|
||
outcomeDM := advSubstituteFlavor(event.OutcomeDM, map[string]string{
|
||
"{gold}": goldStr,
|
||
"{xp}": xpStr,
|
||
"{name}": displayName,
|
||
})
|
||
_ = p.SendDM(ctx.Sender, outcomeDM)
|
||
|
||
// Post outcome to game room
|
||
gr := gamesRoom()
|
||
if gr != "" {
|
||
xpSuffix := ""
|
||
if event.XP > 0 {
|
||
xpSuffix = fmt.Sprintf(" · +%d XP", event.XP)
|
||
}
|
||
roomLine := advSubstituteFlavor(advEventRoomOutcomeWrapper, map[string]string{
|
||
"{outcome_room_line}": advSubstituteFlavor(event.OutcomeRoomLine, map[string]string{
|
||
"{name}": displayName,
|
||
"{gold}": goldStr,
|
||
}),
|
||
"{gold}": goldStr,
|
||
"{xp_suffix}": xpSuffix,
|
||
})
|
||
_ = p.SendMessage(id.RoomID(gr), roomLine)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// ── Event Selection ──────────────────────────────────────────────────────────
|
||
|
||
func advPickRandomEvent(userID id.UserID, activityType string) *AdvRandomEvent {
|
||
// Load recent event keys for dedup
|
||
recent, _ := loadAdvRecentEventKeys(userID, 10)
|
||
recentSet := make(map[string]bool, len(recent))
|
||
for _, k := range recent {
|
||
recentSet[k] = true
|
||
}
|
||
|
||
// Filter eligible events
|
||
var candidates []int
|
||
for i, e := range advRandomEvents {
|
||
if e.Activity != "any" && e.Activity != activityType {
|
||
continue
|
||
}
|
||
if recentSet[e.Key] {
|
||
continue
|
||
}
|
||
candidates = append(candidates, i)
|
||
}
|
||
|
||
if len(candidates) == 0 {
|
||
return nil
|
||
}
|
||
|
||
return &advRandomEvents[candidates[rand.IntN(len(candidates))]]
|
||
}
|
||
|
||
func advFindEventByKey(key string) *AdvRandomEvent {
|
||
for i := range advRandomEvents {
|
||
if advRandomEvents[i].Key == key {
|
||
return &advRandomEvents[i]
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// advPlayerTodayActivity returns the activity type string for what the player did today.
|
||
func advPlayerTodayActivity(userID id.UserID) string {
|
||
d := db.Get()
|
||
today := time.Now().UTC().Format("2006-01-02")
|
||
var actType string
|
||
err := d.QueryRow(`SELECT activity_type FROM adventure_activity_log
|
||
WHERE user_id = ? AND logged_at >= ? AND logged_at < DATE(?, '+1 day') LIMIT 1`, string(userID), today, today).Scan(&actType)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return actType
|
||
}
|
||
|
||
// ── DB Operations ────────────────────────────────────────────────────────────
|
||
|
||
func insertAdvEvent(userID id.UserID, eventKey string, expiresAt time.Time) (int64, error) {
|
||
d := db.Get()
|
||
res, err := d.Exec(`INSERT INTO adventure_events_log (user_id, event_key, expires_at)
|
||
VALUES (?, ?, ?)`, string(userID), eventKey, expiresAt)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return res.LastInsertId()
|
||
}
|
||
|
||
func loadAdvActiveEvent(userID id.UserID) (*advActiveEvent, error) {
|
||
d := db.Get()
|
||
var e advActiveEvent
|
||
err := d.QueryRow(`SELECT id, user_id, event_key, triggered_at, expires_at
|
||
FROM adventure_events_log
|
||
WHERE user_id = ? AND outcome = 'pending' AND expires_at > CURRENT_TIMESTAMP
|
||
ORDER BY triggered_at DESC LIMIT 1`, string(userID)).Scan(
|
||
&e.ID, &e.UserID, &e.EventKey, &e.TriggeredAt, &e.ExpiresAt)
|
||
if err == sql.ErrNoRows {
|
||
return nil, nil
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &e, nil
|
||
}
|
||
|
||
func markAdvEventResponded(eventID int64, goldAwarded int64, xpAwarded int) {
|
||
db.Exec("adventure: mark event responded",
|
||
`UPDATE adventure_events_log
|
||
SET outcome = 'responded', responded_at = CURRENT_TIMESTAMP,
|
||
gold_awarded = ?, xp_awarded = ?
|
||
WHERE id = ?`, goldAwarded, xpAwarded, eventID)
|
||
}
|
||
|
||
func expireAdvPendingEvents() {
|
||
db.Exec("adventure: expire pending events",
|
||
`UPDATE adventure_events_log SET outcome = 'expired'
|
||
WHERE outcome = 'pending' AND expires_at < CURRENT_TIMESTAMP`)
|
||
}
|
||
|
||
func loadAdvRecentEventKeys(userID id.UserID, limit int) ([]string, error) {
|
||
d := db.Get()
|
||
rows, err := d.Query(`SELECT event_key FROM adventure_events_log
|
||
WHERE user_id = ? ORDER BY triggered_at DESC LIMIT ?`, string(userID), limit)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var keys []string
|
||
for rows.Next() {
|
||
var k string
|
||
if err := rows.Scan(&k); err != nil {
|
||
return nil, err
|
||
}
|
||
keys = append(keys, k)
|
||
}
|
||
return keys, rows.Err()
|
||
}
|