Files
gogobee/internal/plugin/adventure_events.go
prosolis 6be7744e81 Review follow-up K: a fourth event anchor for the grind loop
The three A6 presence anchors (expedition Night digest, !sell, arena
cashout) miss a whole playstyle: 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 mid-day events.

Anchor a fourth roll on a foreground single-day zone clear — the climax
DM that player is reading, and one they cannot spam (a clear costs a
full walk). advanceResult.zoneCleared is set only in the full-clear
branch (a mid-zone region clear continues the run and would fire per
region), and the roll lives in zoneCmdAdvance, the foreground path only:
autopilot goes through runAutopilotWalk and party members are refused
earlier by isPartyMember. The per-day slot guard still caps everyone at
one event/day, so the three prior anchors are unmoved.

advEventChanceZoneClear = 0.08 puts a zone-clear-only player at ~1
event/week, matching the fully-engaged player; it is the tuning knob.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 09:12:34 -07:00

398 lines
12 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
}
// ── 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()
}
}
}
// 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()
}