Files
gogobee/internal/plugin/adventure_events.go
prosolis 7878ce0999 Adventure: defer random-event roll to 1-3h after a player acts
Previous scheduler built one fixed roll-minute per player per UTC day in
the 10:00-16:00 window, regardless of when (or whether) the player
actually played. Players who acted late in the day might miss their roll
entirely; players who acted early still had to wait for an arbitrary
mid-day window.

New scheduler:
- Each ticker minute, scan for players who have acted today but don't
  yet have a scheduled roll. Assign each a one-shot roll-minute 60-180
  minutes in the future (capped to 23:50 UTC).
- At the assigned minute, fire the 0.5% trigger roll. Mark advEventRolled
  so it won't fire again that day.
- New day rebuilds both maps fresh.

Result: events trigger relative to the player's actual activity, not
clock time. Late-day actors get their chance; players who skip a day
don't get a roll.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:36:52 -07:00

364 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 60180 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 range 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 60180 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()
}