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

@@ -30,7 +30,7 @@ Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption a
- **E2EE that actually works** - mautrix-go with goolm (pure Go). Crypto state lives in SQLite so device keys survive restarts. Cross-signing bootstraps on first run — the bot self-verifies its own device. - **E2EE that actually works** - mautrix-go with goolm (pure Go). Crypto state lives in SQLite so device keys survive restarts. Cross-signing bootstraps on first run — the bot self-verifies its own device.
- **No CGo, no system deps** - builds to a single static binary. Cross-compile to whatever you want. - **No CGo, no system deps** - builds to a single static binary. Cross-compile to whatever you want.
- **50 plugins** with dependency injection and ordered registration - **50 plugins** with dependency injection and ordered registration
- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring, multilingual clue mode via DreamDict, difficulty tier display), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 24 player multiplayer via DMs, with optional No Mercy mode), Texas Hold'em (2-9 players, CFR-trained NPC bot, DM-based gameplay with Ollama coaching tips, 1-hour idle auto-close with 45-min warning), Wordle (daily cooperative, DreamDict-powered, 5-20 letter words, guess persistence across restarts, midnight expiry announcements, video game themed bonus words with category hints, dupe prevention across last 500 puzzles), Adventure (daily idle RPG via DMs — dungeon, mine, forage, shop, or rest with TwinBee NPC distributing level-scaled rewards, mid-day random events, tier shorthand buying, holiday double actions), Arena (5-tier combat gauntlet with 20 unique monsters, risk-reward cashout system, death lockout, leaderboard), all with channel restriction - **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring, multilingual clue mode via DreamDict, difficulty tier display), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 24 player multiplayer via DMs, with optional No Mercy mode), Texas Hold'em (2-9 players, CFR-trained NPC bot, DM-based gameplay with Ollama coaching tips, 1-hour idle auto-close with 45-min warning), Wordle (daily cooperative, DreamDict-powered, 5-20 letter words, guess persistence across restarts, midnight expiry announcements, video game themed bonus words with category hints, dupe prevention across last 500 puzzles, earnings tracked in stats), Adventure (daily idle RPG via DMs — dungeon, mine, forage, fish, shop, or rest with TwinBee NPC distributing level-scaled rewards, mid-day random events, tier shorthand buying, holiday double actions, hospital revival system, Robbie the Friendly Bandit automated inventory cleaner), Arena (5-tier combat gauntlet with 20 unique monsters, risk-reward cashout system, death lockout, leaderboard), all with channel restriction
- **Moderation system** (optional) - deterministic detection only, no LLM. Word list with leetspeak variation matching, text/image flood, repeated messages, mention flooding, link rate limiting, invite flooding, join/leave cycling. Three-strike ladder (warn → mute → ban). Admin room notifications, DMs over public callouts. - **Moderation system** (optional) - deterministic detection only, no LLM. Word list with leetspeak variation matching, text/image flood, repeated messages, mention flooding, link rate limiting, invite flooding, join/leave cycling. Three-strike ladder (warn → mute → ban). Admin room notifications, DMs over public callouts.
- **Passive tracking** - XP, stats, streaks, achievements, markov corpus, keyword alerts, all running silently - **Passive tracking** - XP, stats, streaks, achievements, markov corpus, keyword alerts, all running silently
- **Scheduled posts** via [robfig/cron](https://github.com/robfig/cron) - Palavra do Dia (Portuguese WOTD with en/fr translations and etymology), holidays, game releases, birthdays, anime/movie releases, concert digests, esteemed members - **Scheduled posts** via [robfig/cron](https://github.com/robfig/cron) - Palavra do Dia (Portuguese WOTD with en/fr translations and etymology), holidays, game releases, birthdays, anime/movie releases, concert digests, esteemed members
@@ -152,6 +152,7 @@ Everything is configured through environment variables or a `.env` file.
| `FEATURE_MODERATION` | Set to `true` to enable the moderation system (disabled by default) | | `FEATURE_MODERATION` | Set to `true` to enable the moderation system (disabled by default) |
| `FEATURE_MARKET` | Set to `true` to enable the market overview plugin | | `FEATURE_MARKET` | Set to `true` to enable the market overview plugin |
| `MARKET_BROADCAST_SUMMARY` | Set to `true` to auto-post daily market summary to broadcast rooms | | `MARKET_BROADCAST_SUMMARY` | Set to `true` to auto-post daily market summary to broadcast rooms |
| `DISABLE_WOTD_POST` | Set to `true` to suppress daily WOTD auto-post (the `!wotd` command and passive WOTD usage tracking still work) |
### Games & Economy ### Games & Economy
@@ -284,7 +285,7 @@ Rep is earned when someone thanks you. The bot detects this automatically.
### Stats & Personality ### Stats & Personality
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `!stats [@user]` | Message statistics | | `!stats [@user]` | Message statistics (includes Wordle and UNO earnings) |
| `!superstatsexplusalpha [@user]` | Comprehensive profile: economy, games W/L, adventure levels, achievements, and more | | `!superstatsexplusalpha [@user]` | Comprehensive profile: economy, games W/L, adventure levels, achievements, and more |
| `!rankings [category]` | Rankings by words, links, questions, or emojis | | `!rankings [category]` | Rankings by words, links, questions, or emojis |
| `!personality` | Your community archetype | | `!personality` | Your community archetype |
@@ -470,17 +471,17 @@ Daily cooperative Wordle — one puzzle per day, the community works together wi
| `!wordle skip` | Reveal answer and end puzzle (admin) | | `!wordle skip` | Reveal answer and end puzzle (admin) |
| `!wordle help` | Show commands | | `!wordle help` | Show commands |
No economy integration — stats and leaderboard position are the reward. Economy rewards are tracked per player — `!wordle stats` shows total earnings.
### Adventure (DM-based idle RPG) ### Adventure (DM-based idle RPG)
A daily DM-driven idle RPG where each player takes one action per day — dungeon, mine, fish, forage, visit the shop, or rest. Outcomes resolve with flavor text and loot is credited to your euro balance. An evening summary posts to the games room. TwinBee is a permanent NPC adventurer who distributes rewards to active players. A daily DM-driven idle RPG where each player takes one action per day — dungeon, mine, fish, forage, visit the shop, or rest. Outcomes resolve with flavor text and loot is credited to your euro balance. An evening summary posts to the games room. TwinBee is a permanent NPC adventurer who distributes rewards to active players.
Characters auto-create on first `!adventure` command. All gameplay happens in DMs — reply to the bot's morning prompt with your choice. DM replies are only interpreted as adventure choices for 15 minutes after a menu is sent, so other DM-based games (UNO, Hold'em) won't conflict. Characters auto-create on first `!adventure` (or `!adv`) command. All gameplay happens in DMs — reply to the bot's morning prompt with your choice. DM replies are only interpreted as adventure choices for 15 minutes after a menu is sent, so other DM-based games (UNO, Hold'em) won't conflict.
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `!adventure` | Open today's action menu (sent via DM) | | `!adventure` / `!adv` | Open today's action menu (sent via DM) |
| `!adventure status` | Character sheet (sent via DM) | | `!adventure status` | Character sheet (sent via DM) |
| `!adventure shop` | Browse equipment for sale (sent via DM) | | `!adventure shop` | Browse equipment for sale (sent via DM) |
| `!adventure buy <item>` | Buy equipment by name or tier shorthand (`3 sword`, `t4 boots`) | | `!adventure buy <item>` | Buy equipment by name or tier shorthand (`3 sword`, `t4 boots`) |
@@ -492,6 +493,7 @@ Characters auto-create on first `!adventure` command. All gameplay happens in DM
| `!adventure revive @user` | Revive a dead player (admin) | | `!adventure revive @user` | Revive a dead player (admin) |
| `!adventure respond <choice>` | Reply to today's action prompt (alternative to DM reply) | | `!adventure respond <choice>` | Reply to today's action prompt (alternative to DM reply) |
| `!adventure summary` | Force daily summary post (admin) | | `!adventure summary` | Force daily summary post (admin) |
| `!hospital` | Check in to St. Guildmore's Memorial Hospital for same-day revival (costs €25k × combat level) |
**DM replies:** Reply to the morning prompt with a number (`1``5`) or activity name (`dungeon`, `mine`, `forage`, `shop`, `rest`). You can specify a location: `1 Soggy Cellar`, `mine 3`, etc. **DM replies:** Reply to the morning prompt with a number (`1``5`) or activity name (`dungeon`, `mine`, `forage`, `shop`, `rest`). You can specify a location: `1 Soggy Cellar`, `mine 3`, etc.
@@ -509,12 +511,14 @@ Four activity types across 5 tiers of locations. Higher tiers require higher cha
- **Equipment** — 5 slots (weapon, armor, helmet, boots, tool) with tiered upgrades from the shop. Equipment degrades on bad outcomes and breaks at 0 condition. - **Equipment** — 5 slots (weapon, armor, helmet, boots, tool) with tiered upgrades from the shop. Equipment degrades on bad outcomes and breaks at 0 condition.
- **Masterwork gear** — rare skill-specific equipment drops from gathering activities (mining, fishing, foraging). 15 items across 5 tiers with decreasing drop rates (5% T1 down to 0.5% T5). Location-gated — drops only at matching tier. Auto-equips if better than current gear; otherwise goes to inventory for manual equipping via `!adventure equip`. Masterwork items provide a +5% skill bonus when the item's source skill matches the current activity. Character sheet marks masterwork items with a star. - **Masterwork gear** — rare skill-specific equipment drops from gathering activities (mining, fishing, foraging). 15 items across 5 tiers with decreasing drop rates (5% T1 down to 0.5% T5). Location-gated — drops only at matching tier. Auto-equips if better than current gear; otherwise goes to inventory for manual equipping via `!adventure equip`. Masterwork items provide a +5% skill bonus when the item's source skill matches the current activity. Character sheet marks masterwork items with a star.
- **Treasures** — rare collectibles (up to 3) that provide passive bonuses (XP multipliers, death chance reduction, loot quality). Prompted to discard when at cap. - **Treasures** — rare collectibles (up to 3) that provide passive bonuses (XP multipliers, death chance reduction, loot quality). Prompted to discard when at cap.
- **Streaks** — consecutive days of activity grant escalating bonuses (XP, loot quality, death chance reduction). Resting or dying resets your streak. - **Streaks** — consecutive days of activity grant escalating bonuses (XP, loot quality, death chance reduction). Resting resets your streak. Dead players' streaks are frozen — you won't lose your streak to involuntary downtime.
- **Grudge** — dying at a location marks it as your grudge. Returning there grants +10% success and +25% XP. Clears on success. - **Grudge** — dying at a location marks it as your grudge. Returning there grants +10% success and +25% XP. Clears on success.
- **Party bonus** — if two players independently visit the same location on the same day, both get +10% loot value. - **Party bonus** — if two players independently visit the same location on the same day, both get +10% loot value.
- **Death** — locked out until midnight UTC. You're automatically revived when the next day starts. Death's Reprieve (surviving a lethal roll) sets all equipment to 1 condition instead of destroying it. - **Death** — locked out for 6 hours (or until midnight, whichever comes first). Natural respawn happens automatically. Use `!hospital` for same-day revival at a cost. Death's Reprieve (surviving a lethal roll) sets all equipment to 1 condition instead of destroying it. Dead players' streaks are preserved with a grace period — if you die and can't act on revival day, your streak won't reset.
- **Holidays** — on recognized holidays (~20/year across religious and cultural calendars), you get a second daily action. Hebrew and Islamic calendar support for floating holidays. - **Holidays** — on recognized holidays (~20/year across religious and cultural calendars), you get a second daily action. Hebrew and Islamic calendar support for floating holidays.
- **TwinBee NPC** — takes a daily action (location tier capped by best player's combined level), distributes loot share to active players scaled quadratically by level, and occasionally gifts random buffs. - **TwinBee NPC** — takes a daily action (location tier capped by best player's combined level), distributes loot share to active players scaled quadratically by level, and occasionally gifts random buffs.
- **Hospital** — St. Guildmore's Memorial Hospital offers same-day revival for dead players. The bill is comically inflated (€125k × combat level) but guild insurance covers 80%, leaving €25k × combat level. Players who can't afford it are discharged back to the natural respawn queue. Nurse Joy provides the bedside manner.
- **Robbie the Friendly Bandit** — an automated NPC who visits at a random hour each day (8:0021:00 UTC). Robbie takes sub-tier gear from your inventory (shop gear below your equipped tier, masterwork gear you've outgrown), leaves €50 per item as a "handling fee," and donates everything to the community pot. If he takes masterwork gear and you don't already have one, he drops a "Get Out of Medical Debt Free" card. No player command — Robbie comes to you.
- **Mid-day events** — random events can trigger between actions, delivering bonus loot, buffs, or narrative encounters. - **Mid-day events** — random events can trigger between actions, delivering bonus loot, buffs, or narrative encounters.
#### Arena (`!arena`) #### Arena (`!arena`)
@@ -753,7 +757,7 @@ All of these run in the background without any commands:
- **URL previews** - OG tag extraction (feature-flagged, off by default) - **URL previews** - OG tag extraction (feature-flagged, off by default)
- **Reactions** - logs all reactions for `!emojiboard` - **Reactions** - logs all reactions for `!emojiboard`
- **Space groups** - rooms with sufficient member overlap are automatically grouped. Leaderboards, trivia scores, and other per-room features aggregate across the group. Recomputed hourly; persisted to SQLite. Uses strict clique-based grouping (every room must meet the threshold with every other room in the group). - **Space groups** - rooms with sufficient member overlap are automatically grouped. Leaderboards, trivia scores, and other per-room features aggregate across the group. Recomputed hourly; persisted to SQLite. Uses strict clique-based grouping (every room must meet the threshold with every other room in the group).
- **LLM classification** - sentiment (10 categories), profanity, insults, WOTD usage (needs Ollama) - **LLM classification** - sentiment (10 categories), profanity, insults, WOTD usage (needs Ollama). Emoji reactions limited to fancy words (🎓), gratitude (💜), WOTD usage (📖), and insult responses
- **Message buffer** - last 50 messages per room held in memory for `!vibe` and `!tldr`. Not persisted to disk; resets on restart. Uptime reported when insufficient messages are buffered. - **Message buffer** - last 50 messages per room held in memory for `!vibe` and `!tldr`. Not persisted to disk; resets on restart. Uptime reported when insufficient messages are buffered.
--- ---
@@ -764,10 +768,9 @@ Uses [robfig/cron](https://github.com/robfig/cron). All times UTC.
| Time | Job | What it does | | Time | Job | What it does |
|------|-----|--------------| |------|-----|--------------|
| 00:05 | Prefetch | Grabs WOTD data ahead of time |
| 06:00 | Birthdays | Birthday shoutouts + 100 XP + €1,000 | | 06:00 | Birthdays | Birthday shoutouts + 100 XP + €1,000 |
| 07:00 | Holidays | Multi-calendar holidays (US, Asian, Jewish, Islamic) | | 07:00 | Holidays | Multi-calendar holidays (US, Asian, Jewish, Islamic) |
| 08:00 | WOTD | Posts the Word of the Day | | 08:00 | WOTD | Posts the Word of the Day (disable with `DISABLE_WOTD_POST=true`) |
| 09:00 Mon | Releases | Weekly game releases | | 09:00 Mon | Releases | Weekly game releases |
| 10:00 | Anime | Anime airing today | | 10:00 | Anime | Anime airing today |
| 11:00 | Movies | Movie releases today | | 11:00 | Movies | Movie releases today |
@@ -840,22 +843,22 @@ Assigned based on your message patterns:
## Sentiment Classification ## Sentiment Classification
Every message (subject to `LLM_SAMPLE_RATE`) is classified by Ollama into one of 10 sentiment categories. The bot reacts with an emoji when the confidence score is strong enough (|score| > 0.5). Per-user counts are tracked in the database and viewable via `!sentiment`. Every message (subject to `LLM_SAMPLE_RATE`) is classified by Ollama into one of 10 sentiment categories. Per-user counts are tracked in the database and viewable via `!sentiment`. The LLM also returns a float score (-1.0 to 1.0) for each message, averaged per user to derive an overall mood shown in `!sentiment` output and fed into `!howami` roast profiles.
| Sentiment | Emoji | Score range | Example | Selective emoji reactions are still active: 🎓 for fancy/uncommon words, 💜 for gratitude, and 📖 for WOTD usage. The bot also reacts when insulted. Sentiment and profanity emoji reactions are disabled — classification still runs and logs to the database, but the bot won't react with sentiment emojis.
|-----------|-------|-------------|---------|
| Positive | 👍 | > 0.5 | "This is awesome, great work!" |
| Excited | 🔥 | > 0.5 | "OH MY GOD I can't wait for this!!" |
| Supportive | 🤗 | > 0.5 | "You've got this, don't give up" |
| Grateful | 💜 | > 0.5 | "Thank you so much for helping me" |
| Humorous | 😂 | > 0.5 | "lmao that's the funniest thing I've seen all day" |
| Curious | 🧐 | > 0.5 | "How does that work exactly?" |
| Neutral | — | — | "I'll be back in 10 minutes" |
| Sarcastic | 🤨 | < -0.5 | "Oh sure, that'll definitely work" |
| Frustrated | 😮‍💨 | < -0.5 | "I've been trying to fix this for three hours" |
| Negative | 👎 | < -0.5 | "This is broken and nobody cares" |
The LLM also returns a float score (-1.0 to 1.0) for each message. These scores are averaged per user to derive an overall mood shown in `!sentiment` output and fed into `!howami` roast profiles. | Sentiment | Score range | Example |
|-----------|-------------|---------|
| Positive | > 0.5 | "This is awesome, great work!" |
| Excited | > 0.5 | "OH MY GOD I can't wait for this!!" |
| Supportive | > 0.5 | "You've got this, don't give up" |
| Grateful | > 0.5 | "Thank you so much for helping me" |
| Humorous | > 0.5 | "lmao that's the funniest thing I've seen all day" |
| Curious | > 0.5 | "How does that work exactly?" |
| Neutral | — | "I'll be back in 10 minutes" |
| Sarcastic | < -0.5 | "Oh sure, that'll definitely work" |
| Frustrated | < -0.5 | "I've been trying to fix this for three hours" |
| Negative | < -0.5 | "This is broken and nobody cares" |
--- ---
@@ -981,6 +984,8 @@ gogobee/
│ │ ├── adventure_twinbee.go # TwinBee NPC, reward distribution, buff gifts │ │ ├── adventure_twinbee.go # TwinBee NPC, reward distribution, buff gifts
│ │ ├── adventure_render.go # Character sheet, DMs, daily summary, leaderboard │ │ ├── adventure_render.go # Character sheet, DMs, daily summary, leaderboard
│ │ ├── adventure_events.go # Mid-day random events │ │ ├── adventure_events.go # Mid-day random events
│ │ ├── adventure_hospital.go # Hospital revival system (Nurse Joy, itemized bill, nudges)
│ │ ├── adventure_robbie.go # Robbie the Friendly Bandit (automated inventory cleaner)
│ │ ├── adventure_scheduler.go # Morning DM, evening summary, midnight reset tickers │ │ ├── adventure_scheduler.go # Morning DM, evening summary, midnight reset tickers
│ │ ├── adventure_holidays.go # Holiday detection (Hebrew/Islamic calendar, fixed dates) │ │ ├── adventure_holidays.go # Holiday detection (Hebrew/Islamic calendar, fixed dates)
│ │ ├── adventure_masterwork.go # Masterwork gear (15 tiered items, drop logic, equip command) │ │ ├── adventure_masterwork.go # Masterwork gear (15 tiered items, drop logic, equip command)
@@ -988,7 +993,7 @@ gogobee/
│ │ ├── adventure_arena_combat.go # Arena combat log generator (action pools, HP narration) │ │ ├── adventure_arena_combat.go # Arena combat log generator (action pools, HP narration)
│ │ ├── adventure_arena_monsters.go # 5 tiers, 20 monsters, death messages │ │ ├── adventure_arena_monsters.go # 5 tiers, 20 monsters, death messages
│ │ ├── adventure_arena_render.go # Arena UI rendering (menus, stats, leaderboard) │ │ ├── adventure_arena_render.go # Arena UI rendering (menus, stats, leaderboard)
│ │ ├── adventure_flavor_*.go # Flavor text pools (dungeon, mining, fishing, treasure, twinbee, events, closing) │ │ ├── adventure_flavor_*.go # Flavor text pools (dungeon, mining, fishing, treasure, twinbee, events, hospital, robbie, closing)
│ │ ├── esteemed.go # Satirical esteemed member posts │ │ ├── esteemed.go # Satirical esteemed member posts
│ │ ├── moderation.go # Moderation system (strikes, word list, flood detection) │ │ ├── moderation.go # Moderation system (strikes, word list, flood detection)
│ │ └── ratelimits.go # Rate limiting │ │ └── ratelimits.go # Rate limiting

View File

@@ -81,6 +81,7 @@ func runMigrations(d *sql.DB) error {
`ALTER TABLE adventure_characters ADD COLUMN babysit_skill_focus TEXT NOT NULL DEFAULT ''`, `ALTER TABLE adventure_characters ADD COLUMN babysit_skill_focus TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_characters ADD COLUMN hospital_visits INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE adventure_characters ADD COLUMN hospital_visits INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN robbie_visit_count INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE adventure_characters ADD COLUMN robbie_visit_count INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE wordle_stats ADD COLUMN total_earned INTEGER NOT NULL DEFAULT 0`,
} }
for _, stmt := range columnMigrations { for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil { if _, err := d.Exec(stmt); err != nil {

View File

@@ -28,6 +28,7 @@ type AdventurePlugin struct {
arenaDeadlines sync.Map // userID string -> time.Time (auto-cashout deadline) arenaDeadlines sync.Map // userID string -> time.Time (auto-cashout deadline)
arenaPending sync.Map // userID string -> int (pending tier number awaiting confirmation) arenaPending sync.Map // userID string -> int (pending tier number awaiting confirmation)
shopSessions sync.Map // userID string -> *advShopSession shopSessions sync.Map // userID string -> *advShopSession
hospitalNudges sync.Map // userID string -> time.Time (when to send nudge)
morningHour int morningHour int
summaryHour int summaryHour int
} }
@@ -120,6 +121,7 @@ func (p *AdventurePlugin) Init() error {
go p.arenaAutoCashoutTicker() go p.arenaAutoCashoutTicker()
go p.rivalChallengeTicker() go p.rivalChallengeTicker()
go p.robbieTicker() go p.robbieTicker()
go p.hospitalNudgeTicker()
// Auto-cashout any arena runs left in 'awaiting' from a prior restart // Auto-cashout any arena runs left in 'awaiting' from a prior restart
p.arenaCleanupStaleRuns() p.arenaCleanupStaleRuns()
@@ -171,7 +173,7 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
} }
// 3. Command dispatch // 3. Command dispatch
if !p.IsCommand(ctx.Body, "adventure") { if !p.IsCommand(ctx.Body, "adventure") && !p.IsCommand(ctx.Body, "adv") {
return nil return nil
} }
@@ -180,6 +182,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error { func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "adventure")) 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) lower := strings.ToLower(args)
switch { switch {
@@ -491,12 +496,13 @@ func (p *AdventurePlugin) handleDMReply(ctx MessageContext) error {
body := strings.TrimSpace(ctx.Body) body := strings.TrimSpace(ctx.Body)
// Skip if it looks like a command for another plugin // 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 return nil
} }
// Strip !adventure prefix if present — dispatch directly to avoid recursion // Strip !adventure / !adv prefix if present — dispatch directly to avoid recursion
if strings.HasPrefix(strings.ToLower(body), "!adventure") { if strings.HasPrefix(lower, "!adventure") || strings.HasPrefix(lower, "!adv") {
return p.dispatchCommand(ctx) 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. // 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 // Shuffle and pick 8-12 items from the pool
pool := make([]hospitalBillItem, len(hospitalBillPool)) pool := make([]hospitalBillItem, len(hospitalBillPool))
copy(pool, hospitalBillPool) copy(pool, hospitalBillPool)
@@ -218,6 +218,27 @@ func generateItemizedBill(total int64, hospitalVisits int, isHoliday bool) strin
sb.WriteString(strings.Repeat(" ", padding)) sb.WriteString(strings.Repeat(" ", padding))
sb.WriteString(formatBillAmount(total)) sb.WriteString(formatBillAmount(total))
sb.WriteByte('\n') 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("```") sb.WriteString("```")
return sb.String() return sb.String()

View File

@@ -75,8 +75,8 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
sb.WriteString(admission) sb.WriteString(admission)
sb.WriteString("\n\n") sb.WriteString("\n\n")
// Act 2 — The Bill (before insurance) // Act 2 — The Bill
sb.WriteString(generateItemizedBill(beforeInsurance, char.HospitalVisits, isHol)) sb.WriteString(generateItemizedBill(beforeInsurance, afterInsurance, char.HospitalVisits, isHol))
sb.WriteString("\n\n") sb.WriteString("\n\n")
delivery, _ := advPickFlavor(nurseJoyBillDelivery, ctx.Sender, "hospital_delivery") delivery, _ := advPickFlavor(nurseJoyBillDelivery, ctx.Sender, "hospital_delivery")
@@ -210,7 +210,8 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
// ── Hospital Ad (sent after death) ───────────────────────────────────────── // ── Hospital Ad (sent after death) ─────────────────────────────────────────
func (p *AdventurePlugin) sendHospitalAd(userID id.UserID, char *AdventureCharacter) { 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" respawnTime := "unknown"
if char.DeadUntil != nil { if char.DeadUntil != nil {
respawnTime = char.DeadUntil.Format("15:04") respawnTime = char.DeadUntil.Format("15:04")
@@ -220,34 +221,49 @@ func (p *AdventurePlugin) sendHospitalAd(userID id.UserID, char *AdventureCharac
"🏥 **St. Guildmore's Memorial Hospital**\n\n"+ "🏥 **St. Guildmore's Memorial Hospital**\n\n"+
"Nurse Joy has been notified. A bed is being prepared.\n\n"+ "Nurse Joy has been notified. A bed is being prepared.\n\n"+
"Type `!hospital` to check in for same-day revival.\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.", "Or rest up — natural respawn at %s UTC.",
cost, respawnTime) beforeInsurance, beforeInsurance-afterInsurance, afterInsurance, respawnTime)
go func() { time.AfterFunc(10*time.Second, func() {
time.Sleep(3 * time.Second)
if err := p.SendDM(userID, text); err != nil { if err := p.SendDM(userID, text); err != nil {
slog.Error("hospital: failed to send hospital ad", "user", userID, "err", err) 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) { func (p *AdventurePlugin) hospitalNudgeTicker() {
go func() { ticker := time.NewTicker(1 * time.Minute)
time.Sleep(2 * time.Hour) defer ticker.Stop()
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
}
// Due — remove regardless of outcome
p.hospitalNudges.Delete(uid)
userID := id.UserID(uid)
char, err := loadAdvCharacter(userID) char, err := loadAdvCharacter(userID)
if err != nil || char.Alive { if err != nil || char.Alive {
return // already revived or error return true
} }
// Don't nudge if already in hospital flow // Don't nudge if already in hospital flow
if val, ok := p.pending.Load(string(userID)); ok { if v, ok := p.pending.Load(uid); ok {
if pi, ok := val.(*advPendingInteraction); ok && pi.Type == "hospital_pay" { if pi, ok := v.(*advPendingInteraction); ok && pi.Type == "hospital_pay" {
return return true
} }
} }
@@ -255,5 +271,7 @@ func (p *AdventurePlugin) scheduleHospitalNudge(userID id.UserID) {
if err := p.SendDM(userID, text); err != nil { if err := p.SendDM(userID, text); err != nil {
slog.Error("hospital: failed to send nudge", "user", userID, "err", err) 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 dmsSent := 0
for _, char := range chars { for _, char := range chars {
// Dead players freeze their streak — death is involuntary, don't punish it.
if !char.Alive { if !char.Alive {
continue continue
} }
if !char.ActionTakenToday { 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 // Jitter between DMs to avoid Matrix rate limits
if dmsSent > 0 { if dmsSent > 0 {
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond) 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 // Sentiment/profanity emoji reactions disabled — logging continues above.
sentimentEmojis := map[string]string{ // Insult-response reactions (bot reacts when insulted) remain active above.
"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") // 🫣
}
}
if result.WOTDUsed { if result.WOTDUsed {
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f4d6") // 📖 open book _ = p.SendReact(item.RoomID, item.EventID, "\U0001f4d6") // 📖 open book
} }
if result.GratitudeTarget != "" { 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 // Grant XP for gratitude

View File

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

View File

@@ -21,15 +21,25 @@ var urlRe = regexp.MustCompile(`https?://[^\s<>"]+`)
type URLsPlugin struct { type URLsPlugin struct {
Base Base
enabled bool enabled bool
ignoreUsers map[string]struct{}
httpClient *http.Client httpClient *http.Client
} }
// NewURLsPlugin creates a new URL preview plugin. // NewURLsPlugin creates a new URL preview plugin.
func NewURLsPlugin(client *mautrix.Client) *URLsPlugin { func NewURLsPlugin(client *mautrix.Client) *URLsPlugin {
enabled := os.Getenv("FEATURE_URL_PREVIEW") != "" 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{ return &URLsPlugin{
Base: NewBase(client), Base: NewBase(client),
enabled: enabled, enabled: enabled,
ignoreUsers: ignore,
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 3 * time.Second, Timeout: 3 * time.Second,
}, },
@@ -56,6 +66,11 @@ func (p *URLsPlugin) OnMessage(ctx MessageContext) error {
return nil 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) allURLs := urlRe.FindAllString(ctx.Body, -1)
// Filter out Matrix internal links (user mentions, room links, etc.) // Filter out Matrix internal links (user mentions, room links, etc.)
var urls []string var urls []string

View File

@@ -685,6 +685,11 @@ func (p *WordlePlugin) awardPrize(puzzle *WordlePuzzle) []WordlePayout {
} }
p.euro.Credit(uid, float64(amount), "wordle_win") p.euro.Credit(uid, float64(amount), "wordle_win")
payouts = append(payouts, WordlePayout{Name: c.name, Amount: amount, Solver: c.solver}) 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 return payouts
} }

View File

@@ -372,7 +372,6 @@ func setupScheduledJobs(
// Prefetch at 00:05 — grab data ahead of scheduled posts // Prefetch at 00:05 — grab data ahead of scheduled posts
c.AddFunc("5 0 * * *", func() { c.AddFunc("5 0 * * *", func() {
slog.Info("scheduler: prefetching daily data") slog.Info("scheduler: prefetching daily data")
wotd.Prefetch()
holidays.Prefetch() holidays.Prefetch()
}) })
@@ -393,12 +392,16 @@ func setupScheduledJobs(
}) })
// WOTD post at 08:00 // WOTD post at 08:00
if strings.ToLower(os.Getenv("DISABLE_WOTD_POST")) != "true" {
c.AddFunc("0 8 * * *", func() { c.AddFunc("0 8 * * *", func() {
slog.Info("scheduler: posting WOTD") slog.Info("scheduler: posting WOTD")
for _, r := range rooms { for _, r := range rooms {
wotd.PostWOTD(r) wotd.PostWOTD(r)
} }
}) })
} else {
slog.Info("scheduler: WOTD daily post disabled via DISABLE_WOTD_POST")
}
// Game releases Monday 09:00 // Game releases Monday 09:00
c.AddFunc("0 9 * * 1", func() { c.AddFunc("0 9 * * 1", func() {