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 } // ── Event anchors ──────────────────────────────────────────────────────────── // N1/A6 — events used to roll at 0.5%/player/day from a deferred ticker slot, // which put one sighting in front of a daily player roughly every 200 days. // They now roll at moments the player is demonstrably present and reading a // DM: the end-of-day digest, a sale at Thom's, and an arena cashout. Each // player still sees at most one event per UTC day. // // The per-anchor chances below put a player who hits all three at ~1 event // per week; see TestAnchoredEventWeeklyRate. // // K — the three social/economic anchors miss the pure grind loop: a player who // only mines/forages/fishes (now automatic, done by walking) and clears // single-day dungeons, but never sells, never enters the arena, and never runs // a multi-day expedition to a Night camp, would roll for zero events. The // fourth anchor, ZoneClear, fires on a foreground single-day zone clear — the // climax DM that player *is* reading — so they get the same rough cadence. // Autopilot walks and mid-zone region clears deliberately do not fire it (see // zoneCmdAdvance / the full-clear branch of advanceOnceWithOpts). The per-day // slot still caps everyone at one event, so a player who happens to hit two // anchors in a day can't double up. This rate is the tuning knob for item K — // bump it if telemetry shows the grind-loop player still sees too few. const ( advEventChanceDigest = 0.08 advEventChanceSell = 0.05 advEventChanceArena = 0.05 advEventChanceZoneClear = 0.08 ) var ( advEventFiredMu sync.Mutex advEventFired map[string]bool // userID -> an event already fired today advEventFiredDay string // "2006-01-02" the map belongs to ) // claimDailyEventSlot reserves today's single event slot for `userID`. // Returns false when the player already had one fire today. func claimDailyEventSlot(userID id.UserID, day string) bool { advEventFiredMu.Lock() defer advEventFiredMu.Unlock() if advEventFiredDay != day { advEventFired = make(map[string]bool) advEventFiredDay = day } if advEventFired[string(userID)] { return false } advEventFired[string(userID)] = true return true } // releaseDailyEventSlot hands the slot back when the trigger bailed before // anything reached the player, so a later anchor the same day can still fire. func releaseDailyEventSlot(userID id.UserID) { advEventFiredMu.Lock() defer advEventFiredMu.Unlock() delete(advEventFired, string(userID)) } // maybeFireAnchoredEvent rolls `chance` at one of the A6 anchor moments and, // on a hit, triggers the player's one mid-day event for today. func (p *AdventurePlugin) maybeFireAnchoredEvent(userID id.UserID, chance float64) { if rand.Float64() >= chance { return } if !claimDailyEventSlot(userID, time.Now().UTC().Format("2006-01-02")) { return } if !p.tryTriggerEvent(userID) { releaseDailyEventSlot(userID) } } // ── Event Ticker ───────────────────────────────────────────────────────────── func (p *AdventurePlugin) eventTicker() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for { select { case <-p.stopCh: return case <-ticker.C: // Expire stale pending events every tick expireAdvPendingEvents() // Auto-play any combat sessions past their 1h timeout. p.reapExpiredCombatSessions() // Latch away party members onto autopilot so one absent player // can't hold a co-op fight hostage (N3/P5). Solo fights are never // listed — they answer to the reaper above. p.nudgeStalledPartyTurns() // Reclaim invites nobody answered (N3/P6b). Every read already // filters on the TTL; this just stops the rows accumulating. purgeExpiredInvites() // World boss (N6/C3): auto-spawn the monthly Siege on the 1st and // resolve one whose 72h window has lapsed. Own dedup inside. p.worldBossTick() // Duels (N6/C2): refund and clear any challenge whose 24h accept // window lapsed. p.expireDuelChallenges() } } } // tryTriggerEvent fires the player's mid-day event. Reports whether an event // actually reached them — a false return means the caller's daily slot was // never spent. The trigger roll itself lives in maybeFireAnchoredEvent. func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) bool { // Load character — must be alive. char, err := loadAdvCharacter(userID) if err != nil || char == nil || !char.Alive { return false } // Already has an active event? active, _ := loadAdvActiveEvent(userID) if active != nil { return false } // Mid-fight: a turn-based session locks the run. Don't drop a random // overworld event into a live fight — the player can't act on it without // finishing the fight first, and the trigger DM talks over the combat feed. if hasActiveCombatSession(userID) { return false } // Determine today's activity for filtering activityType := advPlayerTodayActivity(userID) // Pick an event event := advPickRandomEvent(userID, activityType) if event == nil { return false } // 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 false } slog.Info("adventure: mid-day event triggered", "user", userID, "event", event.Key, "id", eventID) displayName, _ := loadDisplayName(userID) // DM the player triggerDM := advSubstituteFlavor(event.TriggerDM, map[string]string{ "{name}": 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}": displayName}), }) _ = p.SendMessage(id.RoomID(gr), roomLine) } return true } // 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) _ = upsertPlayerMetaSkillState(ctx.Sender, skillStateFromAdvChar(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() }