Add !adv alias, hospital insurance billing, wordle/UNO earnings tracking, streak death protection

- Add !adv shorthand for !adventure commands and DM replies
- Hospital bill now shows insurance deduction and amount due
- Refactor hospital nudge from per-goroutine sleep to shared ticker
- Dead players' streaks are frozen; grace period on revival day
- Track wordle earnings in wordle_stats, show in !stats and superstats
- Show UNO net earnings and trivia accuracy % in superstats
- Disable sentiment/profanity emoji reactions (classification still logs)
- Add URL_PREVIEW_IGNORE_USERS env var to skip bot users
- Add DISABLE_WOTD_POST env var, remove unused wotd.Prefetch()
- README updates for all of the above

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-08 00:24:51 -07:00
parent ec4574f469
commit 26d2846481
11 changed files with 185 additions and 108 deletions

View File

@@ -28,6 +28,7 @@ type AdventurePlugin struct {
arenaDeadlines sync.Map // userID string -> time.Time (auto-cashout deadline)
arenaPending sync.Map // userID string -> int (pending tier number awaiting confirmation)
shopSessions sync.Map // userID string -> *advShopSession
hospitalNudges sync.Map // userID string -> time.Time (when to send nudge)
morningHour int
summaryHour int
}
@@ -120,6 +121,7 @@ func (p *AdventurePlugin) Init() error {
go p.arenaAutoCashoutTicker()
go p.rivalChallengeTicker()
go p.robbieTicker()
go p.hospitalNudgeTicker()
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
p.arenaCleanupStaleRuns()
@@ -171,7 +173,7 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
}
// 3. Command dispatch
if !p.IsCommand(ctx.Body, "adventure") {
if !p.IsCommand(ctx.Body, "adventure") && !p.IsCommand(ctx.Body, "adv") {
return nil
}
@@ -180,6 +182,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "adventure"))
if args == "" && p.IsCommand(ctx.Body, "adv") {
args = strings.TrimSpace(p.GetArgs(ctx.Body, "adv"))
}
lower := strings.ToLower(args)
switch {
@@ -491,12 +496,13 @@ func (p *AdventurePlugin) handleDMReply(ctx MessageContext) error {
body := strings.TrimSpace(ctx.Body)
// Skip if it looks like a command for another plugin
if strings.HasPrefix(body, "!") && !strings.HasPrefix(strings.ToLower(body), "!adventure") {
lower := strings.ToLower(body)
if strings.HasPrefix(body, "!") && !strings.HasPrefix(lower, "!adventure") && !strings.HasPrefix(lower, "!adv") {
return nil
}
// Strip !adventure prefix if present — dispatch directly to avoid recursion
if strings.HasPrefix(strings.ToLower(body), "!adventure") {
// Strip !adventure / !adv prefix if present — dispatch directly to avoid recursion
if strings.HasPrefix(lower, "!adventure") || strings.HasPrefix(lower, "!adv") {
return p.dispatchCommand(ctx)
}

View File

@@ -114,7 +114,7 @@ var hospitalFrequentCustomer = hospitalBillItem{
}
// generateItemizedBill builds a procedural hospital bill that sums to the given total.
func generateItemizedBill(total int64, hospitalVisits int, isHoliday bool) string {
func generateItemizedBill(total int64, afterInsurance int64, hospitalVisits int, isHoliday bool) string {
// Shuffle and pick 8-12 items from the pool
pool := make([]hospitalBillItem, len(hospitalBillPool))
copy(pool, hospitalBillPool)
@@ -218,6 +218,27 @@ func generateItemizedBill(total int64, hospitalVisits int, isHoliday bool) strin
sb.WriteString(strings.Repeat(" ", padding))
sb.WriteString(formatBillAmount(total))
sb.WriteByte('\n')
insLabel := "Guild Adventurer's Insurance"
insPadding := maxName - len(insLabel) + 2
if insPadding < 2 {
insPadding = 2
}
sb.WriteString(insLabel)
sb.WriteString(strings.Repeat(" ", insPadding))
sb.WriteString("-" + formatBillAmount(total-afterInsurance))
sb.WriteByte('\n')
sb.WriteString("─────────────────────────────────\n")
oweLabel := "AMOUNT DUE"
owePadding := maxName - len(oweLabel) + 2
if owePadding < 2 {
owePadding = 2
}
sb.WriteString(oweLabel)
sb.WriteString(strings.Repeat(" ", owePadding))
sb.WriteString(formatBillAmount(afterInsurance))
sb.WriteByte('\n')
sb.WriteString("```")
return sb.String()

View File

@@ -75,8 +75,8 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
sb.WriteString(admission)
sb.WriteString("\n\n")
// Act 2 — The Bill (before insurance)
sb.WriteString(generateItemizedBill(beforeInsurance, char.HospitalVisits, isHol))
// Act 2 — The Bill
sb.WriteString(generateItemizedBill(beforeInsurance, afterInsurance, char.HospitalVisits, isHol))
sb.WriteString("\n\n")
delivery, _ := advPickFlavor(nurseJoyBillDelivery, ctx.Sender, "hospital_delivery")
@@ -210,7 +210,8 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
// ── Hospital Ad (sent after death) ─────────────────────────────────────────
func (p *AdventurePlugin) sendHospitalAd(userID id.UserID, char *AdventureCharacter) {
cost := int64(char.CombatLevel) * 25_000
beforeInsurance := int64(char.CombatLevel) * 125_000
afterInsurance := int64(char.CombatLevel) * 25_000
respawnTime := "unknown"
if char.DeadUntil != nil {
respawnTime = char.DeadUntil.Format("15:04")
@@ -220,40 +221,57 @@ func (p *AdventurePlugin) sendHospitalAd(userID id.UserID, char *AdventureCharac
"🏥 **St. Guildmore's Memorial Hospital**\n\n"+
"Nurse Joy has been notified. A bed is being prepared.\n\n"+
"Type `!hospital` to check in for same-day revival.\n"+
"Cost estimate: €%d (after Guild insurance)\n\n"+
"Estimated bill: €%d (Guild insurance covers €%d → you pay €%d)\n\n"+
"Or rest up — natural respawn at %s UTC.",
cost, respawnTime)
beforeInsurance, beforeInsurance-afterInsurance, afterInsurance, respawnTime)
go func() {
time.Sleep(3 * time.Second)
time.AfterFunc(10*time.Second, func() {
if err := p.SendDM(userID, text); err != nil {
slog.Error("hospital: failed to send hospital ad", "user", userID, "err", err)
return
}
p.scheduleHospitalNudge(userID)
}()
// Schedule nudge for 2 hours from now — checked by hospitalNudgeTicker
p.hospitalNudges.Store(string(userID), time.Now().UTC().Add(2*time.Hour))
})
}
// ── Hospital Nudge (2-hour follow-up) ──────────────────────────────────────
// ── Hospital Nudge Ticker ──────────────────────────────────────────────────
// Runs every minute, checks for nudges that are due. No long-lived goroutines.
func (p *AdventurePlugin) scheduleHospitalNudge(userID id.UserID) {
go func() {
time.Sleep(2 * time.Hour)
func (p *AdventurePlugin) hospitalNudgeTicker() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
char, err := loadAdvCharacter(userID)
if err != nil || char.Alive {
return // already revived or error
}
// Don't nudge if already in hospital flow
if val, ok := p.pending.Load(string(userID)); ok {
if pi, ok := val.(*advPendingInteraction); ok && pi.Type == "hospital_pay" {
return
for range ticker.C {
now := time.Now().UTC()
p.hospitalNudges.Range(func(key, val any) bool {
uid := key.(string)
nudgeAt := val.(time.Time)
if now.Before(nudgeAt) {
return true
}
}
text, _ := advPickFlavor(nurseJoyNudge, userID, "hospital_nudge")
if err := p.SendDM(userID, text); err != nil {
slog.Error("hospital: failed to send nudge", "user", userID, "err", err)
}
}()
// Due — remove regardless of outcome
p.hospitalNudges.Delete(uid)
userID := id.UserID(uid)
char, err := loadAdvCharacter(userID)
if err != nil || char.Alive {
return true
}
// Don't nudge if already in hospital flow
if v, ok := p.pending.Load(uid); ok {
if pi, ok := v.(*advPendingInteraction); ok && pi.Type == "hospital_pay" {
return true
}
}
text, _ := advPickFlavor(nurseJoyNudge, userID, "hospital_nudge")
if err := p.SendDM(userID, text); err != nil {
slog.Error("hospital: failed to send nudge", "user", userID, "err", err)
}
return true
})
}
}

View File

@@ -308,11 +308,23 @@ func (p *AdventurePlugin) midnightReset() error {
dmsSent := 0
for _, char := range chars {
// Dead players freeze their streak — death is involuntary, don't punish it.
if !char.Alive {
continue
}
if !char.ActionTakenToday {
// If the player was recently dead (revived today but couldn't act
// in time), preserve their streak instead of resetting it.
// We detect this by checking if LastActionDate was yesterday —
// they were active, then died, and couldn't act on revival day.
recentlyDead := char.LastActionDate == today ||
char.LastActionDate == time.Now().UTC().Add(-24*time.Hour).Format("2006-01-02")
if recentlyDead {
// Grace period — don't shame, don't reset
continue
}
// Jitter between DMs to avoid Matrix rate limits
if dmsSent > 0 {
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)

View File

@@ -444,40 +444,13 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
)
}
// React with emojis based on sentiment
sentimentEmojis := map[string]string{
"positive": "\U0001f44d", // 👍
"negative": "\U0001f44e", // 👎
"excited": "\U0001f525", // 🔥
"sarcastic": "\U0001f928", // 🤨
"frustrated": "\U0001f62e\u200d\U0001f4a8", // 😮‍💨
"curious": "\U0001f9d0", // 🧐
"grateful": "\U0001f49c", // 💜
"humorous": "\U0001f602", // 😂
"supportive": "\U0001f917", // 🤗
}
if emoji, ok := sentimentEmojis[result.Sentiment]; ok && result.Sentiment != "neutral" {
// Only react to strong sentiments (|score| > 0.5)
score := result.SentimentScore
if score > 0.5 || score < -0.5 {
_ = p.SendReact(item.RoomID, item.EventID, emoji)
}
}
if result.Profanity {
switch result.ProfanitySeverity {
case 3:
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f92c") // 🤬
case 2:
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f632") // 😲
default:
_ = p.SendReact(item.RoomID, item.EventID, "\U0001fae3") // 🫣
}
}
// Sentiment/profanity emoji reactions disabled — logging continues above.
// Insult-response reactions (bot reacts when insulted) remain active above.
if result.WOTDUsed {
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f4d6") // 📖 open book
}
if result.GratitudeTarget != "" {
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f49c") // purple heart
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f49c") // 💜 purple heart
}
// Grant XP for gratitude

View File

@@ -452,15 +452,15 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error {
}
// Wordle
var wPlayed, wSolved, wGuesses int
var wPlayed, wSolved, wGuesses, wEarned int
err = d.QueryRow(
`SELECT puzzles_played, puzzles_solved, total_guesses
`SELECT puzzles_played, puzzles_solved, total_guesses, total_earned
FROM wordle_stats WHERE user_id = ?`, uid,
).Scan(&wPlayed, &wSolved, &wGuesses)
).Scan(&wPlayed, &wSolved, &wGuesses, &wEarned)
if err == nil && wPlayed > 0 {
avg := float64(wGuesses) / float64(wPlayed)
sb.WriteString(fmt.Sprintf(" 🟩 Wordle: %d/%d solved · %.1f avg guesses\n",
wSolved, wPlayed, avg))
sb.WriteString(fmt.Sprintf(" 🟩 Wordle: %d/%d solved · %.1f avg guesses · €%d earned\n",
wSolved, wPlayed, avg, wEarned))
hasGames = true
}
@@ -476,8 +476,9 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error {
if tFastest > 0 {
fastStr = fmt.Sprintf(" · fastest: %.1fs", float64(tFastest)/1000)
}
sb.WriteString(fmt.Sprintf(" 🧠 Trivia: %d/%d correct%s\n",
tCorrect, tCorrect+tWrong, fastStr))
pct := float64(tCorrect) / float64(tCorrect+tWrong) * 100
sb.WriteString(fmt.Sprintf(" 🧠 Trivia: %d/%d correct (%.0f%%)%s\n",
tCorrect, tCorrect+tWrong, pct, fastStr))
hasGames = true
}
@@ -487,16 +488,33 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error {
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN result = 'win' THEN 1 ELSE 0 END), 0)
FROM uno_games WHERE player_id = ?`, uid,
).Scan(&unoSingleTotal, &unoSingleWins)
var unoSingleEarned float64
_ = d.QueryRow(
`SELECT COALESCE(SUM(CASE
WHEN result IN ('player_win','sudden_death_player') THEN pot_before - pot_after
ELSE -wager END), 0)
FROM uno_games WHERE player_id = ?`, uid,
).Scan(&unoSingleEarned)
var unoMultiWins, unoMultiTotal int
_ = d.QueryRow(
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN winner_id = ? THEN 1 ELSE 0 END), 0)
FROM uno_multi_games WHERE player_ids LIKE ?`, uid, "%"+uid+"%",
).Scan(&unoMultiTotal, &unoMultiWins)
var unoMultiEarned float64
_ = d.QueryRow(
`SELECT COALESCE(SUM(CASE WHEN winner_id = ? THEN pot_total - ante ELSE -ante END), 0)
FROM uno_multi_games WHERE player_ids LIKE ?`, uid, "%"+uid+"%",
).Scan(&unoMultiEarned)
unoTotal := unoSingleTotal + unoMultiTotal
unoWins := unoSingleWins + unoMultiWins
unoEarned := unoSingleEarned + unoMultiEarned
if unoTotal > 0 {
sb.WriteString(fmt.Sprintf(" 🎴 UNO: %d/%d W/L\n",
unoWins, unoTotal-unoWins))
sign := "+"
if unoEarned < 0 {
sign = ""
}
sb.WriteString(fmt.Sprintf(" 🎴 UNO: %d/%d W/L · %s€%.0f net\n",
unoWins, unoTotal-unoWins, sign, unoEarned))
hasGames = true
}

View File

@@ -20,16 +20,26 @@ var urlRe = regexp.MustCompile(`https?://[^\s<>"]+`)
// URLsPlugin detects URLs in messages and previews og:title/og:description.
type URLsPlugin struct {
Base
enabled bool
httpClient *http.Client
enabled bool
ignoreUsers map[string]struct{}
httpClient *http.Client
}
// NewURLsPlugin creates a new URL preview plugin.
func NewURLsPlugin(client *mautrix.Client) *URLsPlugin {
enabled := os.Getenv("FEATURE_URL_PREVIEW") != ""
ignore := make(map[string]struct{})
if raw := os.Getenv("URL_PREVIEW_IGNORE_USERS"); raw != "" {
for _, u := range strings.Split(raw, ",") {
if u = strings.TrimSpace(u); u != "" {
ignore[u] = struct{}{}
}
}
}
return &URLsPlugin{
Base: NewBase(client),
enabled: enabled,
Base: NewBase(client),
enabled: enabled,
ignoreUsers: ignore,
httpClient: &http.Client{
Timeout: 3 * time.Second,
},
@@ -56,6 +66,11 @@ func (p *URLsPlugin) OnMessage(ctx MessageContext) error {
return nil
}
// Skip ignored users (e.g. news bots)
if _, ok := p.ignoreUsers[string(ctx.Sender)]; ok {
return nil
}
allURLs := urlRe.FindAllString(ctx.Body, -1)
// Filter out Matrix internal links (user mentions, room links, etc.)
var urls []string

View File

@@ -685,6 +685,11 @@ func (p *WordlePlugin) awardPrize(puzzle *WordlePuzzle) []WordlePayout {
}
p.euro.Credit(uid, float64(amount), "wordle_win")
payouts = append(payouts, WordlePayout{Name: c.name, Amount: amount, Solver: c.solver})
// Track earnings in wordle_stats
d := db.Get()
d.Exec(`UPDATE wordle_stats SET total_earned = total_earned + ? WHERE user_id = ?`,
amount, string(uid))
}
return payouts
}