Add market plugin, arena, holidays, UNO stacking fix, and multiple UX improvements

- Market plugin (#49): daily index snapshots (Yahoo Finance + Finnhub fallback),
  Ollama-generated summaries, !howsthemarket, !marketstatus, !marketreport commands,
  exchange hours with DST support, 30-min room cooldown
- Adventure arena: 5-tier combat gauntlet with 20 monsters, risk-reward cashout,
  death lockout changed from 24h to midnight UTC across all code and flavor text
- Adventure holidays: double daily actions on holidays
- Adventure DM fix: 15-minute response window prevents bare numbers from triggering
  adventure during UNO games
- Adventure scheduler: jitter between morning DMs to avoid Matrix rate limits
- Adventure revive: wire up adv_revived achievement on admin revive
- Column migration system for existing databases (holiday_action_taken)
- Fix italic markdown rendering after newlines
- Fix holdem DMs broken by space groups including DM rooms
- UNO No Mercy: remove stacking escalation rule (any draw card stacks on any other)
- UNO multiplayer: add turn announcements after stack absorption and turn passes,
  jitter between rapid-fire messages with safe mutex handling
- Birthday: add €1,000 gift and 10 XP + €100 community celebration bonus
- Market: guard against division by zero, nil pointer, and timezone errors
- README updates: plugin count 48→49, all new commands, feature flags, architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-27 21:26:19 -07:00
parent 81e6cecad9
commit 0d3485c7c2
22 changed files with 4327 additions and 116 deletions

View File

@@ -3,6 +3,7 @@ package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"time"
"gogobee/internal/db"
@@ -41,10 +42,24 @@ func (p *AdventurePlugin) sendMorningDMs() {
}
now := time.Now().UTC()
isHol, holName := isHolidayToday()
if isHol {
slog.Info("adventure: holiday detected for morning DMs", "holiday", holName)
}
for _, char := range chars {
// Shuffle to avoid always hitting the same users first if early sends
// get rate-limited and later ones don't go out.
rand.Shuffle(len(chars), func(i, j int) { chars[i], chars[j] = chars[j], chars[i] })
for i, char := range chars {
char := char
// Jitter between DMs to avoid Matrix rate limits.
// Skip delay before the first one.
if i > 0 {
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
}
// Check if dead and ready to respawn
if !char.Alive && char.DeadUntil != nil && now.After(*char.DeadUntil) {
// Revive
@@ -88,7 +103,12 @@ func (p *AdventurePlugin) sendMorningDMs() {
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
balance := p.euro.GetBalance(char.UserID)
text := renderAdvMorningDM(&char, equip, balance, bonuses)
holidayLabel := ""
if isHol {
holidayLabel = holName
}
text := renderAdvMorningDM(&char, equip, balance, bonuses, holidayLabel)
p.advMarkMenuSent(char.UserID)
if err := p.SendDM(char.UserID, text); err != nil {
slog.Error("adventure: failed to send morning DM", "user", char.UserID, "err", err)
continue
@@ -141,10 +161,26 @@ func (p *AdventurePlugin) postDailySummary() {
}
todayLogs, _ := loadAdvTodayLogs()
logMap := make(map[id.UserID]*AdvDayLog)
// Group logs per user — holiday days may produce 2 entries per user
logsPerUser := make(map[id.UserID][]*AdvDayLog)
for i := range todayLogs {
logMap[todayLogs[i].UserID] = &todayLogs[i]
uid := todayLogs[i].UserID
logsPerUser[uid] = append(logsPerUser[uid], &todayLogs[i])
}
// logMap picks the last (most recent) log entry for summary display
// lootSums aggregates loot across all actions (relevant on holiday double-action days)
logMap := make(map[id.UserID]*AdvDayLog)
lootSums := make(map[id.UserID]int64)
for uid, logs := range logsPerUser {
logMap[uid] = logs[len(logs)-1]
var total int64
for _, l := range logs {
total += l.LootValue
}
lootSums[uid] = total
}
isHol, holName := isHolidayToday()
// Build player summaries
var players []AdvPlayerDaySummary
@@ -156,6 +192,11 @@ func (p *AdventurePlugin) postDailySummary() {
ForagingSkill: c.ForagingSkill,
}
// Holiday action count from log entries
if isHol {
ps.HolidayActions = len(logsPerUser[c.UserID])
}
if !c.Alive {
ps.IsDead = true
if c.DeadUntil != nil {
@@ -166,8 +207,8 @@ func (p *AdventurePlugin) postDailySummary() {
ps.Activity = log.ActivityType
ps.Location = log.Location
ps.Outcome = log.Outcome
ps.LootValue = log.LootValue
ps.SummaryLine = advSummaryOneLiner(c.UserID, AdvActivityType(log.ActivityType), AdvOutcomeType(log.Outcome), log.LootValue, log.Location)
ps.LootValue = lootSums[c.UserID] // aggregate across all actions
ps.SummaryLine = advSummaryOneLiner(c.UserID, AdvActivityType(log.ActivityType), AdvOutcomeType(log.Outcome), lootSums[c.UserID], log.Location)
}
players = append(players, ps)
continue
@@ -187,8 +228,8 @@ func (p *AdventurePlugin) postDailySummary() {
ps.Activity = log.ActivityType
ps.Location = log.Location
ps.Outcome = log.Outcome
ps.LootValue = log.LootValue
ps.SummaryLine = advSummaryOneLiner(c.UserID, AdvActivityType(log.ActivityType), AdvOutcomeType(log.Outcome), log.LootValue, log.Location)
ps.LootValue = lootSums[c.UserID] // aggregate across all actions
ps.SummaryLine = advSummaryOneLiner(c.UserID, AdvActivityType(log.ActivityType), AdvOutcomeType(log.Outcome), lootSums[c.UserID], log.Location)
}
players = append(players, ps)
@@ -207,7 +248,11 @@ func (p *AdventurePlugin) postDailySummary() {
}
date := time.Now().UTC().Format("2006-01-02")
summary := renderAdvDailySummary(date, tbResult, tbRewards, players)
summaryHolName := ""
if isHol {
summaryHolName = holName
}
summary := renderAdvDailySummary(date, tbResult, tbRewards, players, summaryHolName)
if err := p.SendMessage(id.RoomID(gr), summary); err != nil {
slog.Error("adventure: failed to post daily summary", "err", err)
@@ -250,12 +295,19 @@ func (p *AdventurePlugin) midnightReset() error {
today := time.Now().UTC().Format("2006-01-02")
dmsSent := 0
for _, char := range chars {
if !char.Alive {
continue
}
if !char.ActionTakenToday {
// Jitter between DMs to avoid Matrix rate limits
if dmsSent > 0 {
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
}
dmsSent++
// Idle shame DM
text := renderAdvIdleShameDM(&char)
if err := p.SendDM(char.UserID, text); err != nil {